diff --git a/.env-sample b/.env-sample new file mode 100644 index 0000000..2db3554 --- /dev/null +++ b/.env-sample @@ -0,0 +1,29 @@ +# Copy to docker-compose/.env and set values. + +# Keycloak +KEYCLOAK_USER=admin +KEYCLOAK_PASSWORD=admin + +# Keycloak host (local default). +KEYCLOAK_URL=localhost + +# Postgres for Keycloak. +POSTGRES_USER=keycloak +POSTGRES_PASSWORD=change-me + +# App auth (OIDC) +KEYCLOAK_AUTHORITY=http://localhost:8080/realms/notebookmark +KEYCLOAK_CLIENT_ID=notebookmark +KEYCLOAK_CLIENT_SECRET=replace-with-client-secret + +# Optional +# Keycloak__RequireHttpsMetadata=false + +# AI +REKA_API_KEY=replace-with-reka-api-key + +# Storage +NB_STORAGE_OUTPUTS_TABLEENDPOINT=https://your-storage-account.table.core.windows.net/ +NB_STORAGE_OUTPUTS_BLOBENDPOINT=https://your-storage-account.blob.core.windows.net/ + +# Do not commit docker-compose/.env. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c030ef7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Squad: union merge for append-only team state files +.ai-team/decisions.md merge=union +.ai-team/agents/*/history.md merge=union +.ai-team/log/** merge=union +.ai-team/orchestration-log/** merge=union diff --git a/.github/workflows/running-unit-tests.yml b/.github/workflows/running-unit-tests.yml index 33afcb4..18ab3c1 100644 --- a/.github/workflows/running-unit-tests.yml +++ b/.github/workflows/running-unit-tests.yml @@ -15,7 +15,7 @@ permissions: pull-requests: write env: - DEFAULT_DOTNET_VERSION: "8.0.x" + DEFAULT_DOTNET_VERSION: "10.0.x" jobs: test: @@ -31,7 +31,7 @@ jobs: with: dotnet-version: | ${{ env.DEFAULT_DOTNET_VERSION }} - 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore diff --git a/.gitignore b/.gitignore index 7684431..e1d12a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,499 +1,517 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from `dotnet new gitignore` - -# dotenv files -.env - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET -project.lock.json -project.fragment.lock.json -artifacts/ - -# Tye -.tye/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -.idea - -## -## Visual studio for Mac -## - - -# globs -Makefile.in -*.userprefs -*.usertasks -config.make -config.status -aclocal.m4 -install-sh -autom4te.cache/ -*.tar.gz -tarballs/ -test-results/ - -# Mac bundle stuff -*.dmg -*.app - -# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Vim temporary swap files -*.swp - -*/bin/ - - -NoteBookmark.Api/obj/ -NoteBookmark.Api/appsettings.Development.json - -NoteBookmark.BlazorApp/appsettings.Development.json -.azure - -NoteBookmark.AppHost/appsettings.Development.json - -src/NoteBookmark.AppHost/appsettings.[Dd]evelopment.json - -src/NoteBookmark.AppHost/appsettings.json +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +*/bin/ + + +NoteBookmark.Api/obj/ +NoteBookmark.Api/appsettings.Development.json + +NoteBookmark.BlazorApp/appsettings.Development.json +src/NoteBookmark.BlazorApp/appsettings.Development.json +.azure + +NoteBookmark.AppHost/appsettings.Development.json + +src/NoteBookmark.AppHost/appsettings.[Dd]evelopment.json + +src/NoteBookmark.AppHost/appsettings.json + +# Todos folder +todos/ + +# AI Team folder +.ai-team/ +.ai-team-templates/ + +# Copilot config +.copilot/ + +# Squad/Agent files +.github/agents/ +# Squad (local AI team - not committed) +.ai-team/ + +src/NoteBookmark.BlazorApp/Data/ diff --git a/Directory.Build.props b/Directory.Build.props index 6bb3e34..c799a1a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ 1.1.2 - net9.0 + net10.0 enable enable true diff --git a/Directory.Packages.props b/Directory.Packages.props index 94ddf61..5f8e9da 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,48 +1,49 @@ - - - - - - + + + + + + - + - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + - - + + + - + - + - + \ No newline at end of file diff --git a/NoteBookmark.sln b/NoteBookmark.sln index c39b4a6..f97100e 100644 --- a/NoteBookmark.sln +++ b/NoteBookmark.sln @@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoteBookmark.Api.Tests", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoteBookmark.AIServices", "src\NoteBookmark.AIServices\NoteBookmark.AIServices.csproj", "{D29D80A5-82EC-4350-B738-96BAF88EB9DD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoteBookmark.AIServices.Tests", "src\NoteBookmark.AIServices.Tests\NoteBookmark.AIServices.Tests.csproj", "{13B6E1BC-4B32-4082-A080-FE443F598967}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,10 +115,25 @@ Global {D29D80A5-82EC-4350-B738-96BAF88EB9DD}.Release|x64.Build.0 = Release|Any CPU {D29D80A5-82EC-4350-B738-96BAF88EB9DD}.Release|x86.ActiveCfg = Release|Any CPU {D29D80A5-82EC-4350-B738-96BAF88EB9DD}.Release|x86.Build.0 = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x64.ActiveCfg = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x64.Build.0 = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x86.ActiveCfg = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x86.Build.0 = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|Any CPU.Build.0 = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x64.ActiveCfg = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x64.Build.0 = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x86.ActiveCfg = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {13B6E1BC-4B32-4082-A080-FE443F598967} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D59FFF09-97C3-47EF-B64D-B014BFA22C80} EndGlobalSection diff --git a/README.md b/README.md index cd413dd..b180fe8 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,72 @@ -# Note Bookmark - -![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/9.0-512BD4?logo=dotnet&logoColor=fff) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) [![Reka AI](https://img.shields.io/badge/Power%20By-2E2F2F?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NjYuOTQgNjgxLjI2Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiBub25lOwogICAgICB9CgogICAgICAuY2xzLTIgewogICAgICAgIGZpbGw6ICNmMWVlZTc7CiAgICAgIH0KICAgIDwvc3R5bGU%2BCiAgPC9kZWZzPgogIDxyZWN0IGNsYXNzPSJjbHMtMSIgeD0iLS4yOSIgeT0iLS4xOSIgd2lkdGg9IjY2Ny4yMiIgaGVpZ2h0PSI2ODEuMzMiLz4KICA8Zz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxOC4zNCwwTDgyLjY3LjE2QzM2Ljg1LjE5LS4yOSwzNy4zOC0uMjksODMuMjd2MjM1LjEyaDc0LjkzVjcxLjc1aDI0My43M1YwaC0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik03Mi45NywzNjIuOTdIMHYzMTguMTZoNzIuOTd2LTMxOC4xNloiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxNS4zMywzNjIuODRoLTk5LjEzbC0xMDkuNSwxMDcuMjljLTEzLjk1LDEzLjY4LTIxLjgyLDMyLjM3LTIxLjgyLDUxLjkyczcuODYsMzguMjQsMjEuODIsNTEuOTJsMTA5LjUsMTA3LjI5aDEwMS42M2wtMTYyLjQ1LTE2MS43MiwxNTkuOTUtMTU2LjY3di0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0zNDguNTksODIuOTJ2MTUyLjIzYzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMjMwLjI4di03MS43OGgtMjQwLjMzVjg1Ljg3YzAtNy43NCw2LjI4LTE0LjA2LDE0LjA1LTE0LjA2aDE0NC4zMmM3Ljc0LDAsMTQuMDUsNi4yOCwxNC4wNSwxNC4wNiwwLDUuOS0zLjcxLDExLjE3LTkuMjMsMTMuMmwtMTQ3LjQ1LDU2LjIzdjcwLjczbDE3NC41Ny02Mi4yYzMzLjA0LTExLjgsNTUuMTEtNDMuMTMsNTUuMTEtNzguMjZ2LTIuNjdDNjY3LDM3LDYyOS44LS4xOSw1ODMuOTUtLjE5aC0xNTIuMjdjLTQ1Ljg5LDAtODMuMDUsMzcuMTktODMuMDUsODMuMTFoLS4wM1oiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTY2Ni45NCw1OTguMTJ2LTE1Mi4yM2MwLTQ1Ljg5LTM3LjE2LTgzLjExLTgzLjA1LTgzLjExaC0yMzAuMjh2NzEuNzhoMjQwLjMzdjE2MC42MWMwLDcuNzQtNi4yOCwxNC4wNi0xNC4wNSwxNC4wNmgtMTQ0LjMxYy03Ljc0LDAtMTQuMDUtNi4yOC0xNC4wNS0xNC4wNiwwLTUuOSwzLjcxLTExLjE3LDkuMjMtMTMuMmwxNDcuNDUtNTYuMjN2LTcwLjczbC0xNzQuNTcsNjIuMmMtMzMuMDQsMTEuOC01NS4xMSw0My4xMy01NS4xMSw3OC4yNnYyLjY3YzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMTUyLjI3YzQ1Ljg5LDAsODMuMDUtMzcuMTksODMuMDUtODMuMTFoLjAzWiIvPgogIDwvZz4KPC9zdmc%2B&logoSize=auto&labelColor=2E2F2F&color=F1EEE7)](https://reka.ai/) - - - - -I use this project mostly everyday. I build it to help me collecting my thoughts about articles, and blob posts I read during the week and then aggregate them in a #ReadingNotes blog post. You can find those post on my blog [here](https://frankysnotes.com). - -NoteBookmark is composed of three main sections: - -- **Post**: where you can manage a posts "to read", and add notes to them. -- **Generate Summary**: where you can generate a summary of the posts you read. -- **Summaries**: where you can see all the summaries you generated. - -![Slide show of all NoteBookmark Screens](gh/images/NoteBookmark-Tour_hd.gif) - -## How to deploy Your own NoteBookmark - -### Get the code on your machine - -- Fork this repository to your account. -- Clone the repository to your local machine. - - -### Deploy the solution (5 mins) - -Using Azure Developer CLI let's initialize your environment. In a terminal, at the root of the project, run the following command. When ask give it a name (ex: NoteBookmark-dev). - -```bash -azd init -``` - -Now let's deploy the solution. Run the following command in the terminal. You will have to select your Azure subscription where you want to deploy the solution, and a location (ex: eastus). - -```bash -azd up -``` - -It should take around five minutes to deploy the solution. Once it's done, you will see the URL for **Deploying service blazor-app**. - -### Secure the App in a few clicks - -The app is now deployed, but it's not secure. Navigate to the Azure Portal, and find the Resource Group you just deployed (ex: rg-notebookmark-dev). In this resource group, open the Container App **Container App**. From the left menu, select **Authentication** and click the **Add identity provider**. - -You can choose between multiple providers, I like to use Microsoft since it's deploy in Azure and I'm already logged in. If Microsoft is choose, select the recomended **Client secret expiration** (ex: 180 days). You can keep all the other default settings. Click **Add**. - -Next time you will navigate to the app, you will be prompt a to login with your Microsoft account. The first time you will have a **Permissions requested** screen, click **Accept**. - -Voila! Your app is now secure. - -## Contributing - -Your contributions are welcome! Take a look at [CONTRIBUTING](/CONTRIBUTING.md) for details. +# Note Bookmark + +![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/10.0-512BD4?logo=dotnet&logoColor=fff) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) [![Reka AI](https://img.shields.io/badge/Power%20By-2E2F2F?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NjYuOTQgNjgxLjI2Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiBub25lOwogICAgICB9CgogICAgICAuY2xzLTIgewogICAgICAgIGZpbGw6ICNmMWVlZTc7CiAgICAgIH0KICAgIDwvc3R5bGU%2BCiAgPC9kZWZzPgogIDxyZWN0IGNsYXNzPSJjbHMtMSIgeD0iLS4yOSIgeT0iLS4xOSIgd2lkdGg9IjY2Ny4yMiIgaGVpZ2h0PSI2ODEuMzMiLz4KICA8Zz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxOC4zNCwwTDgyLjY3LjE2QzM2Ljg1LjE5LS4yOSwzNy4zOC0uMjksODMuMjd2MjM1LjEyaDc0LjkzVjcxLjc1aDI0My43M1YwaC0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik03Mi45NywzNjIuOTdIMHYzMTguMTZoNzIuOTd2LTMxOC4xNloiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxNS4zMywzNjIuODRoLTk5LjEzbC0xMDkuNSwxMDcuMjljLTEzLjk1LDEzLjY4LTIxLjgyLDMyLjM3LTIxLjgyLDUxLjkyczcuODYsMzguMjQsMjEuODIsNTEuOTJsMTA5LjUsMTA3LjI5aDEwMS42M2wtMTYyLjQ1LTE2MS43MiwxNTkuOTUtMTU2LjY3di0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0zNDguNTksODIuOTJ2MTUyLjIzYzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMjMwLjI4di03MS43OGgtMjQwLjMzVjg1Ljg3YzAtNy43NCw2LjI4LTE0LjA2LDE0LjA1LTE0LjA2aDE0NC4zMmM3Ljc0LDAsMTQuMDUsNi4yOCwxNC4wNSwxNC4wNiwwLDUuOS0zLjcxLDExLjE3LTkuMjMsMTMuMmwtMTQ3LjQ1LDU2LjIzdjcwLjczbDE3NC41Ny02Mi4yYzMzLjA0LTExLjgsNTUuMTEtNDMuMTMsNTUuMTEtNzguMjZ2LTIuNjdDNjY3LDM3LDYyOS44LS4xOSw1ODMuOTUtLjE5aC0xNTIuMjdjLTQ1Ljg5LDAtODMuMDUsMzcuMTktODMuMDUsODMuMTFoLS4wM1oiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTY2Ni45NCw1OTguMTJ2LTE1Mi4yM2MwLTQ1Ljg5LTM3LjE2LTgzLjExLTgzLjA1LTgzLjExaC0yMzAuMjh2NzEuNzhoMjQwLjMzdjE2MC42MWMwLDcuNzQtNi4yOCwxNC4wNi0xNC4wNSwxNC4wNmgtMTQ0LjMxYy03Ljc0LDAtMTQuMDUtNi4yOC0xNC4wNS0xNC4wNiwwLTUuOSwzLjcxLTExLjE3LDkuMjMtMTMuMmwxNDcuNDUtNTYuMjN2LTcwLjczbC0xNzQuNTcsNjIuMmMtMzMuMDQsMTEuOC01NS4xMSw0My4xMy01NS4xMSw3OC4yNnYyLjY3YzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMTUyLjI3YzQ1Ljg5LDAsODMuMDUtMzcuMTksODMuMDUtODMuMTFoLjAzWiIvPgogIDwvZz4KPC9zdmc%2B&logoSize=auto&labelColor=2E2F2F&color=F1EEE7)](https://reka.ai/) + + + + +I use this project mostly everyday. I build it to help me collecting my thoughts about articles, and blob posts I read during the week and then aggregate them in a #ReadingNotes blog post. You can find those post on my blog [here](https://frankysnotes.com). + +NoteBookmark is composed of three main sections: + +- **Post**: where you can manage a posts "to read", and add notes to them. +- **Generate Summary**: where you can generate a summary of the posts you read. +- **Summaries**: where you can see all the summaries you generated. + +![Slide show of all NoteBookmark Screens](gh/images/NoteBookmark-Tour_hd.gif) + +## Run Options + +- Development: running the Aspire project is the easiest path and everything is wired automatically. +- Production-style: run with containers and deploy to Azure. + +Run locally with Aspire: + +```bash +dotnet run --project src/NoteBookmark.AppHost +``` + +## How to deploy Your own NoteBookmark + +### Get the code on your machine + +- Fork this repository to your account. +- Clone the repository to your local machine. + + +### Deploy the solution (5 mins) + +Using Azure Developer CLI let's initialize your environment. In a terminal, at the root of the project, run the following command. When ask give it a name (ex: NoteBookmark-dev). + +```bash +azd init +``` + +Now let's deploy the solution. Run the following command in the terminal. You will have to select your Azure subscription where you want to deploy the solution, and a location (ex: eastus). + +```bash +azd up +``` + +It should take around five minutes to deploy the solution. Once it's done, you will see the URL for **Deploying service blazor-app**. + +### Secure the App in a few clicks + +The app is now deployed, but it's not secure. Navigate to the Azure Portal, and find the Resource Group you just deployed (ex: rg-notebookmark-dev). In this resource group, open the Container App **Container App**. From the left menu, select **Authentication** and click the **Add identity provider**. + +You can choose between multiple providers, I like to use Microsoft since it's deploy in Azure and I'm already logged in. If Microsoft is choose, select the recomended **Client secret expiration** (ex: 180 days). You can keep all the other default settings. Click **Add**. + +Next time you will navigate to the app, you will be prompt a to login with your Microsoft account. The first time you will have a **Permissions requested** screen, click **Accept**. + +Voila! Your app is now secure. + +## Documentation + +For detailed setup guides and configuration information: +- [Keycloak Container Setup](/docs/keycloak-container-setup.md) - Start a local Keycloak instance if you do not already have one +- [Keycloak Authentication Setup](/docs/keycloak-setup.md) - Complete guide for setting up Keycloak authentication +- [Docker Compose Deployment](/docs/docker-compose-deployment.md) - Deploy NoteBookmark containers (assumes a healthy Keycloak + configured realm) + +## Contributing + +Your contributions are welcome! Take a look at [CONTRIBUTING](/CONTRIBUTING.md) for details. diff --git a/docker-compose/build-and-push.ps1 b/docker-compose/build-and-push.ps1 deleted file mode 100644 index f0cdc83..0000000 --- a/docker-compose/build-and-push.ps1 +++ /dev/null @@ -1,52 +0,0 @@ -# Build and Push Docker Images Script -# Make sure you're logged in to Docker Hub first: docker login - -param( - [Parameter(Mandatory=$true)] - [string]$DockerHubUsername, - - [string]$ApiTag = "latest", - [string]$BlazorTag = "latest" -) - -Write-Host "Building and pushing Docker images for NoteBookmark..." -ForegroundColor Green - -# Build API image -Write-Host "Building API image..." -ForegroundColor Yellow -docker build -f ../src/NoteBookmark.Api/Dockerfile -t "$DockerHubUsername/notebookmark-api:$ApiTag" .. - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to build API image" - exit 1 -} - -# Build Blazor App image -Write-Host "Building Blazor App image..." -ForegroundColor Yellow -docker build -f ../src/NoteBookmark.BlazorApp/Dockerfile -t "$DockerHubUsername/notebookmark-blazor:$BlazorTag" .. - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to build Blazor App image" - exit 1 -} - -# Push API image -Write-Host "Pushing API image to Docker Hub..." -ForegroundColor Yellow -docker push "$DockerHubUsername/notebookmark-api:$ApiTag" - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to push API image" - exit 1 -} - -# Push Blazor App image -Write-Host "Pushing Blazor App image to Docker Hub..." -ForegroundColor Yellow -docker push "$DockerHubUsername/notebookmark-blazor:$BlazorTag" - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to push Blazor App image" - exit 1 -} - -Write-Host "Successfully built and pushed both images!" -ForegroundColor Green -Write-Host "API image: $DockerHubUsername/notebookmark-api:$ApiTag" -ForegroundColor Cyan -Write-Host "Blazor image: $DockerHubUsername/notebookmark-blazor:$BlazorTag" -ForegroundColor Cyan diff --git a/docker-compose/docker-compose.yaml b/docker-compose/docker-compose.yaml deleted file mode 100644 index 01fb91d..0000000 --- a/docker-compose/docker-compose.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: note-bookmark - -services: - api: - image: "fboucher/notebookmark-api:latest" - container_name: "notebookmark-api" - environment: - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" - OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" - ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" - HTTP_PORTS: "8000" - ConnectionStrings__nb-tables: "${NB_STORAGE_OUTPUTS_TABLEENDPOINT}" - ConnectionStrings__nb-blobs: "${NB_STORAGE_OUTPUTS_BLOBENDPOINT}" - ports: - - "8001:8000" - - "8003:8002" - networks: - - "aspire" - blazor-app: - image: "fboucher/notebookmark-blazor:latest" - container_name: "notebookmark-blazor" - environment: - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" - OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" - ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" - HTTP_PORTS: "8004" - services__api__http__0: "http://api:8000" - ports: - - "8005:8004" - - "8007:8006" - depends_on: - api: - condition: "service_started" - networks: - - "aspire" -networks: - aspire: - driver: "bridge" diff --git a/docker-compose/keycloak-compose.yaml b/docker-compose/keycloak-compose.yaml new file mode 100644 index 0000000..a18921f --- /dev/null +++ b/docker-compose/keycloak-compose.yaml @@ -0,0 +1,51 @@ +name: notebookmark-keycloak + +services: + keycloak_postgres: + container_name: keycloak-postgres + image: postgres:14.18 + restart: unless-stopped + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - notebookmark + + keycloak: + container_name: notebookmark-keycloak + image: quay.io/keycloak/keycloak:26.5.4 + restart: unless-stopped + command: + - start + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_USER} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_PASSWORD} + KC_HOSTNAME: ${KEYCLOAK_URL} + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://keycloak_postgres:5432/keycloak + KC_DB_USERNAME: ${POSTGRES_USER} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD} + KC_PROXY_ADDRESS_FORWARDING: "true" + KC_HTTP_ENABLED: "true" + KC_LOG_LEVEL: info + KC_FEATURES: "token-exchange" + ports: + - "8080:8080" + # Optional production TLS setup: place cert/key under docker-compose/data/certs. + # These values can remain unset for local HTTP usage. + volumes: + - ./data/certs:/etc/x509/https:ro + depends_on: + - keycloak_postgres + networks: + - notebookmark + +networks: + notebookmark: + external: true + +volumes: + postgres-data: \ No newline at end of file diff --git a/docker-compose/note-compose.yaml b/docker-compose/note-compose.yaml new file mode 100644 index 0000000..e732a12 --- /dev/null +++ b/docker-compose/note-compose.yaml @@ -0,0 +1,53 @@ +name: notebookmark-app + +services: + api: + image: fboucher/notebookmark-api:alpha-latest + container_name: notebookmark-api + restart: unless-stopped + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + HTTP_PORTS: "8000" + ConnectionStrings__nb-tables: ${NB_STORAGE_OUTPUTS_TABLEENDPOINT} + ConnectionStrings__nb-blobs: ${NB_STORAGE_OUTPUTS_BLOBENDPOINT} + ports: + - "8001:8000" + - "8003:8002" + networks: + - notebookmark + + blazor-app: + image: fboucher/notebookmark-blazor:alpha-latest + container_name: notebookmark-blazor + restart: unless-stopped + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + HTTP_PORTS: "8004" + services__api__http__0: "http://api:8000" + services__keycloak__http__0: "http://keycloak:8080" + ConnectionStrings__nb-tables: ${NB_STORAGE_OUTPUTS_TABLEENDPOINT} + ConnectionStrings__nb-blobs: ${NB_STORAGE_OUTPUTS_BLOBENDPOINT} + REKA_API_KEY: ${REKA_API_KEY} + Keycloak__Authority: ${KEYCLOAK_AUTHORITY} + Keycloak__ClientId: ${KEYCLOAK_CLIENT_ID} + Keycloak__ClientSecret: ${KEYCLOAK_CLIENT_SECRET} + volumes: + - ./dataprotection-keys:/root/.aspnet/DataProtection-Keys + ports: + - "8005:8004" + - "8007:8006" + depends_on: + api: + condition: service_started + networks: + - notebookmark + +networks: + notebookmark: + external: true \ No newline at end of file diff --git a/docs/docker-compose-deployment.md b/docs/docker-compose-deployment.md new file mode 100644 index 0000000..d48b557 --- /dev/null +++ b/docs/docker-compose-deployment.md @@ -0,0 +1,69 @@ +# Docker Compose Deployment + +This file assumes you already have: + +- A healthy Keycloak instance +- A `notebookmark` realm configured (see [`docs/keycloak-setup.md`](keycloak-setup.md)) + +If you do not have Keycloak yet, see [`docs/keycloak-container-setup.md`](keycloak-container-setup.md) first. + + +## Prerequisites + +- Docker Engine with Compose support (`docker compose`) +- `docker-compose/.env` with valid values +- Azure Storage endpoints (Table + Blob) +- Keycloak client secret for client `notebookmark` + +## 1. Prepare Environment Values + +From the repository root: + +```bash +cp .env-sample docker-compose/.env +``` + +Edit `docker-compose/.env` and set all required values. + +Important Keycloak values for NoteBookmark: + +- `KEYCLOAK_AUTHORITY` (for example `http://localhost:8080/realms/notebookmark`) +- `KEYCLOAK_CLIENT_ID` (default: `notebookmark`) +- `KEYCLOAK_CLIENT_SECRET` (from Keycloak client settings) + +## 2. Create Shared Network (One Time) + +```bash +docker network create notebookmark +``` + +Then move into the compose folder so `.env` is auto-detected: + +```bash +cd docker-compose +``` + +## 3. Start NoteBookmark App + +```bash +docker compose -f note-compose.yaml up -d +``` + +## 4. Access Services + +- Blazor App: `http://localhost:8005` +- API: `http://localhost:8001` + +## 5. Stop NoteBookmark App + +```bash +docker compose -f note-compose.yaml down +``` + +## Quick Validation + +```bash +docker compose -f note-compose.yaml config +``` + + diff --git a/docs/keycloak-container-setup.md b/docs/keycloak-container-setup.md new file mode 100644 index 0000000..9f3ed6c --- /dev/null +++ b/docs/keycloak-container-setup.md @@ -0,0 +1,74 @@ +# Keycloak Container Setup (If You Do Not Have Keycloak Yet) + +Use this file only to get a Keycloak container running for NoteBookmark. + +After Keycloak is up, continue with: + +1. [`docs/keycloak-setup.md`](keycloak-setup.md) to configure realm/client +2. [`docs/docker-compose-deployment.md`](docker-compose-deployment.md) to run NoteBookmark + +## Official References + +- Keycloak container guide: +- Keycloak configuration docs: + +## 1. Prepare Environment File + +From repository root: + +```bash +cp .env-sample docker-compose/.env +``` + +Set at least these values in `docker-compose/.env`: + +```env +KEYCLOAK_USER=admin +KEYCLOAK_PASSWORD=change-me +KEYCLOAK_URL=localhost +POSTGRES_USER=keycloak +POSTGRES_PASSWORD=change-me +``` + +## 2. Create Shared Network (One Time) + +```bash +docker network create notebookmark +``` + +Then move into the compose folder so `.env` is auto-detected: + +```bash +cd docker-compose +``` + +## 3. Start Keycloak Stack + +```bash +docker compose -f keycloak-compose.yaml up -d +``` + +This starts: + +- `keycloak_postgres` +- `keycloak` + +Keycloak admin console: `http://localhost:8080` + +## 4. Stop Keycloak Stack + +```bash +docker compose -f keycloak-compose.yaml down +``` + +Remove Keycloak database volume (deletes Keycloak data): + +```bash +docker compose -f keycloak-compose.yaml down -v +``` + +## Quick Validation + +```bash +docker compose -f keycloak-compose.yaml config +``` diff --git a/docs/keycloak-setup.md b/docs/keycloak-setup.md new file mode 100644 index 0000000..523f251 --- /dev/null +++ b/docs/keycloak-setup.md @@ -0,0 +1,62 @@ +# Keycloak Realm Setup For NoteBookmark + +This file explains only how to configure Keycloak for NoteBookmark. + +If you do not have a Keycloak server yet, use [`docs/keycloak-container-setup.md`](keycloak-container-setup.md) first. + +## Official References + +- Keycloak server administration guide: +- Keycloak securing applications (OIDC clients): + +## 1. Create Realm + +In the Keycloak admin console, create a realm named: + +- `notebookmark` + +## 2. Create OIDC Client + +In realm `notebookmark`, create a client with: + +- Client ID: `notebookmark` +- Protocol: OpenID Connect +- Client authentication: Enabled (confidential client) +- Standard flow: Enabled + +Set redirect and origin values for your app URL. + +Local example: + +- Valid redirect URIs: `http://localhost:8005/*` +- Valid post logout redirect URIs: `http://localhost:8005/*` +- Web origins: `http://localhost:8005` + +Then copy the generated client secret. + +## 3. Map Keycloak Values To NoteBookmark + +Use these values in `docker-compose/.env`: + +```env +KEYCLOAK_AUTHORITY=http://localhost:8080/realms/notebookmark +KEYCLOAK_CLIENT_ID=notebookmark +KEYCLOAK_CLIENT_SECRET=your-client-secret +``` + +These are consumed by `docker-compose/note-compose.yaml`: + +- `Keycloak__Authority: ${KEYCLOAK_AUTHORITY}` +- `Keycloak__ClientId: ${KEYCLOAK_CLIENT_ID}` +- `Keycloak__ClientSecret: ${KEYCLOAK_CLIENT_SECRET}` + +## 4. Validate Before Running NoteBookmark + +Check that: + +- Realm is exactly `notebookmark` +- Client ID is exactly `notebookmark` +- Client secret in `.env` matches Keycloak +- Redirect URI matches your app URL + +After that, run NoteBookmark using [`docs/docker-compose-deployment.md`](docker-compose-deployment.md). diff --git a/src/NoteBookmark.AIServices.Tests/NoteBookmark.AIServices.Tests.csproj b/src/NoteBookmark.AIServices.Tests/NoteBookmark.AIServices.Tests.csproj new file mode 100644 index 0000000..113669c --- /dev/null +++ b/src/NoteBookmark.AIServices.Tests/NoteBookmark.AIServices.Tests.csproj @@ -0,0 +1,36 @@ + + + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs b/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs new file mode 100644 index 0000000..513dbe5 --- /dev/null +++ b/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs @@ -0,0 +1,261 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NoteBookmark.Domain; + +namespace NoteBookmark.AIServices.Tests; + +public class ResearchServiceTests +{ + private readonly Mock> _mockLogger; + private readonly HttpClient _httpClient = new(); + + public ResearchServiceTests() + { + _mockLogger = new Mock>(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithMissingApiKey_ThrowsInvalidOperationException() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "AI" }; + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Suggestions.Should().BeNullOrEmpty(); + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithMissingApiKey_LogsError() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithApiKeyFromAppSettings_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-api-key-from-settings"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "Testing" }; + + // Act - Will fail to connect but won't throw missing config exception + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithApiKeyFromRekaEnvVar_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-reka-key"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "Testing" }; + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithCustomBaseUrl_UsesProvidedUrl() + { + // Arrange + const string customUrl = "https://custom.api.example.com/v1"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: customUrl); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithDefaultBaseUrl_UsesRekaApi() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "https://api.reka.ai/v1"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert - Should use default "https://api.reka.ai/v1" + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithCustomModelName_UsesProvidedModel() + { + // Arrange + const string customModel = "custom-model-v2"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: customModel); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithDefaultModelName_UsesRekaFlashResearch() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: "reka-flash-research"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert - Should use default "reka-flash-research" + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithInvalidUrl_ReturnsEmptyPostSuggestions() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "not-a-valid-url"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Suggestions.Should().BeNullOrEmpty(); + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_OnException_ReturnsEmptyPostSuggestions() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithValidSearchCriterias_ProcessesPrompt() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Find articles about {topic}") + { + SearchTopic = "Machine Learning", + AllowedDomains = "example.com, test.org", + BlockedDomains = "spam.com" + }; + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + var prompt = searchCriterias.GetSearchPrompt(); + prompt.Should().Contain("Machine Learning"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task SearchSuggestionsAsync_WithEmptyApiKey_ThrowsInvalidOperationException(string emptyKey) + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: emptyKey); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Suggestions.Should().BeNullOrEmpty(); + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_ApiKeyPriority_AppSettingsOverridesEnvVar() + { + // Arrange - Both AppSettings and env var set, AppSettings should take precedence + var settingsProvider = CreateSettingsProvider(apiKey: "settings-key"); + var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + private Func> CreateSettingsProvider( + string? apiKey = "test-api-key", + string? baseUrl = "https://api.reka.ai/v1", + string? modelName = "reka-flash-research") + { + return () => + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("AI API key not configured"); + } + + return Task.FromResult(( + ApiKey: apiKey, + BaseUrl: baseUrl ?? "https://api.reka.ai/v1", + ModelName: modelName ?? "reka-flash-research" + )); + }; + } + + private void VerifyErrorLogged(string expectedMessagePart) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessagePart)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/src/NoteBookmark.AIServices.Tests/SummaryServiceTests.cs b/src/NoteBookmark.AIServices.Tests/SummaryServiceTests.cs new file mode 100644 index 0000000..e4e3a1f --- /dev/null +++ b/src/NoteBookmark.AIServices.Tests/SummaryServiceTests.cs @@ -0,0 +1,253 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.AIServices.Tests; + +public class SummaryServiceTests +{ + private readonly Mock> _mockLogger; + + public SummaryServiceTests() + { + _mockLogger = new Mock>(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithMissingApiKey_ReturnsEmptyString() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Summarize this text"); + + // Assert + result.Should().BeEmpty(); + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_WithMissingApiKey_LogsError() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + await service.GenerateSummaryAsync("Test prompt"); + + // Assert + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_WithApiKeyFromAppSettings_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-api-key-from-settings"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act - Will fail to connect but won't throw missing config exception + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithApiKeyFromRekaEnvVar_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-reka-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithCustomBaseUrl_UsesProvidedUrl() + { + // Arrange + const string customUrl = "https://custom.api.example.com/v1"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: customUrl); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithDefaultBaseUrl_UsesRekaApi() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "https://api.reka.ai/v1"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert - Should use default "https://api.reka.ai/v1" + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithCustomModelName_UsesProvidedModel() + { + // Arrange + const string customModel = "custom-model-v2"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: customModel); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithDefaultModelName_UsesRekaFlash31() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: "reka-flash-3.1"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert - Should use default "reka-flash-3.1" + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithInvalidUrl_ReturnsEmptyString() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "not-a-valid-url"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().BeEmpty(); + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_OnException_ReturnsEmptyString() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task GenerateSummaryAsync_WithEmptyApiKey_ReturnsEmptyString(string emptyKey) + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: emptyKey); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().BeEmpty(); + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_ApiKeyPriority_AppSettingsOverridesEnvVar() + { + // Arrange - Both AppSettings and env var set, AppSettings should take precedence + var settingsProvider = CreateSettingsProvider(apiKey: "settings-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Theory] + [InlineData("Short prompt")] + [InlineData("This is a longer prompt that should be processed correctly by the service")] + [InlineData("Multi\nline\nprompt")] + public async Task GenerateSummaryAsync_WithVariousPrompts_HandlesCorrectly(string prompt) + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync(prompt); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithNullPrompt_HandlesGracefully() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync(null!); + + // Assert + result.Should().NotBeNull(); + } + + private Func> CreateSettingsProvider( + string? apiKey = "test-api-key", + string? baseUrl = "https://api.reka.ai/v1", + string? modelName = "reka-flash-3.1") + { + return () => + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("AI API key not configured"); + } + + return Task.FromResult(( + ApiKey: apiKey, + BaseUrl: baseUrl ?? "https://api.reka.ai/v1", + ModelName: modelName ?? "reka-flash-3.1" + )); + }; + } + + private void VerifyErrorLogged(string expectedMessagePart) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessagePart)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj b/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj index 8ce1e84..64a8065 100644 --- a/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj +++ b/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj @@ -1,14 +1,16 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/NoteBookmark.AIServices/ResearchService.cs b/src/NoteBookmark.AIServices/ResearchService.cs index bedd895..69ac93a 100644 --- a/src/NoteBookmark.AIServices/ResearchService.cs +++ b/src/NoteBookmark.AIServices/ResearchService.cs @@ -1,147 +1,159 @@ -using System.Text; -using System.Text.Json; -using System.IO; -using System.Linq; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Reka.SDK; -using NoteBookmark.Domain; - -namespace NoteBookmark.AIServices; - -public class ResearchService(HttpClient client, ILogger logger, IConfiguration config) -{ - private readonly HttpClient _client = client; - private readonly ILogger _logger = logger; - private const string BASE_URL = "https://api.reka.ai/v1/chat/completions"; - private const string MODEL_NAME = "reka-flash-research"; - private readonly string _apiKey = config["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); - - public async Task SearchSuggestionsAsync(SearchCriterias searchCriterias) - { - PostSuggestions suggestions = new PostSuggestions(); - - var webSearch = new Dictionary - { - ["max_uses"] = 3 - }; - - var allowedDomains = searchCriterias.GetSplittedAllowedDomains(); - var blockedDomains = searchCriterias.GetSplittedBlockedDomains(); - - if (allowedDomains != null && allowedDomains.Length > 0) - { - webSearch["allowed_domains"] = allowedDomains; - } - else if (blockedDomains != null && blockedDomains.Length > 0) - { - webSearch["blocked_domains"] = blockedDomains; - } - - var requestPayload = new - { - model = MODEL_NAME, - - messages = new[] - { - new - { - role = "user", - content = searchCriterias.GetSearchPrompt() - } - }, - response_format = GetResponseFormat(), - research = new - { - web_search = webSearch - }, - }; - - var jsonPayload = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - - // await SaveToFile("research_request", jsonPayload); - - HttpResponseMessage? response = null; - - try - { - using var request = new HttpRequestMessage(HttpMethod.Post, BASE_URL); - request.Headers.Add("Authorization", $"Bearer {_apiKey}"); - request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); - - response = await _client.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - - await SaveToFile("research_response", responseContent); - - var rekaResponse = JsonSerializer.Deserialize(responseContent); - - if (response.IsSuccessStatusCode) - { - suggestions = JsonSerializer.Deserialize(rekaResponse!.Choices![0].Message!.Content!)!; - } - else - { - throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}"); - } - } - catch (Exception ex) - { - _logger.LogError($"An error occurred while fetching research suggestions: {ex.Message}"); - } - - return suggestions; - } - - - private object GetResponseFormat() - { - return new - { - type = "json_schema", - json_schema = new - { - name = "post_suggestions", - schema = new - { - type = "object", - properties = new - { - suggestions = new - { - type = "array", - items = new - { - type = "object", - properties = new - { - title = new { type = "string" }, - author = new { type = "string" }, - summary = new { type = "string", maxLength = 100 }, - publication_date = new { type = "string", format = "date" }, - url = new { type = "string" } - }, - required = new[] { "title", "summary", "url" } - } - } - }, - required = new[] { "post_suggestions" } - } - } - }; - } - - private async Task SaveToFile(string prefix, string responseContent) - { - string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); - string fileName = $"{prefix}_{datetime}.json"; - string folderPath = "Data"; - Directory.CreateDirectory(folderPath); - string filePath = Path.Combine(folderPath, fileName); - await File.WriteAllTextAsync(filePath, responseContent); - } - +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.ClientModel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; +using OpenAI; +using OpenAI.Chat; +using NoteBookmark.Domain; +using Reka.SDK; +using System.Text; + +namespace NoteBookmark.AIServices; + +public class ResearchService +{ + private readonly ILogger _logger; + private readonly Func> _settingsProvider; + private readonly HttpClient _client; + + public ResearchService( + HttpClient client, + ILogger logger, + Func> settingsProvider) + { + _logger = logger; + _client = client; + _settingsProvider = settingsProvider; + } + + public async Task SearchSuggestionsAsync(SearchCriterias searchCriterias) + { + PostSuggestions suggestions = new PostSuggestions(); + + HttpResponseMessage? response = null; + + try + { + var settings = await _settingsProvider(); + + var webSearch = new Dictionary + { + ["max_uses"] = 3 + }; + + var allowedDomains = searchCriterias.GetSplittedAllowedDomains(); + var blockedDomains = searchCriterias.GetSplittedBlockedDomains(); + + if (allowedDomains != null && allowedDomains.Length > 0) + { + webSearch["allowed_domains"] = allowedDomains; + } + else if (blockedDomains != null && blockedDomains.Length > 0) + { + webSearch["blocked_domains"] = blockedDomains; + } + + var requestPayload = new + { + model = settings.ModelName, + + messages = new[] + { + new + { + role = "user", + content = searchCriterias.GetSearchPrompt() + } + }, + response_format = GetResponseFormat(), + research = new + { + web_search = webSearch + }, + }; + + var jsonPayload = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + // await SaveToFile("research_request", jsonPayload); + + var endpoint = settings.BaseUrl.TrimEnd('/') + "/chat/completions"; + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Add("Authorization", $"Bearer {settings.ApiKey}"); + request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + response = await _client.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + await SaveToFile("research_response", responseContent); + + var rekaResponse = JsonSerializer.Deserialize(responseContent); + + if (response.IsSuccessStatusCode) + { + suggestions = JsonSerializer.Deserialize(rekaResponse!.Choices![0].Message!.Content!)!; + } + else + { + throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}"); + } + } + catch (Exception ex) + { + _logger.LogError($"An error occurred while fetching research suggestions: {ex.Message}"); + } + + return suggestions; + } + + private object GetResponseFormat() + { + return new + { + type = "json_schema", + json_schema = new + { + name = "post_suggestions", + schema = new + { + type = "object", + properties = new + { + suggestions = new + { + type = "array", + items = new + { + type = "object", + properties = new + { + title = new { type = "string" }, + author = new { type = "string" }, + summary = new { type = "string", maxLength = 100 }, + publication_date = new { type = "string", format = "date" }, + url = new { type = "string" } + }, + required = new[] { "title", "summary", "url" } + } + } + }, + required = new[] { "post_suggestions" } + } + } + }; + } + + private async Task SaveToFile(string prefix, string responseContent) + { + string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); + string fileName = $"{prefix}_{datetime}.json"; + string folderPath = "Data"; + Directory.CreateDirectory(folderPath); + string filePath = Path.Combine(folderPath, fileName); + await File.WriteAllTextAsync(filePath, responseContent); + } } \ No newline at end of file diff --git a/src/NoteBookmark.AIServices/SummaryService.cs b/src/NoteBookmark.AIServices/SummaryService.cs index 9257aa3..6ad953e 100644 --- a/src/NoteBookmark.AIServices/SummaryService.cs +++ b/src/NoteBookmark.AIServices/SummaryService.cs @@ -1,70 +1,49 @@ -using System.Text; -using System.Text.Json; -using System.IO; -using System.Linq; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Reka.SDK; - -namespace NoteBookmark.AIServices; - -public class SummaryService(HttpClient client, ILogger logger, IConfiguration config) -{ - private readonly HttpClient _client = client; - private readonly ILogger _logger = logger; - private const string BASE_URL = "https://api.reka.ai/v1/chat"; - private const string MODEL_NAME = "reka-flash-3.1"; - private readonly string _apiKey = config["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); - - public async Task GenerateSummaryAsync(string prompt) - { - string introParagraph; - - _client.Timeout = TimeSpan.FromSeconds(300); - - var requestPayload = new - { - model = MODEL_NAME, - - messages = new[] - { - new - { - role = "user", - content = prompt - } - } - }; - - var jsonPayload = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - - HttpResponseMessage? response = null; - - using var request = new HttpRequestMessage(HttpMethod.Post, BASE_URL); - request.Headers.Add("Authorization", $"Bearer {_apiKey}"); - request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); - - response = await _client.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - - var rekaResponse = JsonSerializer.Deserialize(responseContent); - - if (response.IsSuccessStatusCode) - { - var textContent = rekaResponse!.Responses![0]!.Message!.Content! - .FirstOrDefault(c => c.Type == "text"); - - introParagraph = textContent?.Text ?? String.Empty; - } - else - { - throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}"); - } - - return introParagraph; - } - +using System.ClientModel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; +using OpenAI; +using OpenAI.Chat; +using NoteBookmark.Domain; + +namespace NoteBookmark.AIServices; + +public class SummaryService +{ + private readonly ILogger _logger; + private readonly Func> _settingsProvider; + + public SummaryService( + ILogger logger, + Func> settingsProvider) + { + _logger = logger; + _settingsProvider = settingsProvider; + } + + public async Task GenerateSummaryAsync(string prompt) + { + try + { + var settings = await _settingsProvider(); + + IChatClient chatClient = new ChatClient( + settings.ModelName, + new ApiKeyCredential(settings.ApiKey), + new OpenAIClientOptions { Endpoint = new Uri(settings.BaseUrl) } + ).AsIChatClient(); + + AIAgent agent = new ChatClientAgent(chatClient, + instructions: "You are a helpful assistant that generates concise summaries.", + name: "SummaryAgent"); + + var response = await agent.RunAsync(prompt); + return response.ToString() ?? string.Empty; + } + catch (Exception ex) + { + _logger.LogError($"An error occurred while generating summary: {ex.Message}"); + return string.Empty; + } + } } \ No newline at end of file diff --git a/src/NoteBookmark.Api.Tests/Domain/ContainsPlaceholderAttributeTests.cs b/src/NoteBookmark.Api.Tests/Domain/ContainsPlaceholderAttributeTests.cs new file mode 100644 index 0000000..5f0cf7f --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Domain/ContainsPlaceholderAttributeTests.cs @@ -0,0 +1,135 @@ +using System.ComponentModel.DataAnnotations; +using FluentAssertions; +using NoteBookmark.Domain; +using Xunit; + +namespace NoteBookmark.Api.Tests.Domain; + +public class ContainsPlaceholderAttributeTests +{ + [Fact] + public void IsValid_ShouldReturnSuccess_WhenValueContainsPlaceholder() + { + // Arrange + var attribute = new ContainsPlaceholderAttribute("topic"); + var validationContext = new ValidationContext(new object()); + + // Act + var result = attribute.GetValidationResult("Find articles about {topic}", validationContext); + + // Assert + result.Should().Be(ValidationResult.Success); + } + + [Fact] + public void IsValid_ShouldReturnError_WhenValueDoesNotContainPlaceholder() + { + // Arrange + var attribute = new ContainsPlaceholderAttribute("topic"); + var validationContext = new ValidationContext(new object()); + + // Act + var result = attribute.GetValidationResult("Find articles about something", validationContext); + + // Assert + result.Should().NotBe(ValidationResult.Success); + result?.ErrorMessage.Should().Contain("topic"); + } + + [Fact] + public void IsValid_ShouldReturnSuccess_WhenValueIsNull() + { + // Arrange + var attribute = new ContainsPlaceholderAttribute("content"); + var validationContext = new ValidationContext(new object()); + + // Act + var result = attribute.GetValidationResult(null, validationContext); + + // Assert + result.Should().Be(ValidationResult.Success); + } + + [Fact] + public void IsValid_ShouldReturnSuccess_WhenValueIsEmpty() + { + // Arrange + var attribute = new ContainsPlaceholderAttribute("content"); + var validationContext = new ValidationContext(new object()); + + // Act + var result = attribute.GetValidationResult("", validationContext); + + // Assert + result.Should().Be(ValidationResult.Success); + } + + [Fact] + public void IsValid_ShouldReturnSuccess_WhenValueIsWhitespace() + { + // Arrange + var attribute = new ContainsPlaceholderAttribute("content"); + var validationContext = new ValidationContext(new object()); + + // Act + var result = attribute.GetValidationResult(" ", validationContext); + + // Assert + result.Should().Be(ValidationResult.Success); + } + + [Fact] + public void Constructor_ShouldSetPlaceholder() + { + // Arrange & Act + var attribute = new ContainsPlaceholderAttribute("custom"); + var validationContext = new ValidationContext(new object()); + + // Assert + var result = attribute.GetValidationResult("text with {custom} placeholder", validationContext); + result.Should().Be(ValidationResult.Success); + } + + [Fact] + public void ErrorMessage_ShouldContainPlaceholderName() + { + // Arrange + var attribute = new ContainsPlaceholderAttribute("myplaceholder"); + var validationContext = new ValidationContext(new object()); + + // Act + var result = attribute.GetValidationResult("text without placeholder", validationContext); + + // Assert + result?.ErrorMessage.Should().Contain("myplaceholder"); + result?.ErrorMessage.Should().Contain("must contain"); + } + + [Theory] + [InlineData("Summary of {content}", "content", true)] + [InlineData("Summary of content", "content", false)] + [InlineData("Use {topic} for search", "topic", true)] + [InlineData("Use topic for search", "topic", false)] + [InlineData("Multiple {var1} and {var2}", "var1", true)] + [InlineData("Multiple {var1} and {var2}", "var2", true)] + [InlineData("Multiple var1 and var2", "var1", false)] + public void IsValid_ShouldValidateCorrectly_ForVariousInputs(string value, string placeholder, bool shouldBeValid) + { + // Arrange + var attribute = new ContainsPlaceholderAttribute(placeholder); + var validationContext = new ValidationContext(new object()); + + // Act + var result = attribute.GetValidationResult(value, validationContext); + + // Assert + if (shouldBeValid) + { + result.Should().Be(ValidationResult.Success); + } + else + { + result.Should().NotBe(ValidationResult.Success); + } + } +} diff --git a/src/NoteBookmark.Api.Tests/Domain/NoteCategoriesTests.cs b/src/NoteBookmark.Api.Tests/Domain/NoteCategoriesTests.cs new file mode 100644 index 0000000..7e46ad1 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Domain/NoteCategoriesTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using NoteBookmark.Domain; +using Xunit; + +namespace NoteBookmark.Api.Tests.Domain; + +public class NoteCategoriesTests +{ + [Theory] + [InlineData("ai", "AI")] + [InlineData("AI", "AI")] + [InlineData("cloud", "Cloud")] + [InlineData("CLOUD", "Cloud")] + [InlineData("data", "Data")] + [InlineData("database", "Databases")] + [InlineData("dev", "Programming")] + [InlineData("devops", "DevOps")] + [InlineData("lowcode", "LowCode")] + [InlineData("misc", "Miscellaneous")] + [InlineData("top", "Suggestion of the week")] + [InlineData("oss", "Open Source")] + [InlineData("del", "del")] + public void GetCategory_ShouldReturnCorrectCategory_ForValidInput(string input, string expected) + { + // Act + var result = NoteCategories.GetCategory(input); + + // Assert + result.Should().Be(expected); + } + + [Theory] + [InlineData("unknown")] + [InlineData("invalid")] + [InlineData("")] + public void GetCategory_ShouldReturnMiscellaneous_ForInvalidCategory(string input) + { + // Act + var result = NoteCategories.GetCategory(input); + + // Assert + result.Should().Be("Miscellaneous"); + } + + [Fact] + public void GetCategory_ShouldReturnMiscellaneous_ForNullInput() + { + // Act + var result = NoteCategories.GetCategory(null); + + // Assert + result.Should().Be("Miscellaneous"); + } + + [Fact] + public void GetCategory_ShouldBeCaseInsensitive() + { + // Arrange + var inputs = new[] { "AI", "ai", "Ai", "aI" }; + + // Act & Assert + foreach (var input in inputs) + { + var result = NoteCategories.GetCategory(input); + result.Should().Be("AI"); + } + } + + [Fact] + public void GetCategories_ShouldReturnAllCategories() + { + // Act + var result = NoteCategories.GetCategories(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(11); + result.Should().Contain("AI"); + result.Should().Contain("Cloud"); + result.Should().Contain("Data"); + result.Should().Contain("Databases"); + result.Should().Contain("DevOps"); + result.Should().Contain("LowCode"); + result.Should().Contain("Miscellaneous"); + result.Should().Contain("Programming"); + result.Should().Contain("Open Source"); + result.Should().Contain("Suggestion of the week"); + result.Should().Contain("del"); + } + + [Fact] + public void GetCategories_ShouldReturnListType() + { + // Act + var result = NoteCategories.GetCategories(); + + // Assert + result.Should().BeOfType>(); + } +} diff --git a/src/NoteBookmark.Api.Tests/Domain/NoteTests.cs b/src/NoteBookmark.Api.Tests/Domain/NoteTests.cs index 35bdeff..7c6ebc5 100644 --- a/src/NoteBookmark.Api.Tests/Domain/NoteTests.cs +++ b/src/NoteBookmark.Api.Tests/Domain/NoteTests.cs @@ -19,6 +19,41 @@ public void Note_WhenCreated_HasCorrectDefaultValues() note.Category.Should().BeNull(); } + [Fact] + public void Note_Constructor_ShouldInitializePartitionKey_WithCurrentYearMonth() + { + // Act + var note = new Note(); + + // Assert + note.PartitionKey.Should().Be(DateTime.UtcNow.ToString("yyyy-MM")); + } + + [Fact] + public void Note_Constructor_ShouldInitializeRowKey_WithValidGuid() + { + // Act + var note = new Note(); + + // Assert + note.RowKey.Should().NotBeNullOrEmpty(); + Guid.TryParse(note.RowKey, out _).Should().BeTrue(); + } + + [Fact] + public void Note_Constructor_ShouldInitializeDateAdded_WithCurrentUtcTime() + { + // Arrange + var before = DateTime.UtcNow; + + // Act + var note = new Note(); + var after = DateTime.UtcNow; + + // Assert + note.DateAdded.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); + } + [Fact] public void Note_WhenPropertiesSet_ReturnsCorrectValues() { @@ -41,4 +76,56 @@ public void Note_WhenPropertiesSet_ReturnsCorrectValues() note.Tags.Should().Be("azure, functions, serverless"); note.Category.Should().Be("Technology"); } + + [Fact] + public void Validate_ShouldReturnTrue_WhenCommentIsNotEmpty() + { + // Arrange + var note = new Note { Comment = "This is a valid comment" }; + + // Act + var result = note.Validate(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Validate_ShouldReturnFalse_WhenCommentIsNull() + { + // Arrange + var note = new Note { Comment = null }; + + // Act + var result = note.Validate(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void Validate_ShouldReturnFalse_WhenCommentIsEmpty() + { + // Arrange + var note = new Note { Comment = "" }; + + // Act + var result = note.Validate(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void Validate_ShouldReturnFalse_WhenCommentIsWhitespace() + { + // Arrange + var note = new Note { Comment = " " }; + + // Act + var result = note.Validate(); + + // Assert + result.Should().BeFalse(); + } } diff --git a/src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs b/src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs new file mode 100644 index 0000000..abe9355 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs @@ -0,0 +1,325 @@ +using System.Text.Json; +using FluentAssertions; +using NoteBookmark.Domain; +using Xunit; + +namespace NoteBookmark.Api.Tests.Domain; + +public class PostSuggestionTests +{ + [Fact] + public void PostSuggestion_ShouldSerializeToJson() + { + // Arrange + var postSuggestion = new PostSuggestion + { + Title = "Test Article", + Author = "John Doe", + Summary = "This is a summary", + PublicationDate = "2024-01-15", + Url = "https://example.com/article" + }; + + // Act + var json = JsonSerializer.Serialize(postSuggestion); + + // Assert + json.Should().Contain("\"title\":\"Test Article\""); + json.Should().Contain("\"author\":\"John Doe\""); + json.Should().Contain("\"summary\":\"This is a summary\""); + json.Should().Contain("\"publication_date\":\"2024-01-15\""); + json.Should().Contain("\"url\":\"https://example.com/article\""); + } + + [Fact] + public void PostSuggestion_ShouldDeserializeFromJson() + { + // Arrange + var json = @"{ + ""title"": ""Test Article"", + ""author"": ""Jane Doe"", + ""summary"": ""A great summary"", + ""publication_date"": ""2024-12-01"", + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert + result.Should().NotBeNull(); + result!.Title.Should().Be("Test Article"); + result.Author.Should().Be("Jane Doe"); + result.Summary.Should().Be("A great summary"); + result.PublicationDate.Should().Be("2024-12-01"); + result.Url.Should().Be("https://test.com"); + } + + [Fact] + public void PostSuggestion_ShouldHandleNullAuthor() + { + // Arrange + var json = @"{ + ""title"": ""Test"", + ""author"": null, + ""summary"": ""Summary"", + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert + result.Should().NotBeNull(); + result!.Author.Should().BeNull(); + } + + [Fact] + public void PostSuggestion_ShouldHandleNullPublicationDate() + { + // Arrange + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": null, + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert + result.Should().NotBeNull(); + result!.PublicationDate.Should().BeNull(); + } + + [Fact] + public void PostSuggestion_RoundTrip_ShouldMaintainValues() + { + // Arrange + var original = new PostSuggestion + { + Title = "Test", + Summary = "Summary", + PublicationDate = "2024-12-13", + Url = "https://test.com", + Author = "Test Author" + }; + + // Act + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.Title.Should().Be(original.Title); + deserialized.Summary.Should().Be(original.Summary); + deserialized.PublicationDate.Should().Be(original.PublicationDate); + deserialized.Url.Should().Be(original.Url); + deserialized.Author.Should().Be(original.Author); + } +} + +public class DateOnlyJsonConverterTests +{ + private readonly JsonSerializerOptions _options; + + public DateOnlyJsonConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new DateOnlyJsonConverter()); + } + + [Fact] + public void Read_ShouldParseValidDate() + { + // Arrange + var json = "\"2024-01-15\""; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + result.Should().Be("2024-01-15"); + } + + [Fact] + public void Read_ShouldHandleFullDateTime() + { + // Arrange + var json = "\"2024-01-15T10:30:00\""; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + result.Should().Be("2024-01-15"); + } + + [Fact] + public void Read_ShouldHandleNull() + { + // Arrange + var json = "null"; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Read_ShouldHandleEmptyString() + { + // Arrange + var json = "\"\""; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Write_ShouldWriteValue() + { + // Arrange + var date = "2024-01-15"; + + // Act + var json = JsonSerializer.Serialize(date, _options); + + // Assert + json.Should().Be("\"2024-01-15\""); + } + + [Fact] + public void Write_ShouldWriteNull() + { + // Arrange + string? date = null; + + // Act + var json = JsonSerializer.Serialize(date, _options); + + // Assert + json.Should().Be("null"); + } + + [Fact] + public void DateConverter_ShouldFormatWithYearMonthDay() + { + // Arrange + var postSuggestion = new PostSuggestion + { + Title = "Test", + Summary = "Summary", + PublicationDate = "2024-12-01", + Url = "https://test.com" + }; + + // Act + var json = JsonSerializer.Serialize(postSuggestion); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized!.PublicationDate.Should().Match("????-??-??"); + } + + [Fact] + public void Read_ShouldHandleBoolean_ReturnStringRepresentation() + { + // Arrange - AI might return boolean instead of date + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": true, + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should convert to string instead of throwing + result.Should().NotBeNull(); + result!.PublicationDate.Should().Be("True"); + } + + [Fact] + public void Read_ShouldHandleNumber_ParseAsTimestamp() + { + // Arrange - AI might return Unix timestamp + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": 1704067200, + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should convert Unix timestamp to yyyy-MM-dd + result.Should().NotBeNull(); + result!.PublicationDate.Should().Be("2024-01-01"); + } + + [Fact] + public void Read_ShouldHandleObject_ReturnNull() + { + // Arrange - AI might return object + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": { ""year"": 2024, ""month"": 1, ""day"": 15 }, + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should gracefully skip and return null + result.Should().NotBeNull(); + result!.PublicationDate.Should().BeNull(); + } + + [Fact] + public void Read_ShouldHandleArray_ReturnNull() + { + // Arrange - AI might return array + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": [2024, 1, 15], + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should gracefully skip and return null + result.Should().NotBeNull(); + result!.PublicationDate.Should().BeNull(); + } + + [Fact] + public void Read_ShouldHandleInvalidDateString_ReturnOriginal() + { + // Arrange - AI might return non-parseable date string + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": ""sometime in 2024"", + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should keep original string if not parseable + result.Should().NotBeNull(); + result!.PublicationDate.Should().Be("sometime in 2024"); + } +} diff --git a/src/NoteBookmark.Api.Tests/Domain/ReadingNoteTests.cs b/src/NoteBookmark.Api.Tests/Domain/ReadingNoteTests.cs index aa1a1d4..2f34e9b 100644 --- a/src/NoteBookmark.Api.Tests/Domain/ReadingNoteTests.cs +++ b/src/NoteBookmark.Api.Tests/Domain/ReadingNoteTests.cs @@ -53,4 +53,106 @@ public void ReadingNote_WhenPropertiesSet_ReturnsCorrectValues() readingNote.Category.Should().Be("Performance"); readingNote.ReadingNotesID.Should().Be("reading-notes-123"); } + + [Fact] + public void ToMarkDown_ShouldGenerateCorrectMarkdown_WithAllProperties() + { + // Arrange + var note = new ReadingNote + { + Title = "Test Article", + Url = "https://example.com/article", + Author = "John Doe", + Comment = "Great article!" + }; + + // Act + var result = note.ToMarkDown(); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("**[Test Article](https://example.com/article)**"); + result.Should().Contain("(John Doe)"); + result.Should().Contain("Great article!"); + } + + [Fact] + public void ToMarkDown_ShouldHandleMissingUrl() + { + // Arrange + var note = new ReadingNote + { + Title = "Test Article", + Url = null, + Author = "John Doe", + Comment = "Great article!" + }; + + // Act + var result = note.ToMarkDown(); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("**[Test Article]()**"); + result.Should().Contain("(John Doe)"); + result.Should().Contain("Great article!"); + } + + [Fact] + public void ToMarkDown_ShouldHandleMissingTitle() + { + // Arrange + var note = new ReadingNote + { + Title = null, + Url = "https://example.com/article", + Author = "John Doe", + Comment = "Great article!" + }; + + // Act + var result = note.ToMarkDown(); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("**[](#)**"); + result.Should().Contain("(John Doe)"); + result.Should().Contain("Great article!"); + } + + [Fact] + public void ToMarkDown_ShouldHandleMissingAuthor() + { + // Arrange + var note = new ReadingNote + { + Title = "Test Article", + Url = "https://example.com/article", + Author = null, + Comment = "Great article!" + }; + + // Act + var result = note.ToMarkDown(); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("**[Test Article](https://example.com/article)**"); + result.Should().NotContain("(John"); + result.Should().Contain("Great article!"); + } + + [Fact] + public void ToMarkDown_ShouldStartWithNewLineAndHyphen() + { + // Arrange + var note = new ReadingNote { Comment = "Test" }; + + // Act + var result = note.ToMarkDown(); + + // Assert + result.Should().StartWith(Environment.NewLine); + result.Should().Contain("- "); + } } diff --git a/src/NoteBookmark.Api.Tests/Domain/SearchCriteriasTests.cs b/src/NoteBookmark.Api.Tests/Domain/SearchCriteriasTests.cs new file mode 100644 index 0000000..41ba009 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Domain/SearchCriteriasTests.cs @@ -0,0 +1,162 @@ +using FluentAssertions; +using NoteBookmark.Domain; +using Xunit; + +namespace NoteBookmark.Api.Tests.Domain; + +public class SearchCriteriasTests +{ + [Fact] + public void Constructor_ShouldSetSearchPrompt() + { + // Arrange + var searchPrompt = "Find articles about {topic} from the last week"; + + // Act + var criterias = new SearchCriterias(searchPrompt); + + // Assert + var result = criterias.GetSearchPrompt(); + result.Should().Contain("Find articles about"); + } + + [Fact] + public void GetSplittedAllowedDomains_ShouldReturnNull_WhenAllowedDomainsIsNull() + { + // Arrange + var criterias = new SearchCriterias("test") { AllowedDomains = null }; + + // Act + var result = criterias.GetSplittedAllowedDomains(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetSplittedAllowedDomains_ShouldSplitAndTrim_WhenAllowedDomainsProvided() + { + // Arrange + var criterias = new SearchCriterias("test") + { + AllowedDomains = "example.com, test.com , another.com" + }; + + // Act + var result = criterias.GetSplittedAllowedDomains(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().Contain("example.com"); + result.Should().Contain("test.com"); + result.Should().Contain("another.com"); + } + + [Fact] + public void GetSplittedAllowedDomains_ShouldHandleSingleDomain() + { + // Arrange + var criterias = new SearchCriterias("test") { AllowedDomains = "example.com" }; + + // Act + var result = criterias.GetSplittedAllowedDomains(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result.Should().Contain("example.com"); + } + + [Fact] + public void GetSplittedBlockedDomains_ShouldReturnNull_WhenBlockedDomainsIsNull() + { + // Arrange + var criterias = new SearchCriterias("test") { BlockedDomains = null }; + + // Act + var result = criterias.GetSplittedBlockedDomains(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetSplittedBlockedDomains_ShouldSplitAndTrim_WhenBlockedDomainsProvided() + { + // Arrange + var criterias = new SearchCriterias("test") + { + BlockedDomains = "spam.com, bad.com, malicious.com " + }; + + // Act + var result = criterias.GetSplittedBlockedDomains(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().Contain("spam.com"); + result.Should().Contain("bad.com"); + result.Should().Contain("malicious.com"); + } + + [Fact] + public void GetSearchPrompt_ShouldReplaceTopicPlaceholder() + { + // Arrange + var criterias = new SearchCriterias("Find articles about {topic}") + { + SearchTopic = "Azure DevOps" + }; + + // Act + var result = criterias.GetSearchPrompt(); + + // Assert + result.Should().Be("Find articles about Azure DevOps "); + } + + [Fact] + public void GetSearchPrompt_ShouldHandleNullSearchTopic() + { + // Arrange + var criterias = new SearchCriterias("Find articles about {topic}") { SearchTopic = null }; + + // Act + var result = criterias.GetSearchPrompt(); + + // Assert + result.Should().Be("Find articles about "); + } + + [Fact] + public void GetSearchPrompt_ShouldHandleEmptySearchTopic() + { + // Arrange + var criterias = new SearchCriterias("Find articles about {topic}") { SearchTopic = "" }; + + // Act + var result = criterias.GetSearchPrompt(); + + // Assert + result.Should().Be("Find articles about "); + } + + [Fact] + public void Properties_ShouldBeSettable() + { + // Arrange + var criterias = new SearchCriterias("test"); + + // Act + criterias.SearchTopic = "Kubernetes"; + criterias.AllowedDomains = "k8s.io"; + criterias.BlockedDomains = "spam.com"; + + // Assert + criterias.SearchTopic.Should().Be("Kubernetes"); + criterias.AllowedDomains.Should().Be("k8s.io"); + criterias.BlockedDomains.Should().Be("spam.com"); + } +} diff --git a/src/NoteBookmark.Api.Tests/Endpoints/NoteEndpointsTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/NoteEndpointsTests.cs index e4f26e0..565d0da 100644 --- a/src/NoteBookmark.Api.Tests/Endpoints/NoteEndpointsTests.cs +++ b/src/NoteBookmark.Api.Tests/Endpoints/NoteEndpointsTests.cs @@ -86,7 +86,7 @@ public async Task GetNotes_ReturnsAllNotes() // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); - var notes = await response.Content.ReadFromJsonAsync>(); + var notes = await response.Content.ReadFromJsonAsync>(); notes.Should().NotBeNull(); notes.Should().NotBeEmpty(); } @@ -122,7 +122,6 @@ public async Task GetNotesForSummary_WithInvalidReadingNotesId_ReturnsEmptyList( response.StatusCode.Should().Be(HttpStatusCode.OK); var readingNotes = await response.Content.ReadFromJsonAsync>(); - readingNotes.Should().NotBeNull(); readingNotes.Should().BeEmpty(); } @@ -159,6 +158,111 @@ public async Task UpdatePostReadStatus_UpdatesAllPostsWithNotes() // This would require additional verification logic based on the actual implementation } + [Fact] + public async Task GetNote_WithValidNoteId_ReturnsNote() + { + // Arrange + var testPost = await CreateAndSaveTestPost(); + var testNote = CreateTestNote(); + testNote.PostId = testPost.RowKey; + await _client.PostAsJsonAsync("/api/notes/note", testNote); + + // Act + var response = await _client.GetAsync($"/api/notes/note/{testNote.RowKey}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var retrievedNote = await response.Content.ReadFromJsonAsync(); + retrievedNote.Should().NotBeNull(); + retrievedNote!.RowKey.Should().Be(testNote.RowKey); + retrievedNote.Comment.Should().Be(testNote.Comment); + } + + [Fact] + public async Task GetNote_WithInvalidNoteId_ReturnsNotFound() + { + // Arrange + var nonExistentNoteId = "non-existent-note-id"; + + // Act + var response = await _client.GetAsync($"/api/notes/note/{nonExistentNoteId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpdateNote_WithValidNote_ReturnsOk() + { + // Arrange + var testPost = await CreateAndSaveTestPost(); + var testNote = CreateTestNote(); + testNote.PostId = testPost.RowKey; + await _client.PostAsJsonAsync("/api/notes/note", testNote); + + // Update the note + testNote.Comment = "Updated comment"; + testNote.Tags = "updated, tags"; + + // Act + var response = await _client.PutAsJsonAsync("/api/notes/note", testNote); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var updatedNote = await response.Content.ReadFromJsonAsync(); + updatedNote.Should().NotBeNull(); + updatedNote!.Comment.Should().Be("Updated comment"); + updatedNote.Tags.Should().Be("updated, tags"); + } + + [Fact] + public async Task UpdateNote_WithInvalidNote_ReturnsBadRequest() + { + // Arrange + var invalidNote = new Note(); // Missing required comment + + // Act + var response = await _client.PutAsJsonAsync("/api/notes/note", invalidNote); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task DeleteNote_WithValidNoteId_ReturnsOk() + { + // Arrange + var testPost = await CreateAndSaveTestPost(); + var testNote = CreateTestNote(); + testNote.PostId = testPost.RowKey; + await _client.PostAsJsonAsync("/api/notes/note", testNote); + + // Act + var response = await _client.DeleteAsync($"/api/notes/note/{testNote.RowKey}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify the note is deleted + var getResponse = await _client.GetAsync($"/api/notes/note/{testNote.RowKey}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteNote_WithInvalidNoteId_ReturnsNotFound() + { + // Arrange + var nonExistentNoteId = "non-existent-note-id"; + + // Act + var response = await _client.DeleteAsync($"/api/notes/note/{nonExistentNoteId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + // Helper methods private async Task SeedTestNotes() { diff --git a/src/NoteBookmark.Api/AISettingsProvider.cs b/src/NoteBookmark.Api/AISettingsProvider.cs new file mode 100644 index 0000000..efcd15d --- /dev/null +++ b/src/NoteBookmark.Api/AISettingsProvider.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.Api; + +/// +/// Provides AI configuration settings from the database with fallback to IConfiguration. +/// This ensures user-saved settings in Azure Table Storage take precedence over environment variables. +/// +public class AISettingsProvider : IAISettingsProvider +{ + private readonly IDataStorageService _dataStorageService; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public AISettingsProvider( + IDataStorageService dataStorageService, + IConfiguration config, + ILogger logger) + { + _dataStorageService = dataStorageService; + _config = config; + _logger = logger; + } + + public async Task<(string ApiKey, string BaseUrl, string ModelName)> GetAISettingsAsync() + { + try + { + // Try to get settings from database first (user-saved settings) + var settings = await _dataStorageService.GetSettings(); + + // Check if user has configured AI settings in the database + if (!string.IsNullOrWhiteSpace(settings.AiApiKey)) + { + _logger.LogDebug("Using AI settings from database"); + + string baseUrl = !string.IsNullOrWhiteSpace(settings.AiBaseUrl) + ? settings.AiBaseUrl + : "https://api.reka.ai/v1"; + + string modelName = !string.IsNullOrWhiteSpace(settings.AiModelName) + ? settings.AiModelName + : "reka-flash-3.1"; + + return (settings.AiApiKey, baseUrl, modelName); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve settings from database, falling back to configuration"); + } + + // Fallback to IConfiguration (environment variables, appsettings.json) + _logger.LogDebug("Using AI settings from IConfiguration fallback"); + + string? apiKey = _config["AppSettings:AiApiKey"] + ?? _config["AppSettings:REKA_API_KEY"] + ?? Environment.GetEnvironmentVariable("REKA_API_KEY"); + + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("AI API key not configured. Set AiApiKey in settings or REKA_API_KEY environment variable."); + + string fallbackBaseUrl = _config["AppSettings:AiBaseUrl"] ?? "https://api.reka.ai/v1"; + string fallbackModelName = _config["AppSettings:AiModelName"] ?? "reka-flash-3.1"; + + return (apiKey, fallbackBaseUrl, fallbackModelName); + } +} diff --git a/src/NoteBookmark.Api/DataStorageService.cs b/src/NoteBookmark.Api/DataStorageService.cs index 3fbb8fa..c744eb4 100644 --- a/src/NoteBookmark.Api/DataStorageService.cs +++ b/src/NoteBookmark.Api/DataStorageService.cs @@ -179,6 +179,26 @@ public void CreateNote(Note note) } } + public Note? GetNote(string rowKey) + { + var tblNote = GetNoteTable(); + var result = tblNote.Query(filter: $"RowKey eq '{rowKey}'"); + Note? note = result.FirstOrDefault(); + return note; + } + + public bool DeleteNote(string rowKey) + { + var tblNote = GetNoteTable(); + var existingNote = tblNote.Query(filter: $"RowKey eq '{rowKey}'").FirstOrDefault(); + if (existingNote != null) + { + tblNote.DeleteEntity(existingNote.PartitionKey, existingNote.RowKey); + return true; + } + return false; + } + public async Task GetSettings() { diff --git a/src/NoteBookmark.Api/Dockerfile b/src/NoteBookmark.Api/Dockerfile index d73d06f..fd69a84 100644 --- a/src/NoteBookmark.Api/Dockerfile +++ b/src/NoteBookmark.Api/Dockerfile @@ -1,9 +1,9 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 8000 EXPOSE 8002 -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src # Copy central props for TargetFramework and package management COPY ["Directory.Build.props", "/src/"] diff --git a/src/NoteBookmark.Api/IAISettingsProvider.cs b/src/NoteBookmark.Api/IAISettingsProvider.cs new file mode 100644 index 0000000..89f3f28 --- /dev/null +++ b/src/NoteBookmark.Api/IAISettingsProvider.cs @@ -0,0 +1,10 @@ +namespace NoteBookmark.Api; + +/// +/// Provides AI configuration settings from the database with fallback to IConfiguration. +/// This ensures user-saved settings take precedence over environment variables. +/// +public interface IAISettingsProvider +{ + Task<(string ApiKey, string BaseUrl, string ModelName)> GetAISettingsAsync(); +} diff --git a/src/NoteBookmark.Api/NoteEnpoints.cs b/src/NoteBookmark.Api/NoteEnpoints.cs index 77d36f3..59bc41b 100644 --- a/src/NoteBookmark.Api/NoteEnpoints.cs +++ b/src/NoteBookmark.Api/NoteEnpoints.cs @@ -27,6 +27,15 @@ public static void MapNoteEndpoints(this IEndpointRouteBuilder app) endpoints.MapGet("/UpdatePostReadStatus", UpdatePostReadStatus) .WithDescription("Update the read status of all posts to true if they have a note referencing them."); + + endpoints.MapGet("/note/{rowKey}", GetNote) + .WithDescription("Get a specific note by its row key."); + + endpoints.MapPut("/note", UpdateNote) + .WithDescription("Update an existing note"); + + endpoints.MapDelete("/note/{rowKey}", DeleteNote) + .WithDescription("Delete a note"); } static Results, BadRequest> CreateNote(Note note, @@ -115,4 +124,44 @@ private static async Task> UpdatePostReadStatus(TableSer return TypedResults.BadRequest(); } } + + static Results, NotFound> GetNote(string rowKey, + TableServiceClient tblClient, + BlobServiceClient blobClient) + { + var dataStorageService = new DataStorageService(tblClient, blobClient); + var note = dataStorageService.GetNote(rowKey); + return note == null ? TypedResults.NotFound() : TypedResults.Ok(note); + } + + static Results, BadRequest> UpdateNote(Note note, + TableServiceClient tblClient, + BlobServiceClient blobClient) + { + try + { + if (!note.Validate()) + { + return TypedResults.BadRequest(); + } + + var dataStorageService = new DataStorageService(tblClient, blobClient); + dataStorageService.CreateNote(note); + return TypedResults.Ok(note); + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred while updating a note: {ex.Message}"); + return TypedResults.BadRequest(); + } + } + + static Results DeleteNote(string rowKey, + TableServiceClient tblClient, + BlobServiceClient blobClient) + { + var dataStorageService = new DataStorageService(tblClient, blobClient); + var result = dataStorageService.DeleteNote(rowKey); + return result ? TypedResults.Ok() : TypedResults.NotFound(); + } } diff --git a/src/NoteBookmark.Api/Program.cs b/src/NoteBookmark.Api/Program.cs index f8cfab6..6def1b2 100644 --- a/src/NoteBookmark.Api/Program.cs +++ b/src/NoteBookmark.Api/Program.cs @@ -1,36 +1,42 @@ -using Microsoft.Extensions.Azure; -using NoteBookmark.Api; - -var builder = WebApplication.CreateBuilder(args); - -builder.AddServiceDefaults(); -builder.AddAzureTableClient("nb-tables"); -builder.AddAzureBlobClient("nb-blobs"); - -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -app.MapDefaultEndpoints(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -app.MapPostEndpoints(); -app.MapNoteEndpoints(); -app.MapSummaryEndpoints(); -app.MapSettingEndpoints(); - -app.Run(); - -// Make the Program class accessible for testing -public partial class Program { } +using Microsoft.Extensions.Azure; +using NoteBookmark.Api; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddAzureTableClient("nb-tables"); +builder.AddAzureBlobClient("nb-blobs"); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Register data storage service +builder.Services.AddScoped(); + +// Register AI settings provider +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.MapPostEndpoints(); +app.MapNoteEndpoints(); +app.MapSummaryEndpoints(); +app.MapSettingEndpoints(); + +app.Run(); + +// Make the Program class accessible for testing +public partial class Program { } diff --git a/src/NoteBookmark.Api/SettingEndpoints.cs b/src/NoteBookmark.Api/SettingEndpoints.cs index 6301b92..d7a40aa 100644 --- a/src/NoteBookmark.Api/SettingEndpoints.cs +++ b/src/NoteBookmark.Api/SettingEndpoints.cs @@ -46,6 +46,14 @@ static async Task> SaveSettings(Settings settings, Table } var dataStorageService = new DataStorageService(tblClient, blobClient); + + // If API key is masked, preserve the existing value from database + if (settings.AiApiKey == "********") + { + var existingSettings = await dataStorageService.GetSettings(); + settings.AiApiKey = existingSettings.AiApiKey; + } + var result = await dataStorageService.SaveSettings(settings); return result ? TypedResults.Ok() : TypedResults.BadRequest(); } @@ -71,6 +79,12 @@ static async Task, BadRequest>> GetSettings(TableServiceCli settings.SummaryPrompt = "write a short introduction paragraph, without using '—', for the blog post: {content}"; } + // Security: Do not expose the API key to clients - return masked value + if (!string.IsNullOrWhiteSpace(settings.AiApiKey)) + { + settings.AiApiKey = "********"; // Masked for security + } + return settings != null ? TypedResults.Ok(settings) : TypedResults.BadRequest(); } } diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index 0ee93c9..264e783 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -1,36 +1,83 @@ +using Aspire.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Projects; var builder = DistributedApplication.CreateBuilder(args); -#pragma warning disable ASPIRECOMPUTE001 +// Load docker-compose environment var compose = builder.AddDockerComposeEnvironment("docker-env"); -var noteStorage = builder.AddAzureStorage("nb-storage"); - -var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); +// Add Keycloak authentication server +var keycloak = builder.AddKeycloak("keycloak", port: 8080) + .WithDataVolume(); // Persist Keycloak data across container restarts if (builder.Environment.IsDevelopment()) { + + var noteStorage = builder.AddAzureStorage("nb-storage"); + + var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); + noteStorage.RunAsEmulator(); + + var tables = noteStorage.AddTables("nb-tables"); + var blobs = noteStorage.AddBlobs("nb-blobs"); + + var api = builder.AddProject("api") + .WithReference(tables) + .WithReference(blobs) + .WaitFor(tables) + .WaitFor(blobs) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-api"; + }); + + builder.AddProject("blazor-app") + .WithReference(api) + .WithReference(tables) // Server-side access to Azure Tables for unmasked settings + .WithReference(keycloak) // Reference Keycloak for authentication + .WaitFor(api) + .WaitFor(keycloak) + //.WaitFor(compose) // Wait for docker-compose services to be ready + .WithExternalHttpEndpoints() + .WithEnvironment("REKA_API_KEY", apiKey) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-blazor"; + }); } +else +{ + // Production mode - no Aspire resources, expects docker-compose or Azure deployment + var noteStorage = builder.AddAzureStorage("nb-storage"); + + var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); -var tables = noteStorage.AddTables("nb-tables"); -var blobs = noteStorage.AddBlobs("nb-blobs"); - -var api = builder.AddProject("api") - .WithReference(tables) - .WithReference(blobs) - .WaitFor(tables) - .WaitFor(blobs) - .WithComputeEnvironment(compose); // comment this line to deploy to Azure - -builder.AddProject("blazor-app") - .WithReference(api) - .WaitFor(api) - .WithExternalHttpEndpoints() - .WithEnvironment("REKA_API_KEY", apiKey) - .WithComputeEnvironment(compose); // comment this line to deploy to Azure + var tables = noteStorage.AddTables("nb-tables"); + var blobs = noteStorage.AddBlobs("nb-blobs"); + + var api = builder.AddProject("api") + .WithReference(tables) + .WithReference(blobs) + .WaitFor(tables) + .WaitFor(blobs) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-api"; + }); + + builder.AddProject("blazor-app") + .WithReference(api) + .WithReference(tables) // Server-side access to Azure Tables for unmasked settings + .WaitFor(api) + .WithExternalHttpEndpoints() + .WithEnvironment("REKA_API_KEY", apiKey) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-blazor"; + }); +} builder.Build().Run(); diff --git a/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj b/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj index 2267fb5..3e6cdf8 100644 --- a/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj +++ b/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj @@ -1,17 +1,17 @@ - - - - Exe - true - 0784f0a9-b1e6-4e65-8d31-00f1369f6d75 - - - - - - - - - - + + + Exe + true + 0784f0a9-b1e6-4e65-8d31-00f1369f6d75 + + + + + + + + + + + \ No newline at end of file diff --git a/src/NoteBookmark.BlazorApp/AISettingsProvider.cs b/src/NoteBookmark.BlazorApp/AISettingsProvider.cs new file mode 100644 index 0000000..71526c5 --- /dev/null +++ b/src/NoteBookmark.BlazorApp/AISettingsProvider.cs @@ -0,0 +1,79 @@ +using Azure.Data.Tables; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NoteBookmark.Domain; + +namespace NoteBookmark.BlazorApp; + +/// +/// Server-side settings provider that retrieves unmasked AI configuration directly from Azure Table Storage. +/// This is only for internal server-side use by AI services - external API endpoints should mask secrets. +/// +public class AISettingsProvider +{ + private readonly TableServiceClient _tableClient; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public AISettingsProvider( + TableServiceClient tableClient, + IConfiguration config, + ILogger logger) + { + _tableClient = tableClient; + _config = config; + _logger = logger; + } + + public async Task<(string ApiKey, string BaseUrl, string ModelName)> GetAISettingsAsync() + { + try + { + // Direct database access - bypasses the HTTP API endpoint that masks secrets + var settingsTable = _tableClient.GetTableClient("Settings"); + await settingsTable.CreateIfNotExistsAsync(); + + var result = await settingsTable.GetEntityIfExistsAsync("setting", "setting"); + + if (result.HasValue) + { + var settings = result.Value; + + // Check if user has configured AI settings in the database + if (settings != null && !string.IsNullOrWhiteSpace(settings.AiApiKey)) + { + _logger.LogDebug("Using AI settings from database (unmasked for server-side use)"); + + string baseUrl = !string.IsNullOrWhiteSpace(settings.AiBaseUrl) + ? settings.AiBaseUrl + : "https://api.reka.ai/v1"; + + string modelName = !string.IsNullOrWhiteSpace(settings.AiModelName) + ? settings.AiModelName + : "reka-flash-3.1"; + + return (settings.AiApiKey, baseUrl, modelName); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve settings from database, falling back to configuration"); + } + + // Fallback to IConfiguration (environment variables, appsettings.json) + _logger.LogDebug("Using AI settings from IConfiguration fallback"); + + string? apiKey = _config["AppSettings:AiApiKey"] + ?? _config["AppSettings:REKA_API_KEY"] + ?? Environment.GetEnvironmentVariable("REKA_API_KEY"); + + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("AI API key not configured. Set AiApiKey in settings or REKA_API_KEY environment variable."); + + string fallbackBaseUrl = _config["AppSettings:AiBaseUrl"] ?? "https://api.reka.ai/v1"; + string fallbackModelName = _config["AppSettings:AiModelName"] ?? "reka-flash-3.1"; + + return (apiKey, fallbackBaseUrl, fallbackModelName); + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor b/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor index 340eae8..097843c 100644 --- a/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor +++ b/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor @@ -1,45 +1,49 @@ -@inherits LayoutComponentBase -@using Microsoft.FluentUI.AspNetCore.Components -@using Microsoft.FluentUI.AspNetCore.Components.Extensions -@inject NavigationManager NavigationManager - - - - Note Bookmark - - - - - - - - -
- @Body -
-
-
- - Documentation and demos - - About Blazor - -
- - - - - -
- An unhandled error has occurred. - Reload - 🗙 -
- - - -@code { - - public DesignThemeModes Mode { get; set; } - public OfficeColor? OfficeColor { get; set; } -} +@inherits LayoutComponentBase +@using Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using NoteBookmark.BlazorApp.Components.Shared +@inject NavigationManager NavigationManager + + + + Note Bookmark + + + + + + + + + + + +
+ @Body +
+
+
+ + Documentation and demos + + About Blazor + +
+ + + + + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + +@code { + + public DesignThemeModes Mode { get; set; } + public OfficeColor? OfficeColor { get; set; } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor index 576cc2d..cd14c6e 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor @@ -1,4 +1,6 @@ @page "/Error" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization @using System.Diagnostics Error diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor index 6f7e2e6..c00d62b 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor @@ -1,66 +1,68 @@ -@page "/" -@using Microsoft.FluentUI.AspNetCore.Components -@inject NavigationManager Navigation - -Home - NoteBookmark - - -

📚 NoteBookmark

- - -

Your personal reading companion for capturing thoughts and insights from articles and blog posts. - Transform your reading notes into polished summaries, perfect for sharing your weekly discoveries.

-
- - - - -
📝
-

Manage Posts

-

Collect articles to read and add your notes as you go through them.

-
-
- - - -
🔍
-

AI-Powered Search

-

Discover relevant content with intelligent suggestions tailored to your interests.

-
-
- - - -
-

Generate Summaries

-

Create beautiful summaries of your reading notes with AI assistance.

-
-
-
- - - - -

Built with Modern Tech

- - - .NET 9 - - - Blazor - - - Fluent UI Blazor - - - Aspire - - - Azure Table Storage - - - Reka AI - - -
+@page "/" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.FluentUI.AspNetCore.Components +@inject NavigationManager Navigation + +Home - NoteBookmark + + +

📚 NoteBookmark

+ + +

Your personal reading companion for capturing thoughts and insights from articles and blog posts. + Transform your reading notes into polished summaries, perfect for sharing your weekly discoveries.

+
+ + + + +
📝
+

Manage Posts

+

Collect articles to read and add your notes as you go through them.

+
+
+ + + +
🔍
+

AI-Powered Search

+

Discover relevant content with intelligent suggestions tailored to your interests.

+
+
+ + + +
+

Generate Summaries

+

Create beautiful summaries of your reading notes with AI assistance.

+
+
+
+ + + + +

Built with Modern Tech

+ + + .NET 9 + + + Blazor + + + Fluent UI Blazor + + + Aspire + + + Azure Table Storage + + + Reka AI + + +
\ No newline at end of file diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Login.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Login.razor new file mode 100644 index 0000000..bb64738 --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Login.razor @@ -0,0 +1,27 @@ +@page "/login" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.OpenIdConnect +@inject NavigationManager Navigation +@inject IHttpContextAccessor HttpContextAccessor +@code { + protected override async Task OnInitializedAsync() + { + // Get the return URL from query string or default to home + var uri = new Uri(Navigation.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var returnUrl = query["returnUrl"] ?? "/"; + + // Trigger authentication challenge via HttpContext + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + var authProperties = new AuthenticationProperties + { + RedirectUri = returnUrl + }; + await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); + } + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Logout.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Logout.razor new file mode 100644 index 0000000..4bfd9da --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Logout.razor @@ -0,0 +1,22 @@ +@page "/logout" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.Cookies +@using Microsoft.AspNetCore.Authentication.OpenIdConnect +@inject IHttpContextAccessor HttpContextAccessor +@code { + protected override async Task OnInitializedAsync() + { + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + var properties = new AuthenticationProperties + { + RedirectUri = "/" + }; + await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, properties); + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor index 0167b46..d2d5f52 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor @@ -1,5 +1,6 @@ @page "/posteditor/{id?}" - +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.BlazorApp @using NoteBookmark.Domain @inject PostNoteClient client diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor index 8448b02..2f7a149 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor @@ -1,5 +1,6 @@ @page "/posteditorlight/{id?}" - +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.BlazorApp @using NoteBookmark.BlazorApp.Components.Layout @using NoteBookmark.Domain diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor index e43e68f..fdfef51 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor @@ -1,4 +1,6 @@ @page "/posts" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.BlazorApp.Components.Shared @using NoteBookmark.Domain @using Microsoft.FluentUI.AspNetCore.Components @@ -35,7 +37,7 @@ } else { - + } @@ -98,23 +100,74 @@ }); var result = await dialog.Result; - if (!result.Cancelled && result.Data != null) + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) { - var note = (Note)result.Data; - await client.CreateNote(note); - ShowConfirmationMessage(); - await LoadPosts(); + if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + await client.CreateNote(dialogResult.Note); + toastService.ShowSuccess("Note created successfully!"); + await LoadPosts(); + } } } - private void ShowConfirmationMessage() + private void EditNote(string postId) { - toastService.ShowSuccess("Note created successfully!"); + Navigation.NavigateTo($"posteditor/{postId}"); } - private void EditNote(string postId) + private async Task EditNoteForPost(string noteId) { - Navigation.NavigateTo($"posteditor/{postId}"); + try + { + var existingNote = await client.GetNote(noteId); + if (existingNote == null) + { + toastService.ShowError("Note not found."); + return; + } + + IDialogReference dialog = await DialogService.ShowDialogAsync(existingNote, new DialogParameters(){ + Title = "Edit note", + PreventDismissOnOverlayClick = true, + PreventScroll = true, + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) + { + if (dialogResult.Action == "Delete" && dialogResult.Note != null) + { + var deleteResult = await client.DeleteNote(dialogResult.Note.RowKey); + if (deleteResult) + { + toastService.ShowSuccess("Note deleted successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to delete note. Please try again."); + } + } + else if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + var updateResult = await client.UpdateNote(dialogResult.Note); + if (updateResult) + { + toastService.ShowSuccess("Note updated successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to update note. Please try again."); + } + } + } + } + catch (Exception) + { + toastService.ShowError("An error occurred. Please try again."); + } } private async Task AddNewPost() diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor index 9c4b02d..4eab507 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor @@ -1,4 +1,6 @@ @page "/search" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.AIServices @using NoteBookmark.BlazorApp.Components.Shared @using NoteBookmark.Domain diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor index 4c57b86..bc250c9 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor @@ -1,122 +1,139 @@ -@page "/settings" - -@using Microsoft.FluentUI.AspNetCore.Components.Extensions -@using NoteBookmark.Domain -@inject ILogger Logger -@inject PostNoteClient client -@inject NavigationManager Navigation -@using NoteBookmark.BlazorApp - -@rendermode InteractiveServer - - - -

Settings

- -
- - - - - - - - - - - @context - - - - - -
- -
- -@if( settings != null) -{ -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Save - - - -
-} - - -@code { - public DesignThemeModes Mode { get; set; } - public OfficeColor? OfficeColor { get; set; } - - private Domain.Settings? settings; - - protected override async Task OnInitializedAsync() - { - settings = await client.GetSettings(); - } - - private async Task SaveSettings() - { - if (settings != null) - { - await client.SaveSettings(settings); - Navigation.NavigateTo("/"); - } - } - - void OnLoaded(LoadedEventArgs e) - { - Logger.LogInformation($"Loaded: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); - } - - void OnLuminanceChanged(LuminanceChangedEventArgs e) - { - Logger.LogInformation($"Changed: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); - } - - private void IncrementCounter() - { - var cnt = Convert.ToInt32(settings!.ReadingNotesCounter)+1; - settings.ReadingNotesCounter = (cnt).ToString(); - } -} +@page "/settings" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using NoteBookmark.Domain +@inject ILogger Logger +@inject PostNoteClient client +@inject NavigationManager Navigation +@using NoteBookmark.BlazorApp + +@rendermode InteractiveServer + + + +

Settings

+ +
+ + + + + + + + + + + @context + + + + + +
+ +
+ +@if( settings != null) +{ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AI Provider Configuration + + + + + + + + + + + + + + Save + + + +
+} + + +@code { + public DesignThemeModes Mode { get; set; } + public OfficeColor? OfficeColor { get; set; } + + private Domain.Settings? settings; + + protected override async Task OnInitializedAsync() + { + settings = await client.GetSettings(); + } + + private async Task SaveSettings() + { + if (settings != null) + { + await client.SaveSettings(settings); + Navigation.NavigateTo("/"); + } + } + + void OnLoaded(LoadedEventArgs e) + { + Logger.LogInformation($"Loaded: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); + } + + void OnLuminanceChanged(LuminanceChangedEventArgs e) + { + Logger.LogInformation($"Changed: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); + } + + private void IncrementCounter() + { + var cnt = Convert.ToInt32(settings!.ReadingNotesCounter)+1; + settings.ReadingNotesCounter = (cnt).ToString(); + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor index c9dc2fb..9009a4f 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor @@ -1,4 +1,6 @@ @page "/summaries" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.Domain @inject PostNoteClient client @rendermode InteractiveServer diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor b/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor index c9bfd49..4397287 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor @@ -1,5 +1,6 @@ @page "/summaryeditor/{number?}" - +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using Markdig @using NoteBookmark.Domain @using NoteBookmark.AIServices diff --git a/src/NoteBookmark.BlazorApp/Components/Routes.razor b/src/NoteBookmark.BlazorApp/Components/Routes.razor index 4d3379c..842b358 100644 --- a/src/NoteBookmark.BlazorApp/Components/Routes.razor +++ b/src/NoteBookmark.BlazorApp/Components/Routes.razor @@ -1,8 +1,42 @@ - +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Authorization - - - - - - + + + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + +

Authentication Required

+

You need to be logged in to access this page.

+ + Login + +
+ } + else + { + + +

Access Denied

+

You don't have permission to access this page.

+ + Go to Home + +
+ } +
+
+ +
+
+
+ +@code { + [Inject] private NavigationManager NavigationManager { get; set; } = default!; +} diff --git a/src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor b/src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor new file mode 100644 index 0000000..d3b4595 --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor @@ -0,0 +1,36 @@ +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Components.Authorization +@inject NavigationManager Navigation + + + + + Hello, @context.User.Identity?.Name + + Logout + + + + + + Login + + + + +@code { + private void Login() + { + var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); + if (string.IsNullOrEmpty(returnUrl)) + { + returnUrl = "/"; + } + Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false); + } + + private void Logout() + { + Navigation.NavigateTo("/logout", forceLoad: false); + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Shared/NoteDialog.razor b/src/NoteBookmark.BlazorApp/Components/Shared/NoteDialog.razor index 396cb5b..79526a6 100644 --- a/src/NoteBookmark.BlazorApp/Components/Shared/NoteDialog.razor +++ b/src/NoteBookmark.BlazorApp/Components/Shared/NoteDialog.razor @@ -35,8 +35,24 @@
- - + + + + @foreach (var tag in _currentTags) + { + + @tag + + } + + + + Add + + +
@@ -56,6 +72,14 @@ OnClick="@CancelAsync"> Cancel + @if (_isEditMode) + { + + Delete + + } @code { @@ -68,19 +92,40 @@ private Domain.Note _note = default!; private List _categories = NoteCategories.GetCategories(); + private bool _isEditMode = false; + + private List _currentTags = new(); + private string _newTagInput = string.Empty; protected override void OnInitialized() { - _note = new Note{PostId = Content.PostId}; + // Check if we're editing an existing note or creating a new one + _isEditMode = !string.IsNullOrEmpty(Content.RowKey) && !Content.RowKey.Equals(Guid.Empty.ToString(), StringComparison.OrdinalIgnoreCase); + + if (_isEditMode) + { + // Editing mode - use the existing note data + _note = Content; + } + else + { + // Create mode - create a new note with the PostId + _note = new Note { PostId = Content.PostId }; + } + + ParseTagsFromString(); } private async Task SaveAsync() { - _note.DateAdded = DateTime.UtcNow; + if (!_isEditMode) + { + _note.DateAdded = DateTime.UtcNow; + } if (_note.Validate()) { - await Dialog.CloseAsync(_note); + await Dialog.CloseAsync(new NoteDialogResult { Action = "Save", Note = _note }); } } @@ -89,5 +134,57 @@ await Dialog.CancelAsync(); } + private async Task DeleteAsync() + { + await Dialog.CloseAsync(new NoteDialogResult { Action = "Delete", Note = _note }); + } + + private void ParseTagsFromString() + { + _currentTags = string.IsNullOrWhiteSpace(_note.Tags) + ? new List() + : _note.Tags.Split(',') + .Select(t => t.Trim().ToLower()) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct() + .ToList(); + } + + private void SerializeTagsToString() + { + _note.Tags = _currentTags.Any() ? string.Join(", ", _currentTags) : null; + } + + private void AddTag(string tag) + { + if (string.IsNullOrWhiteSpace(tag)) return; + + var normalizedTag = tag.Trim().ToLower(); + if (!_currentTags.Contains(normalizedTag)) + { + _currentTags.Add(normalizedTag); + SerializeTagsToString(); + _newTagInput = string.Empty; + StateHasChanged(); + } + } + + private void RemoveTag(string tag) + { + _currentTags.Remove(tag); + SerializeTagsToString(); + StateHasChanged(); + } + + private void HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + if (!string.IsNullOrWhiteSpace(_newTagInput)) + { + AddTag(_newTagInput); + } + } + } } diff --git a/src/NoteBookmark.BlazorApp/Data/research_response_2026-02-14_15-38.json b/src/NoteBookmark.BlazorApp/Data/research_response_2026-02-14_15-38.json new file mode 100644 index 0000000..c522dfa --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Data/research_response_2026-02-14_15-38.json @@ -0,0 +1,25 @@ +{ + "suggestions": [ + { + "title": "5 Ways Your .NET Developers Can Get Started with Azure Machine Learning", + "author": "James Serra, Azure Developer Ecosystem blog", + "summary": "Azure Machine Learning is a cloud-based platform for building and deploying AI models. In this article we will explore how your .Net developers can get started working with Azure Machine Learning through Visual Studio Code.", + "publication_date": {}, + "url": "https://jamesmicrosoftcom/5-ways-get-started-with-azure-machine-learning-as-a-net-developer/" + }, + { + "title": "C# and C++ Machine Learning for .NET Developers and AI Researchers", + "author": "Pankaj Dua, aka ‘AI Guy’ on Microsoft’s Developer Community blog.", + "summary": "In this article we'll present how to use open-source libraries like Accord.NET to build machine learning models in your choice of languages i.e., C#, F# or even C++. We'll walk through building your 'first ML model' using popular tools that you might have never used.", + "publication_date": {}, + "url": "https://blogs.msdn.microsoft.com/ptgoa/c-cpp-companion-piece-on-ml-in-dot-net-world/" + }, + { + "title": ".NET AI – a new home for .NET Machine Learning", + "author": "Microsoft .Net blog", + "summary": "Find out about latest developments in the world of machine learning on .net, including deep dive into ONNX and .NET Core. ", + "publication_date": {}, + "url": "https://devblogs.microsoft.com/dotnet/net-ai-a-new-home-for-net-machine-learning/" + } + ] +} \ No newline at end of file diff --git a/src/NoteBookmark.BlazorApp/Dockerfile b/src/NoteBookmark.BlazorApp/Dockerfile index d286c0e..c16aa42 100644 --- a/src/NoteBookmark.BlazorApp/Dockerfile +++ b/src/NoteBookmark.BlazorApp/Dockerfile @@ -1,9 +1,9 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 8004 EXPOSE 8006 -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY ["Directory.Build.props", "/src/"] COPY ["Directory.Packages.props", "/src/"] diff --git a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj index e9f531d..bc0fb7c 100644 --- a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj +++ b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj @@ -1,7 +1,10 @@ + + + diff --git a/src/NoteBookmark.BlazorApp/PostNoteClient.cs b/src/NoteBookmark.BlazorApp/PostNoteClient.cs index 65e6a49..4ceee52 100644 --- a/src/NoteBookmark.BlazorApp/PostNoteClient.cs +++ b/src/NoteBookmark.BlazorApp/PostNoteClient.cs @@ -1,163 +1,181 @@ -using System; -using NoteBookmark.Domain; - -namespace NoteBookmark.BlazorApp; - -public class PostNoteClient(HttpClient httpClient) -{ - public async Task> GetUnreadPosts() - { - var posts = await httpClient.GetFromJsonAsync>("api/posts"); - return posts ?? new List(); - } - - public async Task> GetReadPosts() - { - var posts = await httpClient.GetFromJsonAsync>("api/posts/read"); - return posts ?? new List(); - } - - public async Task> GetSummaries() - { - var summaries = await httpClient.GetFromJsonAsync>("api/summary"); - return summaries ?? new List(); - } - - public async Task CreateNote(Note note) - { - var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); - note.PartitionKey = rnCounter; - var response = await httpClient.PostAsJsonAsync("api/notes/note", note); - response.EnsureSuccessStatusCode(); - } - - public async Task CreateReadingNotes() - { - var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); - var readingNotes = new ReadingNotes(rnCounter); - - //Get all unused notes - var unsortedNotes = await httpClient.GetFromJsonAsync>($"api/notes/GetNotesForSummary/{rnCounter}"); - - if(unsortedNotes == null || unsortedNotes.Count == 0){ - return readingNotes; - } - - Dictionary> sortedNotes = GroupNotesByCategory(unsortedNotes); - - readingNotes.Notes = sortedNotes; - readingNotes.Tags = readingNotes.GetAllUniqueTags(); - - return readingNotes; - } - - public async Task GetReadingNotes(string number) - { - ReadingNotes? readingNotes; - readingNotes = await httpClient.GetFromJsonAsync($"api/summary/{number}"); - - return readingNotes; - } - - - private Dictionary> GroupNotesByCategory(List notes) - { - var sortedNotes = new Dictionary>(); - - foreach (var note in notes) - { - var tags = note.Tags?.ToLower().Split(',') ?? Array.Empty(); - - if(string.IsNullOrEmpty(note.Category)){ - note.Category = NoteCategories.GetCategory(tags[0]); - } - - string category = note.Category; - if (sortedNotes.ContainsKey(category)) - { - sortedNotes[category].Add(note); - } - else - { - sortedNotes.Add(category, new List {note}); - } - } - - return sortedNotes; - } - - public async Task SaveReadingNotes(ReadingNotes readingNotes) - { - var response = await httpClient.PostAsJsonAsync("api/notes/SaveReadingNotes", readingNotes); - - string jsonURL = ((string)await response.Content.ReadAsStringAsync()).Replace("\"", ""); - - if (response.IsSuccessStatusCode && !string.IsNullOrEmpty(jsonURL)) - { - var summary = new Summary - { - PartitionKey = readingNotes.Number, - RowKey = readingNotes.Number, - Title = readingNotes.Title, - Id = readingNotes.Number, - IsGenerated = "true", - PublishedURL = readingNotes.PublishedUrl, - FileName = jsonURL - }; - - var summaryResponse = await httpClient.PostAsJsonAsync("api/summary/summary", summary); - return summaryResponse.IsSuccessStatusCode; - } - - return false; - } - - - public async Task GetPost(string id) - { - var post = await httpClient.GetFromJsonAsync($"api/posts/{id}"); - return post; - } - - - public async Task SavePost(Post post) - { - var response = await httpClient.PostAsJsonAsync("api/posts", post); - return response.IsSuccessStatusCode; - } - - public async Task GetSettings() - { - var settings = await httpClient.GetFromJsonAsync("api/settings"); - return settings; - } - - public async Task SaveSettings(Settings settings) - { - var response = await httpClient.PostAsJsonAsync("api/settings/SaveSettings", settings); - return response.IsSuccessStatusCode; - } - - public async Task ExtractPostDetailsAndSave(string url) - { - //var encodedUrl = System.Net.WebUtility.UrlEncode(url); - var requestBody = new {url = url}; - - var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails", requestBody); - // var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails?url={encodedUrl}", url); - return response.IsSuccessStatusCode; - } - - public async Task DeletePost(string id) - { - var response = await httpClient.DeleteAsync($"api/posts/{id}"); - return response.IsSuccessStatusCode; - } - - public async Task SaveReadingNotesMarkdown(string markdown, string number) - { - var request = new { Markdown = markdown }; - var response = await httpClient.PostAsJsonAsync($"api/summary/{number}/markdown", request); - return response.IsSuccessStatusCode; - } -} +using System; +using NoteBookmark.Domain; + +namespace NoteBookmark.BlazorApp; + +public class PostNoteClient(HttpClient httpClient) +{ + public async Task> GetUnreadPosts() + { + var posts = await httpClient.GetFromJsonAsync>("api/posts"); + return posts ?? new List(); + } + + public async Task> GetReadPosts() + { + var posts = await httpClient.GetFromJsonAsync>("api/posts/read"); + return posts ?? new List(); + } + + public async Task> GetSummaries() + { + var summaries = await httpClient.GetFromJsonAsync>("api/summary"); + return summaries ?? new List(); + } + + public async Task CreateNote(Note note) + { + var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); + note.PartitionKey = rnCounter; + var response = await httpClient.PostAsJsonAsync("api/notes/note", note); + response.EnsureSuccessStatusCode(); + } + + public async Task GetNote(string noteId) + { + var note = await httpClient.GetFromJsonAsync($"api/notes/note/{noteId}"); + return note; + } + + public async Task UpdateNote(Note note) + { + var response = await httpClient.PutAsJsonAsync("api/notes/note", note); + return response.IsSuccessStatusCode; + } + + public async Task DeleteNote(string noteId) + { + var response = await httpClient.DeleteAsync($"api/notes/note/{noteId}"); + return response.IsSuccessStatusCode; + } + + public async Task CreateReadingNotes() + { + var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); + var readingNotes = new ReadingNotes(rnCounter); + + //Get all unused notes + var unsortedNotes = await httpClient.GetFromJsonAsync>($"api/notes/GetNotesForSummary/{rnCounter}"); + + if(unsortedNotes == null || unsortedNotes.Count == 0){ + return readingNotes; + } + + Dictionary> sortedNotes = GroupNotesByCategory(unsortedNotes); + + readingNotes.Notes = sortedNotes; + readingNotes.Tags = readingNotes.GetAllUniqueTags(); + + return readingNotes; + } + + public async Task GetReadingNotes(string number) + { + ReadingNotes? readingNotes; + readingNotes = await httpClient.GetFromJsonAsync($"api/summary/{number}"); + + return readingNotes; + } + + + private Dictionary> GroupNotesByCategory(List notes) + { + var sortedNotes = new Dictionary>(); + + foreach (var note in notes) + { + var tags = note.Tags?.ToLower().Split(',') ?? Array.Empty(); + + if(string.IsNullOrEmpty(note.Category)){ + note.Category = NoteCategories.GetCategory(tags[0]); + } + + string category = note.Category; + if (sortedNotes.ContainsKey(category)) + { + sortedNotes[category].Add(note); + } + else + { + sortedNotes.Add(category, new List {note}); + } + } + + return sortedNotes; + } + + public async Task SaveReadingNotes(ReadingNotes readingNotes) + { + var response = await httpClient.PostAsJsonAsync("api/notes/SaveReadingNotes", readingNotes); + + string jsonURL = ((string)await response.Content.ReadAsStringAsync()).Replace("\"", ""); + + if (response.IsSuccessStatusCode && !string.IsNullOrEmpty(jsonURL)) + { + var summary = new Summary + { + PartitionKey = readingNotes.Number, + RowKey = readingNotes.Number, + Title = readingNotes.Title, + Id = readingNotes.Number, + IsGenerated = "true", + PublishedURL = readingNotes.PublishedUrl, + FileName = jsonURL + }; + + var summaryResponse = await httpClient.PostAsJsonAsync("api/summary/summary", summary); + return summaryResponse.IsSuccessStatusCode; + } + + return false; + } + + + public async Task GetPost(string id) + { + var post = await httpClient.GetFromJsonAsync($"api/posts/{id}"); + return post; + } + + + public async Task SavePost(Post post) + { + var response = await httpClient.PostAsJsonAsync("api/posts", post); + return response.IsSuccessStatusCode; + } + + public async Task GetSettings() + { + var settings = await httpClient.GetFromJsonAsync("api/settings"); + return settings; + } + + public async Task SaveSettings(Settings settings) + { + var response = await httpClient.PostAsJsonAsync("api/settings/SaveSettings", settings); + return response.IsSuccessStatusCode; + } + + public async Task ExtractPostDetailsAndSave(string url) + { + //var encodedUrl = System.Net.WebUtility.UrlEncode(url); + var requestBody = new {url = url}; + + var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails", requestBody); + // var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails?url={encodedUrl}", url); + return response.IsSuccessStatusCode; + } + + public async Task DeletePost(string id) + { + var response = await httpClient.DeleteAsync($"api/posts/{id}"); + return response.IsSuccessStatusCode; + } + + public async Task SaveReadingNotesMarkdown(string markdown, string number) + { + var request = new { Markdown = markdown }; + var response = await httpClient.PostAsJsonAsync($"api/summary/{number}/markdown", request); + return response.IsSuccessStatusCode; + } +} diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 896b180..5404ab5 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -1,3 +1,6 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.FluentUI.AspNetCore.Components; using NoteBookmark.AIServices; using NoteBookmark.BlazorApp; @@ -6,41 +9,132 @@ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.AddAzureTableClient("nb-tables"); -// Register ResearchService with a manual HttpClient to bypass Aspire resilience policies -// builder.Services.AddTransient(sp => -// { -// var handler = new SocketsHttpHandler -// { -// PooledConnectionLifetime = TimeSpan.FromMinutes(5), -// ConnectTimeout = TimeSpan.FromMinutes(5) -// }; - -// var httpClient = new HttpClient(handler) -// { -// Timeout = TimeSpan.FromMinutes(5) -// }; - -// var logger = sp.GetRequiredService>(); -// var config = sp.GetRequiredService(); - -// return new ResearchService(httpClient, logger, config); -// }); - +// Add HTTP client for API calls builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https+http://api"); }); -builder.Services.AddHttpClient(client => +// Register server-side AI settings provider (direct database access, unmasked) +builder.Services.AddScoped(); + +// Register AI services with settings provider that reads directly from database +builder.Services.AddTransient(sp => { - client.Timeout = TimeSpan.FromMinutes(5); + var logger = sp.GetRequiredService>(); + var settingsProvider = sp.GetRequiredService(); + + // Settings provider that fetches directly from database (server-side, unmasked) + Func> provider = async () => + { + var settings = await settingsProvider.GetAISettingsAsync(); + return ( + settings.ApiKey, + settings.BaseUrl, + settings.ModelName + ); + }; + + return new SummaryService(logger, provider); }); +builder.Services.AddHttpClient(nameof(ResearchService)); + +builder.Services.AddTransient(sp => +{ + var logger = sp.GetRequiredService>(); + var settingsProvider = sp.GetRequiredService(); + var httpClientFactory = sp.GetRequiredService(); + var client = httpClientFactory.CreateClient(nameof(ResearchService)); + + // Settings provider that fetches directly from database (server-side, unmasked) + Func> provider = async () => + { + var settings = await settingsProvider.GetAISettingsAsync(); + return ( + settings.ApiKey, + settings.BaseUrl, + settings.ModelName + ); + }; + + return new ResearchService(client, logger, provider); +}); + + +// Add authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; +}) +.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) +.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => +{ + var authority = builder.Configuration["Keycloak:Authority"]; + options.Authority = authority; + options.ClientId = builder.Configuration["Keycloak:ClientId"]; + options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"]; + options.ResponseType = "code"; + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + + // Allow overriding RequireHttpsMetadata via configuration for development/docker scenarios. + // If not explicitly configured, relax the requirement when running in a container against HTTP Keycloak. + var requireHttpsConfigured = builder.Configuration.GetValue("Keycloak:RequireHttpsMetadata"); + var isRunningInContainer = string.Equals( + System.Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), + "true", + StringComparison.OrdinalIgnoreCase); -builder.Services.AddHttpClient(); - // .AddStandardResilienceHandler(); + if (requireHttpsConfigured.HasValue) + { + options.RequireHttpsMetadata = requireHttpsConfigured.Value; + } + else + { + var defaultRequireHttps = !builder.Environment.IsDevelopment(); + if (isRunningInContainer && + !string.IsNullOrEmpty(authority) && + authority.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + defaultRequireHttps = false; + } + + options.RequireHttpsMetadata = defaultRequireHttps; + } + + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + + options.TokenValidationParameters = new() + { + NameClaimType = "preferred_username", + RoleClaimType = "roles" + }; + + // Configure logout to properly pass id_token_hint to Keycloak + options.Events = new OpenIdConnectEvents + { + OnRedirectToIdentityProviderForSignOut = async context => + { + // Get the id_token from saved tokens + var idToken = await context.HttpContext.GetTokenAsync("id_token"); + if (!string.IsNullOrEmpty(idToken)) + { + context.ProtocolMessage.IdTokenHint = idToken; + } + } + }; +}); +builder.Services.AddAuthorization(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddHttpContextAccessor(); // Add services to the container. builder.Services.AddRazorComponents() @@ -63,7 +157,30 @@ app.UseStaticFiles(); app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapRazorComponents() .AddInteractiveServerRenderMode(); +// Authentication endpoints +app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => +{ + var authProperties = new AuthenticationProperties + { + RedirectUri = returnUrl ?? "/" + }; + await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); +}); + +app.MapGet("/authentication/logout", async (HttpContext context) => +{ + var authProperties = new AuthenticationProperties + { + RedirectUri = "/" + }; + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); +}); + app.Run(); diff --git a/src/NoteBookmark.Domain/NoteDialogResult.cs b/src/NoteBookmark.Domain/NoteDialogResult.cs new file mode 100644 index 0000000..691f609 --- /dev/null +++ b/src/NoteBookmark.Domain/NoteDialogResult.cs @@ -0,0 +1,7 @@ +namespace NoteBookmark.Domain; + +public class NoteDialogResult +{ + public string Action { get; set; } = "Save"; + public Note? Note { get; set; } +} diff --git a/src/NoteBookmark.Domain/PostSuggestion.cs b/src/NoteBookmark.Domain/PostSuggestion.cs index f6fe06a..54cd6b3 100644 --- a/src/NoteBookmark.Domain/PostSuggestion.cs +++ b/src/NoteBookmark.Domain/PostSuggestion.cs @@ -31,15 +31,57 @@ public class DateOnlyJsonConverter : JsonConverter if (reader.TokenType == JsonTokenType.Null) return null; - var dateString = reader.GetString(); - if (string.IsNullOrEmpty(dateString)) - return null; + try + { + // Handle different JSON token types the AI might return + switch (reader.TokenType) + { + case JsonTokenType.String: + var dateString = reader.GetString(); + if (string.IsNullOrEmpty(dateString)) + return null; + + // Try to parse as DateTime and format to yyyy-MM-dd + if (DateTime.TryParse(dateString, out var date)) + { + return date.ToString(DateFormat); + } + // If parsing fails, return the original string + return dateString; + + case JsonTokenType.Number: + // Handle Unix timestamp (seconds or milliseconds) + if (reader.TryGetInt64(out var timestamp)) + { + DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // Assume milliseconds if > year 2100 in seconds (2147483647) + var dateTime = timestamp > 2147483647 + ? epoch.AddMilliseconds(timestamp) + : epoch.AddSeconds(timestamp); + return dateTime.ToString(DateFormat); + } + break; - if (DateTime.TryParse(dateString, out var date)) + case JsonTokenType.True: + case JsonTokenType.False: + // Handle unexpected boolean - convert to string + return reader.GetBoolean().ToString(); + + case JsonTokenType.StartObject: + case JsonTokenType.StartArray: + // Handle complex types - skip and return null + reader.Skip(); + return null; + } + } + catch { - return date.ToString(DateFormat); + // If any parsing fails, skip the value and return null to gracefully degrade + try { reader.Skip(); } catch { /* ignore */ } + return null; } - return dateString; + + return null; } public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) diff --git a/src/NoteBookmark.Domain/Settings.cs b/src/NoteBookmark.Domain/Settings.cs index fe5e4eb..f37d026 100644 --- a/src/NoteBookmark.Domain/Settings.cs +++ b/src/NoteBookmark.Domain/Settings.cs @@ -1,40 +1,52 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.Runtime.Serialization; -using Azure; -using Azure.Data.Tables; - -namespace NoteBookmark.Domain; - -public class Settings: ITableEntity -{ - [DataMember(Name="last_bookmark_date")] - public string? LastBookmarkDate { get; set; } - - - [DataMember(Name="reading_notes_counter")] - public string? ReadingNotesCounter { get; set; } - - - [DataMember(Name="favorite_domains")] - public string? FavoriteDomains { get; set; } - - - [DataMember(Name="blocked_domains")] - public string? BlockedDomains { get; set; } - - - [DataMember(Name="summary_prompt")] - [ContainsPlaceholder("content")] - public string? SummaryPrompt { get; set; } - - - [DataMember(Name="search_prompt")] - [ContainsPlaceholder("topic")] - public string? SearchPrompt { get; set; } - - public required string PartitionKey { get ; set; } - public required string RowKey { get ; set; } - public DateTimeOffset? Timestamp { get; set; } - public ETag ETag { get; set; } -} +using System; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Azure; +using Azure.Data.Tables; + +namespace NoteBookmark.Domain; + +public class Settings: ITableEntity +{ + [DataMember(Name="last_bookmark_date")] + public string? LastBookmarkDate { get; set; } + + + [DataMember(Name="reading_notes_counter")] + public string? ReadingNotesCounter { get; set; } + + + [DataMember(Name="favorite_domains")] + public string? FavoriteDomains { get; set; } + + + [DataMember(Name="blocked_domains")] + public string? BlockedDomains { get; set; } + + + [DataMember(Name="summary_prompt")] + [ContainsPlaceholder("content")] + public string? SummaryPrompt { get; set; } + + + [DataMember(Name="search_prompt")] + [ContainsPlaceholder("topic")] + public string? SearchPrompt { get; set; } + + + [DataMember(Name="ai_api_key")] + public string? AiApiKey { get; set; } + + + [DataMember(Name="ai_base_url")] + public string? AiBaseUrl { get; set; } + + + [DataMember(Name="ai_model_name")] + public string? AiModelName { get; set; } + + public required string PartitionKey { get ; set; } + public required string RowKey { get ; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } +}