Build flashsharp flashcard app
This commit is contained in:
commit
c97759b04c
69 files changed
+6959
No files matched your search
+488
@@ -0,0 +1,488 @@
|
|||||||
|
## 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
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.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/
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/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/main/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
|
||||||
|
|
||||||
|
# local runtime data
|
||||||
|
/data/
|
||||||
|
.flashsharp-data/
|
||||||
|
.env
|
||||||
|
/result
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Flashsharp/Flashsharp.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Flashsharp.Tests/Flashsharp.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791914995,
|
||||||
|
"narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1792502884,
|
||||||
|
"narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
{
|
||||||
|
description = "flashsharp — self-hosted flashcards with a remote MCP surface";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
(flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
flashsharp = pkgs.buildDotnetModule {
|
||||||
|
pname = "flashsharp";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
projectFile = "src/Flashsharp/Flashsharp.csproj";
|
||||||
|
nugetDeps = ./nuget-deps.json;
|
||||||
|
dotnet-sdk = pkgs.dotnet-sdk_10;
|
||||||
|
dotnet-runtime = pkgs.dotnetCorePackages.aspnetcore_10_0;
|
||||||
|
selfContainedBuild = false;
|
||||||
|
executables = [ "flashsharp" ];
|
||||||
|
meta.mainProgram = "flashsharp";
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.default = flashsharp;
|
||||||
|
packages.flashsharp = flashsharp;
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
dotnet-sdk_10
|
||||||
|
postgresql
|
||||||
|
curl
|
||||||
|
jq
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)) // {
|
||||||
|
nixosModules.default = { config, pkgs, lib, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.flashsharp;
|
||||||
|
package = cfg.package;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.flashsharp = {
|
||||||
|
enable = lib.mkEnableOption "flashsharp";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
description = "flashsharp package to run.";
|
||||||
|
};
|
||||||
|
|
||||||
|
host = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address Kestrel binds to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 7015;
|
||||||
|
description = "HTTP port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
databaseUrl = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "Host=/run/postgresql;Database=flashsharp;Username=flashsharp";
|
||||||
|
description = ''
|
||||||
|
Npgsql connection string. The database and role must already exist;
|
||||||
|
add them to services.postgresql.ensureDatabases / ensureUsers.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
seedUsername = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "admin";
|
||||||
|
description = "Username created on the first run when no users exist.";
|
||||||
|
};
|
||||||
|
|
||||||
|
seedPasswordHash = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Bcrypt hash of the seed password. Preferred over a plaintext file.
|
||||||
|
Generate with: htpasswd -bnBC 12 "" 'your-password' | cut -d: -f2
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
seedPasswordFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Runtime file containing the plaintext seed password. Only needed on
|
||||||
|
the first run, when the user table is empty. It is read through
|
||||||
|
systemd credentials. Do not point this at a path inside the Nix
|
||||||
|
store, because store paths are world-readable.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
environmentFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Optional extra environment file for the service.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.groups.flashsharp = { };
|
||||||
|
|
||||||
|
users.users.flashsharp = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "flashsharp";
|
||||||
|
home = "/var/lib/flashsharp";
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.flashsharp = {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" "postgresql.service" ];
|
||||||
|
requires = [ "postgresql.service" ];
|
||||||
|
|
||||||
|
environment =
|
||||||
|
{
|
||||||
|
FLASHSHARP_DB = cfg.databaseUrl;
|
||||||
|
FLASHSHARP_HOST = cfg.host;
|
||||||
|
FLASHSHARP_PORT = toString cfg.port;
|
||||||
|
FLASHSHARP_SEED_USERNAME = cfg.seedUsername;
|
||||||
|
FLASHSHARP_DATA_DIR = "/var/lib/flashsharp";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.seedPasswordHash != null) {
|
||||||
|
FLASHSHARP_SEED_PASSWORD_HASH = cfg.seedPasswordHash;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.seedPasswordFile != null) {
|
||||||
|
FLASHSHARP_SEED_PASSWORD_FILE = "%d/seed-password";
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
User = "flashsharp";
|
||||||
|
Group = "flashsharp";
|
||||||
|
StateDirectory = "flashsharp";
|
||||||
|
StateDirectoryMode = "0700";
|
||||||
|
UMask = "0077";
|
||||||
|
WorkingDirectory = "/var/lib/flashsharp";
|
||||||
|
ExecStart = "${package}/bin/flashsharp";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 2;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.seedPasswordFile != null) {
|
||||||
|
LoadCredential = "seed-password:${cfg.seedPasswordFile}";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.environmentFile != null) {
|
||||||
|
EnvironmentFile = cfg.environmentFile;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.401",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"pname": "AngleSharp",
|
||||||
|
"version": "1.5.0",
|
||||||
|
"hash": "sha256-290+oGXJRDf3q06rRrcQmg3XbKYhgIa6xjn5vUJWRJ4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "AngleSharp",
|
||||||
|
"version": "1.7.2",
|
||||||
|
"hash": "sha256-1X13CsRhBZUnovpom/1x2uNYJwoNLQO7gLs/L3x1szw="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "AngleSharp.Css",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"hash": "sha256-vfAKu0IE5hx5QJXMSB3lK2ds6poeDsse4rOOS2yAXmI="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "BCrypt.Net-Next",
|
||||||
|
"version": "4.2.0",
|
||||||
|
"hash": "sha256-dqDg0iQQUjncCN+bIASlsrxt4oEfyg/JkPu34wSRpc4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "Dapper",
|
||||||
|
"version": "2.1.89",
|
||||||
|
"hash": "sha256-NJlK2Rg5N0+KH3mKTHngFsylHqJd42csa5HtEXZjI+8="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "HtmlSanitizer",
|
||||||
|
"version": "9.2.1039",
|
||||||
|
"hash": "sha256-QetNfjswzKEpL5gFco7k1C6eVztyhnahkn8IM8cmzrw="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "Markdig",
|
||||||
|
"version": "1.4.2",
|
||||||
|
"hash": "sha256-SxNzvROEvNgkA4cFW/GcJpQ9hZ3ev+Ci19Z+jMtOshs="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "Microsoft.Extensions.AI.Abstractions",
|
||||||
|
"version": "10.11.0",
|
||||||
|
"hash": "sha256-h+E2f4nYpJV/8oHfCXA8hgqTeGgwkw+yuh9dCvOtIkU="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "ModelContextProtocol",
|
||||||
|
"version": "2.3.0",
|
||||||
|
"hash": "sha256-QzQlT6jC9A494E2kzE7zbiLAHgdV+eEIr/caP/SJmpU="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "ModelContextProtocol.AspNetCore",
|
||||||
|
"version": "2.3.0",
|
||||||
|
"hash": "sha256-uD0DUpZvGAa5utUgjj473fUb+XoSI6vsce7REasgd/8="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "ModelContextProtocol.Core",
|
||||||
|
"version": "2.3.0",
|
||||||
|
"hash": "sha256-2lgZzQXBkHx9a37shyI7dEsKxHpuMcqdLE6YUv5YyBU="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "Npgsql",
|
||||||
|
"version": "10.0.3",
|
||||||
|
"hash": "sha256-ddCXCSOoyfy7035OvnL+4LEDYqHjZyPoZ3ffG2coMW0="
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Flashsharp.Auth;
|
||||||
|
|
||||||
|
public static class AuthClaims
|
||||||
|
{
|
||||||
|
public const string TokenId = "flashsharp:token_id";
|
||||||
|
|
||||||
|
public static long GetUserId(this ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
var value = principal.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? throw new InvalidOperationException("Missing user id claim.");
|
||||||
|
return long.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long? GetTokenId(this ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
var value = principal.FindFirstValue(TokenId);
|
||||||
|
return value is null ? null : long.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Flashsharp.Auth;
|
||||||
|
|
||||||
|
public static class SessionCookie
|
||||||
|
{
|
||||||
|
public static void Set(HttpResponse response, string token, bool secure, int days)
|
||||||
|
{
|
||||||
|
response.Cookies.Append(
|
||||||
|
TokenAuthenticationHandler.CookieName,
|
||||||
|
token,
|
||||||
|
Options(secure, DateTimeOffset.UtcNow.AddDays(days)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Clear(HttpResponse response, bool secure)
|
||||||
|
{
|
||||||
|
response.Cookies.Delete(TokenAuthenticationHandler.CookieName, Options(secure, expires: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
static CookieOptions Options(bool secure, DateTimeOffset? expires) => new()
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = secure,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Path = "/",
|
||||||
|
Expires = expires
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Flashsharp.Auth;
|
||||||
|
|
||||||
|
public sealed class TokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public const string SchemeName = "FlashsharpToken";
|
||||||
|
public const string CookieName = "flashsharp_session";
|
||||||
|
|
||||||
|
readonly Store store;
|
||||||
|
|
||||||
|
public TokenAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
Store store)
|
||||||
|
: base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var rawToken = ReadBearerToken() ?? Request.Cookies[CookieName];
|
||||||
|
if (string.IsNullOrWhiteSpace(rawToken))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await store.AuthenticateTokenAsync(rawToken, Context.RequestAborted);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid or expired token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (user, token) = result.Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await store.TouchTokenAsync(token.Id, Context.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(exception, "Could not update token usage");
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
[
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
|
new Claim(AuthClaims.TokenId, token.Id.ToString(CultureInfo.InvariantCulture))
|
||||||
|
],
|
||||||
|
SchemeName);
|
||||||
|
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
if (Context.Request.Path.StartsWithSegments("/mcp"))
|
||||||
|
{
|
||||||
|
return base.HandleChallengeAsync(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
var returnUrl = Context.Request.Path + Context.Request.QueryString;
|
||||||
|
Context.Response.Redirect("/Login?returnUrl=" + Uri.EscapeDataString(returnUrl));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? ReadBearerToken()
|
||||||
|
{
|
||||||
|
var header = Request.Headers.Authorization.ToString();
|
||||||
|
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? header["Bearer ".Length..].Trim()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace Flashsharp.Configuration;
|
||||||
|
|
||||||
|
public sealed record AppOptions(
|
||||||
|
string DatabaseUrl,
|
||||||
|
string Host,
|
||||||
|
int Port,
|
||||||
|
string? SeedUsername,
|
||||||
|
string? SeedPassword,
|
||||||
|
string? SeedPasswordHash,
|
||||||
|
int SessionDays,
|
||||||
|
string DataDirectory)
|
||||||
|
{
|
||||||
|
public static AppOptions FromEnvironment()
|
||||||
|
{
|
||||||
|
var databaseUrl = Env("FLASHSHARP_DB") ?? Env("DATABASE_URL")
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"FLASHSHARP_DB is required (for example: Host=localhost;Database=flashsharp;Username=flashsharp).");
|
||||||
|
|
||||||
|
var host = Env("FLASHSHARP_HOST") ?? "127.0.0.1";
|
||||||
|
var portText = Env("FLASHSHARP_PORT") ?? Env("PORT") ?? "8080";
|
||||||
|
if (!int.TryParse(portText, out var port) || port is < 1 or > 65535)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Invalid port: {portText}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionDaysText = Env("FLASHSHARP_SESSION_DAYS") ?? "30";
|
||||||
|
if (!int.TryParse(sessionDaysText, out var sessionDays) || sessionDays < 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Invalid FLASHSHARP_SESSION_DAYS: {sessionDaysText}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AppOptions(
|
||||||
|
databaseUrl,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
Env("FLASHSHARP_SEED_USERNAME"),
|
||||||
|
ReadPassword(),
|
||||||
|
ReadHash(),
|
||||||
|
sessionDays,
|
||||||
|
Env("FLASHSHARP_DATA_DIR") ?? ".flashsharp-data");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? ReadHash()
|
||||||
|
{
|
||||||
|
var direct = Env("FLASHSHARP_SEED_PASSWORD_HASH");
|
||||||
|
if (!string.IsNullOrEmpty(direct))
|
||||||
|
{
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Env("FLASHSHARP_SEED_PASSWORD_HASH_FILE");
|
||||||
|
return string.IsNullOrEmpty(path) ? null : File.ReadAllText(path).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? ReadPassword()
|
||||||
|
{
|
||||||
|
var direct = Env("FLASHSHARP_SEED_PASSWORD");
|
||||||
|
if (!string.IsNullOrEmpty(direct))
|
||||||
|
{
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Env("FLASHSHARP_SEED_PASSWORD_FILE");
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return File.ReadAllText(path).TrimEnd('\r', '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? Env(string name)
|
||||||
|
{
|
||||||
|
var value = Environment.GetEnvironmentVariable(name);
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Flashsharp.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public sealed class Db
|
||||||
|
{
|
||||||
|
public Db(NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
DataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NpgsqlDataSource DataSource { get; }
|
||||||
|
|
||||||
|
public ValueTask<NpgsqlConnection> OpenAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> DataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
public static NpgsqlDataSource Build(string connectionString)
|
||||||
|
{
|
||||||
|
var builder = new NpgsqlDataSourceBuilder(connectionString);
|
||||||
|
builder.MapEnum<SideKind>("card_side_kind");
|
||||||
|
builder.MapEnum<ReviewRating>("review_rating");
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
create extension if not exists pg_trgm;
|
||||||
|
|
||||||
|
create type card_side_kind as enum ('front', 'back', 'extra');
|
||||||
|
create type review_rating as enum ('again', 'hard', 'good', 'easy');
|
||||||
|
|
||||||
|
create table app_user (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
username text not null check (username = btrim(username)),
|
||||||
|
password_hash text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index app_user_username_key on app_user(lower(username));
|
||||||
|
|
||||||
|
create table auth_token (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
user_id bigint not null references app_user(id) on delete cascade,
|
||||||
|
label text not null check (length(label) > 0 and label = btrim(label)),
|
||||||
|
kind text not null check (kind in ('browser', 'api')),
|
||||||
|
token_hash bytea not null unique,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
last_used_at timestamptz,
|
||||||
|
expires_at timestamptz,
|
||||||
|
revoked_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create index auth_token_user_idx on auth_token(user_id, kind) where revoked_at is null;
|
||||||
|
|
||||||
|
create table course (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
name text not null check (length(name) > 0 and name = btrim(name)),
|
||||||
|
description text not null default '',
|
||||||
|
position integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index course_name_key on course(lower(name));
|
||||||
|
|
||||||
|
create table lesson (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
course_id bigint not null references course(id) on delete cascade,
|
||||||
|
name text not null check (length(name) > 0 and name = btrim(name)),
|
||||||
|
description text not null default '',
|
||||||
|
position integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index lesson_course_idx on lesson(course_id, position, id);
|
||||||
|
create unique index lesson_name_key on lesson(course_id, lower(name));
|
||||||
|
|
||||||
|
create table deck (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
lesson_id bigint not null references lesson(id) on delete cascade,
|
||||||
|
name text not null check (length(name) > 0 and name = btrim(name)),
|
||||||
|
description text not null default '',
|
||||||
|
position integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index deck_lesson_idx on deck(lesson_id, position, id);
|
||||||
|
create unique index deck_name_key on deck(lesson_id, lower(name));
|
||||||
|
|
||||||
|
create table card (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
created_by bigint references app_user(id) on delete set null,
|
||||||
|
search_text text not null default '',
|
||||||
|
search_vector tsvector generated always as (to_tsvector('simple', search_text)) stored,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create index card_search_vector_idx on card using gin(search_vector);
|
||||||
|
create index card_search_trgm_idx on card using gin(search_text gin_trgm_ops);
|
||||||
|
create index card_active_idx on card(id) where deleted_at is null;
|
||||||
|
|
||||||
|
create table card_side (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
card_id bigint not null references card(id) on delete cascade,
|
||||||
|
kind card_side_kind not null,
|
||||||
|
label text not null default '' check (label = btrim(label)),
|
||||||
|
body text not null check (length(trim(body)) > 0),
|
||||||
|
position integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index card_side_one_front on card_side(card_id) where kind = 'front';
|
||||||
|
create unique index card_side_one_back on card_side(card_id) where kind = 'back';
|
||||||
|
create index card_side_card_idx on card_side(card_id, kind, position, id);
|
||||||
|
|
||||||
|
create table card_deck (
|
||||||
|
card_id bigint not null references card(id) on delete cascade,
|
||||||
|
deck_id bigint not null references deck(id) on delete cascade,
|
||||||
|
added_at timestamptz not null default now(),
|
||||||
|
primary key (card_id, deck_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index card_deck_deck_idx on card_deck(deck_id, card_id);
|
||||||
|
|
||||||
|
create table tag (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
name text not null check (name = btrim(name)),
|
||||||
|
description text not null default '',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index tag_name_key on tag(lower(name));
|
||||||
|
|
||||||
|
create table card_tag (
|
||||||
|
card_id bigint not null references card(id) on delete cascade,
|
||||||
|
tag_id bigint not null references tag(id) on delete cascade,
|
||||||
|
primary key (card_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index card_tag_tag_idx on card_tag(tag_id, card_id);
|
||||||
|
|
||||||
|
create table review (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
user_id bigint not null references app_user(id) on delete cascade,
|
||||||
|
card_id bigint not null references card(id),
|
||||||
|
deck_id bigint references deck(id) on delete set null,
|
||||||
|
rating review_rating not null,
|
||||||
|
duration_ms integer not null default 0 check (duration_ms >= 0),
|
||||||
|
reviewed_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index review_user_reviewed_idx on review(user_id, reviewed_at desc);
|
||||||
|
create index review_card_reviewed_idx on review(card_id, reviewed_at desc);
|
||||||
|
create index review_deck_idx on review(deck_id) where deck_id is not null;
|
||||||
|
create index review_reviewed_brin on review using brin(reviewed_at);
|
||||||
|
|
||||||
|
create table card_schedule (
|
||||||
|
user_id bigint not null references app_user(id) on delete cascade,
|
||||||
|
card_id bigint not null references card(id) on delete cascade,
|
||||||
|
due_at timestamptz not null,
|
||||||
|
interval_seconds integer not null default 0 check (interval_seconds >= 0),
|
||||||
|
ease double precision not null default 2.5 check (ease >= 1.3),
|
||||||
|
repetitions integer not null default 0 check (repetitions >= 0),
|
||||||
|
lapses integer not null default 0 check (lapses >= 0),
|
||||||
|
last_review_id bigint references review(id) on delete set null,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (user_id, card_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index card_schedule_due_idx on card_schedule(user_id, due_at);
|
||||||
|
|
||||||
|
create function touch_updated_at() returns trigger
|
||||||
|
language plpgsql as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger course_touch_updated_at before update on course
|
||||||
|
for each row execute function touch_updated_at();
|
||||||
|
|
||||||
|
create trigger lesson_touch_updated_at before update on lesson
|
||||||
|
for each row execute function touch_updated_at();
|
||||||
|
|
||||||
|
create trigger deck_touch_updated_at before update on deck
|
||||||
|
for each row execute function touch_updated_at();
|
||||||
|
|
||||||
|
create trigger card_touch_updated_at before update on card
|
||||||
|
for each row execute function touch_updated_at();
|
||||||
|
|
||||||
|
create trigger card_side_touch_updated_at before update on card_side
|
||||||
|
for each row execute function touch_updated_at();
|
||||||
|
|
||||||
|
create function refresh_card_search_text() returns trigger
|
||||||
|
language plpgsql as $$
|
||||||
|
declare
|
||||||
|
target_card bigint;
|
||||||
|
begin
|
||||||
|
target_card := coalesce(new.card_id, old.card_id);
|
||||||
|
update card
|
||||||
|
set search_text = coalesce((
|
||||||
|
select string_agg(side.body, E'\n\n' order by side.position, side.id)
|
||||||
|
from card_side side
|
||||||
|
where side.card_id = target_card
|
||||||
|
), '')
|
||||||
|
where id = target_card;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger card_side_refresh_search
|
||||||
|
after insert or update or delete on card_side
|
||||||
|
for each row execute function refresh_card_search_text();
|
||||||
|
|
||||||
|
create function ensure_card_has_front() returns trigger
|
||||||
|
language plpgsql as $$
|
||||||
|
declare
|
||||||
|
target_card bigint;
|
||||||
|
begin
|
||||||
|
target_card := coalesce(new.card_id, old.card_id);
|
||||||
|
if not exists (select 1 from card where id = target_card) then
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from card_side where card_id = target_card and kind = 'front'
|
||||||
|
) then
|
||||||
|
raise exception 'card % must have a front side', target_card;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create constraint trigger card_requires_front
|
||||||
|
after insert or update or delete on card_side
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function ensure_card_has_front();
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Dapper;
|
||||||
|
|
||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public static class Migrator
|
||||||
|
{
|
||||||
|
const string LockName = "flashsharp:migrations";
|
||||||
|
|
||||||
|
public static async Task RunAsync(Db db, ILogger logger, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync("select pg_advisory_lock(hashtext(@name))", new { name = LockName });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
create table if not exists schema_migration (
|
||||||
|
version text primary key,
|
||||||
|
applied_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
|
||||||
|
var applied = (await connection.QueryAsync<string>("select version from schema_migration"))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var migration in LoadMigrations())
|
||||||
|
{
|
||||||
|
if (applied.Contains(migration.Version))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Applying migration {Version}", migration.Version);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync(migration.Sql, transaction: transaction);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"insert into schema_migration (version) values (@version)",
|
||||||
|
new { migration.Version },
|
||||||
|
transaction);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync("select pg_advisory_unlock(hashtext(@name))", new { name = LockName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static IEnumerable<(string Version, string Sql)> LoadMigrations()
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var prefix = "Flashsharp.Data.Migrations.";
|
||||||
|
var names = assembly.GetManifestResourceNames()
|
||||||
|
.Where(name => name.StartsWith(prefix, StringComparison.Ordinal) && name.EndsWith(".sql", StringComparison.Ordinal))
|
||||||
|
.OrderBy(name => name, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
using var stream = assembly.GetManifestResourceStream(name)
|
||||||
|
?? throw new InvalidOperationException($"Missing migration resource {name}.");
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
yield return (name[prefix.Length..^4], reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public sealed partial class Store
|
||||||
|
{
|
||||||
|
const int PasswordWorkFactor = 12;
|
||||||
|
const string TokenPrefix = "fsh_";
|
||||||
|
|
||||||
|
public async Task<int> CountUsersAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.ExecuteScalarAsync<int>("select count(*) from app_user");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SeedAsync(
|
||||||
|
string? username,
|
||||||
|
string? password,
|
||||||
|
string? passwordHash,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (await CountUsersAsync(cancellationToken) > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username)
|
||||||
|
|| (string.IsNullOrEmpty(password) && string.IsNullOrWhiteSpace(passwordHash)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"No users exist yet. Set FLASHSHARP_SEED_USERNAME and one of " +
|
||||||
|
"FLASHSHARP_SEED_PASSWORD, FLASHSHARP_SEED_PASSWORD_FILE, " +
|
||||||
|
"FLASHSHARP_SEED_PASSWORD_HASH or FLASHSHARP_SEED_PASSWORD_HASH_FILE " +
|
||||||
|
"to create the first user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = username.Trim();
|
||||||
|
var hash = string.IsNullOrWhiteSpace(passwordHash)
|
||||||
|
? BCrypt.Net.BCrypt.HashPassword(password!, PasswordWorkFactor)
|
||||||
|
: passwordHash.Trim();
|
||||||
|
|
||||||
|
if (!hash.StartsWith("$2", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The seed password hash must be a bcrypt hash.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await InsertUserAsync(normalized, hash, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User> CreateUserAsync(
|
||||||
|
string username,
|
||||||
|
string password,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalized = username.Trim();
|
||||||
|
if (normalized.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Username must not be empty.", nameof(username));
|
||||||
|
}
|
||||||
|
if (password.Length < 8)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Password must be at least 8 characters.", nameof(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = BCrypt.Net.BCrypt.HashPassword(password, PasswordWorkFactor);
|
||||||
|
return await InsertUserAsync(normalized, hash, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<User> InsertUserAsync(string username, string passwordHash, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var id = await connection.ExecuteScalarAsync<long>(
|
||||||
|
"""
|
||||||
|
insert into app_user (username, password_hash)
|
||||||
|
values (@username, @passwordHash)
|
||||||
|
returning id
|
||||||
|
""",
|
||||||
|
new { username, passwordHash });
|
||||||
|
return new User(id, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User?> GetUserByUsernameAsync(string username, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<User>(
|
||||||
|
"""select id as "Id", username as "Username" from app_user where lower(username) = lower(@username) and is_active""",
|
||||||
|
new { username });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User?> GetUserAsync(long id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<User>(
|
||||||
|
"""select id as "Id", username as "Username" from app_user where id = @id and is_active""",
|
||||||
|
new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<User>> ListUsersAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<User>(
|
||||||
|
"""select id as "Id", username as "Username" from app_user where is_active order by lower(username)""");
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeactivateUserAsync(long id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync("update app_user set is_active = false where id = @id", new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var hash = await connection.QuerySingleOrDefaultAsync<string>(
|
||||||
|
"""select password_hash from app_user where lower(username) = lower(@username) and is_active""",
|
||||||
|
new { username });
|
||||||
|
return hash is not null && BCrypt.Net.BCrypt.Verify(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ChangePasswordAsync(long userId, string newPassword, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Password must be at least 8 characters.", nameof(newPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = BCrypt.Net.BCrypt.HashPassword(newPassword, PasswordWorkFactor);
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"update app_user set password_hash = @hash where id = @userId",
|
||||||
|
new { hash, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(AuthToken Token, string Raw)> CreateTokenAsync(
|
||||||
|
long userId,
|
||||||
|
string label,
|
||||||
|
string kind,
|
||||||
|
TimeSpan? lifetime,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var raw = TokenPrefix + WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var hash = HashToken(raw);
|
||||||
|
var expiresAt = lifetime is null ? (DateTime?)null : DateTime.UtcNow.Add(lifetime.Value);
|
||||||
|
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var row = await connection.QuerySingleAsync<AuthToken>(
|
||||||
|
"""
|
||||||
|
insert into auth_token (user_id, label, kind, token_hash, expires_at)
|
||||||
|
values (@userId, @label, @kind, @hash, @expiresAt)
|
||||||
|
returning id as "Id", label as "Label", kind as "Kind",
|
||||||
|
created_at as "CreatedAt", last_used_at as "LastUsedAt", expires_at as "ExpiresAt"
|
||||||
|
""",
|
||||||
|
new { userId, label = label.Trim(), kind, hash, expiresAt });
|
||||||
|
return (row, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AuthToken>> ListTokensAsync(
|
||||||
|
long userId,
|
||||||
|
string kind,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<AuthToken>(
|
||||||
|
"""
|
||||||
|
select id as "Id", label as "Label", kind as "Kind",
|
||||||
|
created_at as "CreatedAt", last_used_at as "LastUsedAt", expires_at as "ExpiresAt"
|
||||||
|
from auth_token
|
||||||
|
where user_id = @userId and kind = @kind and revoked_at is null
|
||||||
|
order by created_at desc
|
||||||
|
""",
|
||||||
|
new { userId, kind });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeTokenAsync(long userId, long tokenId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var affected = await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
update auth_token set revoked_at = now()
|
||||||
|
where id = @tokenId and user_id = @userId and revoked_at is null
|
||||||
|
""",
|
||||||
|
new { tokenId, userId });
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeOtherBrowserTokensAsync(
|
||||||
|
long userId,
|
||||||
|
long? exceptTokenId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
update auth_token set revoked_at = now()
|
||||||
|
where user_id = @userId
|
||||||
|
and kind = 'browser'
|
||||||
|
and revoked_at is null
|
||||||
|
and (@exceptTokenId::bigint is null or id <> @exceptTokenId)
|
||||||
|
""",
|
||||||
|
new { userId, exceptTokenId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(User User, AuthToken Token)?> AuthenticateTokenAsync(
|
||||||
|
string rawToken,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var hash = HashToken(rawToken);
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<AuthLookup>(
|
||||||
|
"""
|
||||||
|
select t.id as "TokenId", t.label as "Label", t.kind as "Kind",
|
||||||
|
t.created_at as "TokenCreatedAt", t.last_used_at as "LastUsedAt", t.expires_at as "ExpiresAt",
|
||||||
|
u.id as "UserId", u.username as "Username"
|
||||||
|
from auth_token t
|
||||||
|
join app_user u on u.id = t.user_id
|
||||||
|
where t.token_hash = @hash
|
||||||
|
and t.revoked_at is null
|
||||||
|
and (t.expires_at is null or t.expires_at > now())
|
||||||
|
and u.is_active
|
||||||
|
""",
|
||||||
|
new { hash });
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = new AuthToken
|
||||||
|
{
|
||||||
|
Id = row.TokenId,
|
||||||
|
Label = row.Label,
|
||||||
|
Kind = row.Kind,
|
||||||
|
CreatedAt = row.TokenCreatedAt,
|
||||||
|
LastUsedAt = row.LastUsedAt,
|
||||||
|
ExpiresAt = row.ExpiresAt
|
||||||
|
};
|
||||||
|
return (new User(row.UserId, row.Username), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TouchTokenAsync(long tokenId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
update auth_token
|
||||||
|
set last_used_at = now()
|
||||||
|
where id = @tokenId
|
||||||
|
and (last_used_at is null or last_used_at < now() - interval '5 minutes')
|
||||||
|
""",
|
||||||
|
new { tokenId });
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] HashToken(string rawToken) => SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
|
||||||
|
|
||||||
|
sealed class AuthLookup
|
||||||
|
{
|
||||||
|
public long TokenId { get; init; }
|
||||||
|
public string Label { get; init; } = "";
|
||||||
|
public string Kind { get; init; } = "";
|
||||||
|
public DateTime TokenCreatedAt { get; init; }
|
||||||
|
public DateTime? LastUsedAt { get; init; }
|
||||||
|
public DateTime? ExpiresAt { get; init; }
|
||||||
|
public long UserId { get; init; }
|
||||||
|
public string Username { get; init; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public sealed partial class Store
|
||||||
|
{
|
||||||
|
public async Task<long> CreateCardAsync(
|
||||||
|
long deckId,
|
||||||
|
long userId,
|
||||||
|
CardDraft draft,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var cardId = await InsertCardAsync(connection, transaction, deckId, userId, draft, cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return cardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<long>> CreateCardsAsync(
|
||||||
|
long deckId,
|
||||||
|
long userId,
|
||||||
|
IReadOnlyList<CardDraft> drafts,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (drafts.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (drafts.Count > 200)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At most 200 cards can be created in one call.", nameof(drafts));
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var ids = new List<long>(drafts.Count);
|
||||||
|
foreach (var draft in drafts)
|
||||||
|
{
|
||||||
|
ids.Add(await InsertCardAsync(connection, transaction, deckId, userId, draft, cancellationToken));
|
||||||
|
}
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<long> InsertCardAsync(
|
||||||
|
NpgsqlConnection connection,
|
||||||
|
NpgsqlTransaction transaction,
|
||||||
|
long deckId,
|
||||||
|
long userId,
|
||||||
|
CardDraft draft,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var front = RequireBody(draft.Front, "front");
|
||||||
|
var back = string.IsNullOrWhiteSpace(draft.Back) ? null : draft.Back.Trim();
|
||||||
|
var extras = (draft.Extras ?? [])
|
||||||
|
.Where(extra => !string.IsNullOrWhiteSpace(extra.Body))
|
||||||
|
.Select(extra => new ExtraSide((extra.Label ?? "").Trim(), extra.Body.Trim()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var cardId = await connection.ExecuteScalarAsync<long>(
|
||||||
|
"insert into card (created_by) values (@userId) returning id",
|
||||||
|
new { userId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await InsertSideAsync(connection, transaction, cardId, SideKind.Front, "", front, 0);
|
||||||
|
if (back is not null)
|
||||||
|
{
|
||||||
|
await InsertSideAsync(connection, transaction, cardId, SideKind.Back, "", back, 0);
|
||||||
|
}
|
||||||
|
for (var index = 0; index < extras.Count; index++)
|
||||||
|
{
|
||||||
|
await InsertSideAsync(connection, transaction, cardId, SideKind.Extra, extras[index].Label, extras[index].Body, index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"insert into card_deck (card_id, deck_id) values (@cardId, @deckId)",
|
||||||
|
new { cardId, deckId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await SetTagsAsync(connection, transaction, cardId, draft.Tags);
|
||||||
|
return cardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Task InsertSideAsync(
|
||||||
|
NpgsqlConnection connection,
|
||||||
|
NpgsqlTransaction transaction,
|
||||||
|
long cardId,
|
||||||
|
SideKind kind,
|
||||||
|
string label,
|
||||||
|
string body,
|
||||||
|
int position)
|
||||||
|
=> connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
insert into card_side (card_id, kind, label, body, position)
|
||||||
|
values (@cardId, @kind::card_side_kind, @label, @body, @position)
|
||||||
|
""",
|
||||||
|
new { cardId, kind = kind.ToString().ToLowerInvariant(), label, body, position },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
public async Task<Card?> GetCardAsync(long cardId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var header = await connection.QuerySingleOrDefaultAsync<CardHeader>(
|
||||||
|
"""select id as "Id", created_at as "CreatedAt" from card where id = @cardId and deleted_at is null""",
|
||||||
|
new { cardId });
|
||||||
|
if (header is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sides = await connection.QueryAsync<Side>(
|
||||||
|
"""
|
||||||
|
select id as "Id", kind as "Kind", label as "Label", body as "Body", position as "Position"
|
||||||
|
from card_side
|
||||||
|
where card_id = @cardId
|
||||||
|
order by case kind when 'front' then 0 when 'back' then 1 else 2 end, position, id
|
||||||
|
""",
|
||||||
|
new { cardId });
|
||||||
|
return new Card(header.Id, header.CreatedAt, sides.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CardExistsAsync(long cardId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.ExecuteScalarAsync<bool>(
|
||||||
|
"select exists(select 1 from card where id = @cardId and deleted_at is null)",
|
||||||
|
new { cardId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CardRef?> GetCardRefAsync(long cardId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var refs = await LoadCardRefsAsync([cardId], cancellationToken);
|
||||||
|
return refs.GetValueOrDefault(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CardRef>> ListDeckCardsAsync(
|
||||||
|
long deckId,
|
||||||
|
int limit,
|
||||||
|
int offset,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var ids = (await connection.QueryAsync<long>(
|
||||||
|
"""
|
||||||
|
select c.id
|
||||||
|
from card_deck cd
|
||||||
|
join card c on c.id = cd.card_id
|
||||||
|
where cd.deck_id = @deckId and c.deleted_at is null
|
||||||
|
order by c.created_at desc, c.id desc
|
||||||
|
limit @limit offset @offset
|
||||||
|
""",
|
||||||
|
new { deckId, limit, offset })).ToList();
|
||||||
|
|
||||||
|
var refs = await LoadCardRefsAsync(ids, cancellationToken);
|
||||||
|
return ids.Where(refs.ContainsKey).Select(id => refs[id]).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CardRef>> SearchCardsAsync(
|
||||||
|
string? query,
|
||||||
|
long? courseId,
|
||||||
|
long? lessonId,
|
||||||
|
long? deckId,
|
||||||
|
string? tag,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var q = (query ?? "").Trim();
|
||||||
|
var tagFilter = (tag ?? "").Trim();
|
||||||
|
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var ids = (await connection.QueryAsync<long>(
|
||||||
|
"""
|
||||||
|
select c.id
|
||||||
|
from card c
|
||||||
|
where c.deleted_at is null
|
||||||
|
and (@q = ''
|
||||||
|
or c.search_vector @@ websearch_to_tsquery('simple', @q)
|
||||||
|
or c.search_text ilike '%' || @q || '%')
|
||||||
|
and (@tag = ''
|
||||||
|
or exists (
|
||||||
|
select 1 from card_tag ct
|
||||||
|
join tag t on t.id = ct.tag_id
|
||||||
|
where ct.card_id = c.id and lower(t.name) = lower(@tag)))
|
||||||
|
and (@courseId::bigint is null
|
||||||
|
or exists (
|
||||||
|
select 1 from card_deck cd
|
||||||
|
join deck d on d.id = cd.deck_id
|
||||||
|
join lesson l on l.id = d.lesson_id
|
||||||
|
where cd.card_id = c.id and l.course_id = @courseId))
|
||||||
|
and (@lessonId::bigint is null
|
||||||
|
or exists (
|
||||||
|
select 1 from card_deck cd
|
||||||
|
join deck d on d.id = cd.deck_id
|
||||||
|
where cd.card_id = c.id and d.lesson_id = @lessonId))
|
||||||
|
and (@deckId::bigint is null
|
||||||
|
or exists (
|
||||||
|
select 1 from card_deck cd
|
||||||
|
where cd.card_id = c.id and cd.deck_id = @deckId))
|
||||||
|
order by
|
||||||
|
case when @q = '' then 0 else
|
||||||
|
ts_rank(c.search_vector, websearch_to_tsquery('simple', @q)) + similarity(c.search_text, @q)
|
||||||
|
end desc,
|
||||||
|
c.created_at desc,
|
||||||
|
c.id desc
|
||||||
|
limit @limit
|
||||||
|
""",
|
||||||
|
new { q, tag = tagFilter, courseId, lessonId, deckId, limit })).ToList();
|
||||||
|
|
||||||
|
var refs = await LoadCardRefsAsync(ids, cancellationToken);
|
||||||
|
return ids.Where(refs.ContainsKey).Select(id => refs[id]).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TagInfo>> ListTagsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<TagInfo>(
|
||||||
|
"""
|
||||||
|
select t.name as "Name",
|
||||||
|
t.description as "Description",
|
||||||
|
count(c.id)::int as "CardCount"
|
||||||
|
from tag t
|
||||||
|
left join card_tag ct on ct.tag_id = t.id
|
||||||
|
left join card c on c.id = ct.card_id and c.deleted_at is null
|
||||||
|
group by t.id
|
||||||
|
order by lower(t.name)
|
||||||
|
""");
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateTagAsync(
|
||||||
|
string name,
|
||||||
|
string description = "",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var clean = (name ?? "").Trim();
|
||||||
|
if (clean.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Tag name must not be empty.", nameof(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleAsync<string>(
|
||||||
|
"""
|
||||||
|
insert into tag (name, description)
|
||||||
|
values (@clean, @description)
|
||||||
|
on conflict (lower(name)) do update
|
||||||
|
set description = case when excluded.description = '' then tag.description else excluded.description end
|
||||||
|
returning name
|
||||||
|
""",
|
||||||
|
new { clean, description = description ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateCardAsync(
|
||||||
|
long cardId,
|
||||||
|
CardDraft draft,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var exists = await connection.ExecuteScalarAsync<bool>(
|
||||||
|
"""select exists(select 1 from card where id = @cardId and deleted_at is null)""",
|
||||||
|
new { cardId },
|
||||||
|
transaction);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Card {cardId} does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync("delete from card_side where card_id = @cardId", new { cardId }, transaction);
|
||||||
|
await connection.ExecuteAsync("delete from card_tag where card_id = @cardId", new { cardId }, transaction);
|
||||||
|
|
||||||
|
var front = RequireBody(draft.Front, "front");
|
||||||
|
var back = string.IsNullOrWhiteSpace(draft.Back) ? null : draft.Back.Trim();
|
||||||
|
var extras = (draft.Extras ?? [])
|
||||||
|
.Where(extra => !string.IsNullOrWhiteSpace(extra.Body))
|
||||||
|
.Select(extra => new ExtraSide((extra.Label ?? "").Trim(), extra.Body.Trim()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await InsertSideAsync(connection, transaction, cardId, SideKind.Front, "", front, 0);
|
||||||
|
if (back is not null)
|
||||||
|
{
|
||||||
|
await InsertSideAsync(connection, transaction, cardId, SideKind.Back, "", back, 0);
|
||||||
|
}
|
||||||
|
for (var index = 0; index < extras.Count; index++)
|
||||||
|
{
|
||||||
|
await InsertSideAsync(connection, transaction, cardId, SideKind.Extra, extras[index].Label, extras[index].Body, index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SetTagsAsync(connection, transaction, cardId, draft.Tags);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteCardAsync(long cardId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"update card set deleted_at = now() where id = @cardId and deleted_at is null",
|
||||||
|
new { cardId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddCardToDeckAsync(long cardId, long deckId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
insert into card_deck (card_id, deck_id)
|
||||||
|
select c.id, d.id from card c, deck d
|
||||||
|
where c.id = @cardId and d.id = @deckId and c.deleted_at is null
|
||||||
|
on conflict (card_id, deck_id) do nothing
|
||||||
|
""",
|
||||||
|
new { cardId, deckId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveCardFromDeckAsync(long cardId, long deckId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"delete from card_deck where card_id = @cardId and deck_id = @deckId",
|
||||||
|
new { cardId, deckId });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task SetTagsAsync(
|
||||||
|
NpgsqlConnection connection,
|
||||||
|
NpgsqlTransaction transaction,
|
||||||
|
long cardId,
|
||||||
|
IReadOnlyList<string>? tags)
|
||||||
|
{
|
||||||
|
var clean = (tags ?? [])
|
||||||
|
.Select(tag => (tag ?? "").Trim())
|
||||||
|
.Where(tag => tag.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var tag in clean)
|
||||||
|
{
|
||||||
|
var tagId = await connection.ExecuteScalarAsync<long>(
|
||||||
|
"""
|
||||||
|
insert into tag (name) values (@tag)
|
||||||
|
on conflict (lower(name)) do update set name = excluded.name
|
||||||
|
returning id
|
||||||
|
""",
|
||||||
|
new { tag },
|
||||||
|
transaction);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"insert into card_tag (card_id, tag_id) values (@cardId, @tagId) on conflict do nothing",
|
||||||
|
new { cardId, tagId },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<IReadOnlyDictionary<long, CardRef>> LoadCardRefsAsync(
|
||||||
|
IReadOnlyList<long> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<long, CardRef>();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sides = (await connection.QueryAsync<SideRow>(
|
||||||
|
"""
|
||||||
|
select card_id as "CardId", kind as "Kind", label as "Label", body as "Body", position as "Position"
|
||||||
|
from card_side
|
||||||
|
where card_id = any(@ids)
|
||||||
|
order by case kind when 'front' then 0 when 'back' then 1 else 2 end, position, id
|
||||||
|
""",
|
||||||
|
new { ids })).ToList();
|
||||||
|
|
||||||
|
var tags = (await connection.QueryAsync<TagRow>(
|
||||||
|
"""
|
||||||
|
select ct.card_id as "CardId", t.name as "Name"
|
||||||
|
from card_tag ct
|
||||||
|
join tag t on t.id = ct.tag_id
|
||||||
|
where ct.card_id = any(@ids)
|
||||||
|
order by lower(t.name)
|
||||||
|
""",
|
||||||
|
new { ids })).ToList();
|
||||||
|
|
||||||
|
var decks = (await connection.QueryAsync<CardDeckRow>(
|
||||||
|
"""
|
||||||
|
select cd.card_id as "CardId",
|
||||||
|
d.id as "Id", d.name as "Name",
|
||||||
|
l.id as "LessonId", l.name as "LessonName",
|
||||||
|
co.id as "CourseId", co.name as "CourseName"
|
||||||
|
from card_deck cd
|
||||||
|
join deck d on d.id = cd.deck_id
|
||||||
|
join lesson l on l.id = d.lesson_id
|
||||||
|
join course co on co.id = l.course_id
|
||||||
|
where cd.card_id = any(@ids)
|
||||||
|
order by lower(co.name), lower(l.name), lower(d.name)
|
||||||
|
""",
|
||||||
|
new { ids })).ToList();
|
||||||
|
|
||||||
|
var sidesByCard = sides.GroupBy(row => row.CardId).ToDictionary(group => group.Key, group => group.ToList());
|
||||||
|
var tagsByCard = tags.GroupBy(row => row.CardId).ToDictionary(group => group.Key, group => group.Select(row => row.Name).ToList());
|
||||||
|
var decksByCard = decks.GroupBy(row => row.CardId).ToDictionary(group => group.Key, group => group
|
||||||
|
.Select(row => new DeckRef(row.Id, row.Name, row.LessonId, row.LessonName, row.CourseId, row.CourseName))
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
var result = new Dictionary<long, CardRef>(ids.Count);
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
if (!sidesByCard.TryGetValue(id, out var cardSides))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var front = cardSides.FirstOrDefault(side => side.Kind == SideKind.Front)?.Body ?? "";
|
||||||
|
var back = cardSides.FirstOrDefault(side => side.Kind == SideKind.Back)?.Body;
|
||||||
|
result[id] = new CardRef(
|
||||||
|
id,
|
||||||
|
front,
|
||||||
|
back,
|
||||||
|
tagsByCard.GetValueOrDefault(id, []),
|
||||||
|
decksByCard.GetValueOrDefault(id, []));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string RequireBody(string body, string label)
|
||||||
|
{
|
||||||
|
var clean = (body ?? "").Trim();
|
||||||
|
if (clean.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"The {label} side must not be empty.");
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class CardHeader
|
||||||
|
{
|
||||||
|
public long Id { get; init; }
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SideRow
|
||||||
|
{
|
||||||
|
public long CardId { get; init; }
|
||||||
|
public SideKind Kind { get; init; }
|
||||||
|
public string Label { get; init; } = "";
|
||||||
|
public string Body { get; init; } = "";
|
||||||
|
public int Position { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TagRow
|
||||||
|
{
|
||||||
|
public long CardId { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class CardDeckRow
|
||||||
|
{
|
||||||
|
public long CardId { get; init; }
|
||||||
|
public long Id { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public long LessonId { get; init; }
|
||||||
|
public string LessonName { get; init; } = "";
|
||||||
|
public long CourseId { get; init; }
|
||||||
|
public string CourseName { get; init; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
|
||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public sealed partial class Store
|
||||||
|
{
|
||||||
|
const string CourseSummarySql =
|
||||||
|
"""
|
||||||
|
select co.id as "Id",
|
||||||
|
co.name as "Name",
|
||||||
|
co.description as "Description",
|
||||||
|
co.position as "Position",
|
||||||
|
(select count(*) from lesson l where l.course_id = co.id)::int as "LessonCount",
|
||||||
|
(select count(*) from deck d join lesson l on l.id = d.lesson_id where l.course_id = co.id)::int as "DeckCount",
|
||||||
|
(select count(distinct c.id)
|
||||||
|
from card_deck cd
|
||||||
|
join deck d on d.id = cd.deck_id
|
||||||
|
join lesson l on l.id = d.lesson_id
|
||||||
|
join card c on c.id = cd.card_id
|
||||||
|
where l.course_id = co.id and c.deleted_at is null)::int as "CardCount",
|
||||||
|
(select count(distinct c.id)
|
||||||
|
from card_deck cd
|
||||||
|
join deck d on d.id = cd.deck_id
|
||||||
|
join lesson l on l.id = d.lesson_id
|
||||||
|
join card c on c.id = cd.card_id
|
||||||
|
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
|
||||||
|
where l.course_id = co.id
|
||||||
|
and c.deleted_at is null
|
||||||
|
and (s.user_id is null or s.due_at <= now()))::int as "DueCount"
|
||||||
|
from course co
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string LessonSummarySql =
|
||||||
|
"""
|
||||||
|
select l.id as "Id",
|
||||||
|
l.course_id as "CourseId",
|
||||||
|
l.name as "Name",
|
||||||
|
l.description as "Description",
|
||||||
|
l.position as "Position",
|
||||||
|
(select count(*) from deck d where d.lesson_id = l.id)::int as "DeckCount",
|
||||||
|
(select count(distinct c.id)
|
||||||
|
from card_deck cd
|
||||||
|
join deck d on d.id = cd.deck_id
|
||||||
|
join card c on c.id = cd.card_id
|
||||||
|
where d.lesson_id = l.id and c.deleted_at is null)::int as "CardCount",
|
||||||
|
(select count(distinct c.id)
|
||||||
|
from card_deck cd
|
||||||
|
join deck d on d.id = cd.deck_id
|
||||||
|
join card c on c.id = cd.card_id
|
||||||
|
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
|
||||||
|
where d.lesson_id = l.id
|
||||||
|
and c.deleted_at is null
|
||||||
|
and (s.user_id is null or s.due_at <= now()))::int as "DueCount"
|
||||||
|
from lesson l
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string DeckSummarySql =
|
||||||
|
"""
|
||||||
|
select d.id as "Id",
|
||||||
|
d.lesson_id as "LessonId",
|
||||||
|
l.course_id as "CourseId",
|
||||||
|
l.name as "LessonName",
|
||||||
|
co.name as "CourseName",
|
||||||
|
d.name as "Name",
|
||||||
|
d.description as "Description",
|
||||||
|
d.position as "Position",
|
||||||
|
(select count(*) from card_deck cd join card c on c.id = cd.card_id
|
||||||
|
where cd.deck_id = d.id and c.deleted_at is null)::int as "CardCount",
|
||||||
|
(select count(*)
|
||||||
|
from card_deck cd
|
||||||
|
join card c on c.id = cd.card_id
|
||||||
|
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
|
||||||
|
where cd.deck_id = d.id
|
||||||
|
and c.deleted_at is null
|
||||||
|
and (s.user_id is null or s.due_at <= now()))::int as "DueCount"
|
||||||
|
from deck d
|
||||||
|
join lesson l on l.id = d.lesson_id
|
||||||
|
join course co on co.id = l.course_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CourseSummary>> ListCoursesAsync(
|
||||||
|
long userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<CourseSummary>(
|
||||||
|
$"{CourseSummarySql} order by co.position, lower(co.name), co.id",
|
||||||
|
new { userId });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CourseSummary?> GetCourseAsync(
|
||||||
|
long courseId,
|
||||||
|
long userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<CourseSummary>(
|
||||||
|
$"{CourseSummarySql} where co.id = @courseId",
|
||||||
|
new { courseId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<LessonSummary>> ListLessonsAsync(
|
||||||
|
long courseId,
|
||||||
|
long userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<LessonSummary>(
|
||||||
|
$"{LessonSummarySql} where l.course_id = @courseId order by l.position, lower(l.name), l.id",
|
||||||
|
new { courseId, userId });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LessonSummary?> GetLessonAsync(
|
||||||
|
long lessonId,
|
||||||
|
long userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<LessonSummary>(
|
||||||
|
$"{LessonSummarySql} where l.id = @lessonId",
|
||||||
|
new { lessonId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DeckSummary>> ListDecksAsync(
|
||||||
|
long lessonId,
|
||||||
|
long userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<DeckSummary>(
|
||||||
|
$"{DeckSummarySql} where d.lesson_id = @lessonId order by d.position, lower(d.name), d.id",
|
||||||
|
new { lessonId, userId });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeckSummary?> GetDeckAsync(
|
||||||
|
long deckId,
|
||||||
|
long userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<DeckSummary>(
|
||||||
|
$"{DeckSummarySql} where d.id = @deckId",
|
||||||
|
new { deckId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CourseExistsAsync(long courseId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.ExecuteScalarAsync<bool>(
|
||||||
|
"select exists(select 1 from course where id = @courseId)",
|
||||||
|
new { courseId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> LessonExistsAsync(long lessonId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.ExecuteScalarAsync<bool>(
|
||||||
|
"select exists(select 1 from lesson where id = @lessonId)",
|
||||||
|
new { lessonId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeckContext?> GetDeckContextAsync(
|
||||||
|
long deckId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<DeckContext>(
|
||||||
|
"""
|
||||||
|
select d.id as "Id", d.name as "Name",
|
||||||
|
l.id as "LessonId", l.name as "LessonName",
|
||||||
|
co.id as "CourseId", co.name as "CourseName"
|
||||||
|
from deck d
|
||||||
|
join lesson l on l.id = d.lesson_id
|
||||||
|
join course co on co.id = l.course_id
|
||||||
|
where d.id = @deckId
|
||||||
|
""",
|
||||||
|
new { deckId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CourseNode>> GetTreeAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var courses = (await connection.QueryAsync<CourseRow>(
|
||||||
|
"""select id as "Id", name as "Name", description as "Description", position as "Position" from course order by position, lower(name), id"""
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
var lessons = (await connection.QueryAsync<LessonRow>(
|
||||||
|
"""select id as "Id", course_id as "CourseId", name as "Name", description as "Description", position as "Position" from lesson order by position, lower(name), id"""
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
var decks = (await connection.QueryAsync<DeckRow>(
|
||||||
|
"""
|
||||||
|
select d.id as "Id", d.lesson_id as "LessonId", d.name as "Name", d.description as "Description",
|
||||||
|
d.position as "Position",
|
||||||
|
(select count(*) from card_deck cd join card c on c.id = cd.card_id
|
||||||
|
where cd.deck_id = d.id and c.deleted_at is null)::int as "CardCount"
|
||||||
|
from deck d
|
||||||
|
order by d.position, lower(d.name), d.id
|
||||||
|
"""
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
var decksByLesson = decks
|
||||||
|
.GroupBy(deck => deck.LessonId)
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<DeckNode>)group
|
||||||
|
.Select(deck => new DeckNode(deck.Id, deck.Name, deck.Description, deck.Position, deck.CardCount))
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
var lessonsByCourse = lessons
|
||||||
|
.GroupBy(lesson => lesson.CourseId)
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<LessonNode>)group
|
||||||
|
.Select(lesson => new LessonNode(
|
||||||
|
lesson.Id,
|
||||||
|
lesson.Name,
|
||||||
|
lesson.Description,
|
||||||
|
lesson.Position,
|
||||||
|
decksByLesson.GetValueOrDefault(lesson.Id, [])))
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
return courses
|
||||||
|
.Select(course => new CourseNode(
|
||||||
|
course.Id,
|
||||||
|
course.Name,
|
||||||
|
course.Description,
|
||||||
|
course.Position,
|
||||||
|
lessonsByCourse.GetValueOrDefault(course.Id, [])))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class CourseRow
|
||||||
|
{
|
||||||
|
public long Id { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
public int Position { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EntityRef> CreateCourseAsync(
|
||||||
|
string name,
|
||||||
|
string description = "",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleAsync<EntityRef>(
|
||||||
|
"""
|
||||||
|
insert into course (name, description, position)
|
||||||
|
values (@name, @description, coalesce((select max(position) + 1 from course), 0))
|
||||||
|
on conflict (lower(name)) do update set name = excluded.name
|
||||||
|
returning id as "Id", name as "Name"
|
||||||
|
""",
|
||||||
|
new { name = CleanName(name), description = description ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EntityRef> CreateLessonAsync(
|
||||||
|
long courseId,
|
||||||
|
string name,
|
||||||
|
string description = "",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleAsync<EntityRef>(
|
||||||
|
"""
|
||||||
|
insert into lesson (course_id, name, description, position)
|
||||||
|
values (@courseId, @name, @description,
|
||||||
|
coalesce((select max(position) + 1 from lesson where course_id = @courseId), 0))
|
||||||
|
on conflict (course_id, lower(name)) do update set name = excluded.name
|
||||||
|
returning id as "Id", name as "Name"
|
||||||
|
""",
|
||||||
|
new { courseId, name = CleanName(name), description = description ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EntityRef> CreateDeckAsync(
|
||||||
|
long lessonId,
|
||||||
|
string name,
|
||||||
|
string description = "",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
return await connection.QuerySingleAsync<EntityRef>(
|
||||||
|
"""
|
||||||
|
insert into deck (lesson_id, name, description, position)
|
||||||
|
values (@lessonId, @name, @description,
|
||||||
|
coalesce((select max(position) + 1 from deck where lesson_id = @lessonId), 0))
|
||||||
|
on conflict (lesson_id, lower(name)) do update set name = excluded.name
|
||||||
|
returning id as "Id", name as "Name"
|
||||||
|
""",
|
||||||
|
new { lessonId, name = CleanName(name), description = description ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RenameCourseAsync(long courseId, string name, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync("update course set name = @name where id = @courseId", new { courseId, name = CleanName(name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RenameLessonAsync(long lessonId, string name, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync("update lesson set name = @name where id = @lessonId", new { lessonId, name = CleanName(name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RenameDeckAsync(long deckId, string name, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteAsync("update deck set name = @name where id = @deckId", new { deckId, name = CleanName(name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
static string CleanName(string name)
|
||||||
|
{
|
||||||
|
var trimmed = name.Trim();
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Name must not be empty.", nameof(name));
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class LessonRow
|
||||||
|
{
|
||||||
|
public long Id { get; init; }
|
||||||
|
public long CourseId { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
public int Position { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DeckRow
|
||||||
|
{
|
||||||
|
public long Id { get; init; }
|
||||||
|
public long LessonId { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
public int Position { get; init; }
|
||||||
|
public int CardCount { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
|
||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public sealed partial class Store
|
||||||
|
{
|
||||||
|
public async Task<StatsSummary> GetStatsSummaryAsync(long userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var row = await connection.QuerySingleAsync<SummaryRow>(
|
||||||
|
"""
|
||||||
|
select (select count(*) from review where user_id = @userId)::int as "TotalReviews",
|
||||||
|
(select count(*) from review
|
||||||
|
where user_id = @userId
|
||||||
|
and (reviewed_at at time zone 'UTC')::date = (now() at time zone 'UTC')::date)::int as "ReviewsToday",
|
||||||
|
(select count(distinct card_id) from review where user_id = @userId)::int as "CardsSeen",
|
||||||
|
(select count(*)
|
||||||
|
from card c
|
||||||
|
where c.deleted_at is null
|
||||||
|
and exists (select 1 from card_deck cd where cd.card_id = c.id)
|
||||||
|
and (not exists (select 1 from card_schedule s
|
||||||
|
where s.card_id = c.id and s.user_id = @userId)
|
||||||
|
or exists (select 1 from card_schedule s
|
||||||
|
where s.card_id = c.id and s.user_id = @userId and s.due_at <= now())))::int as "DueNow",
|
||||||
|
(select count(*) from review
|
||||||
|
where user_id = @userId and reviewed_at >= now() - interval '30 days')::int as "Reviews30",
|
||||||
|
(select count(*) from review
|
||||||
|
where user_id = @userId
|
||||||
|
and reviewed_at >= now() - interval '30 days'
|
||||||
|
and rating in ('good', 'easy'))::int as "Good30",
|
||||||
|
(select coalesce(sum(duration_ms), 0) from review where user_id = @userId)::bigint as "TimeSpentMs"
|
||||||
|
""",
|
||||||
|
new { userId });
|
||||||
|
|
||||||
|
var days = (await connection.QueryAsync<DateOnly>(
|
||||||
|
"""
|
||||||
|
select distinct (reviewed_at at time zone 'UTC')::date as day
|
||||||
|
from review
|
||||||
|
where user_id = @userId
|
||||||
|
order by day desc
|
||||||
|
""",
|
||||||
|
new { userId })).ToList();
|
||||||
|
|
||||||
|
var retention = row.Reviews30 == 0 ? 0 : (int)Math.Round(row.Good30 * 100.0 / row.Reviews30);
|
||||||
|
return new StatsSummary(
|
||||||
|
row.TotalReviews,
|
||||||
|
row.ReviewsToday,
|
||||||
|
Streak(days),
|
||||||
|
row.CardsSeen,
|
||||||
|
row.DueNow,
|
||||||
|
retention,
|
||||||
|
row.TimeSpentMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DailyStat>> GetDailyStatsAsync(
|
||||||
|
long userId,
|
||||||
|
int days,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<DailyStat>(
|
||||||
|
"""
|
||||||
|
select day::date as "Day",
|
||||||
|
count(r.id)::int as "Total",
|
||||||
|
count(r.id) filter (where r.rating = 'again')::int as "Again",
|
||||||
|
count(r.id) filter (where r.rating = 'hard')::int as "Hard",
|
||||||
|
count(r.id) filter (where r.rating = 'good')::int as "Good",
|
||||||
|
count(r.id) filter (where r.rating = 'easy')::int as "Easy",
|
||||||
|
coalesce(sum(r.duration_ms), 0)::bigint as "DurationMs"
|
||||||
|
from generate_series((now() at time zone 'UTC')::date - (@days - 1) * interval '1 day',
|
||||||
|
(now() at time zone 'UTC')::date,
|
||||||
|
interval '1 day') day
|
||||||
|
left join review r
|
||||||
|
on r.user_id = @userId
|
||||||
|
and (r.reviewed_at at time zone 'UTC')::date = day::date
|
||||||
|
group by day
|
||||||
|
order by day
|
||||||
|
""",
|
||||||
|
new { userId, days });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<RatingStat>> GetRatingStatsAsync(
|
||||||
|
long userId,
|
||||||
|
int days,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<RatingStat>(
|
||||||
|
"""
|
||||||
|
select rating as "Rating", count(*)::int as "Count"
|
||||||
|
from review
|
||||||
|
where user_id = @userId and reviewed_at >= now() - (@days * interval '1 day')
|
||||||
|
group by rating
|
||||||
|
""",
|
||||||
|
new { userId, days });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HardCardStat>> GetHardCardsAsync(
|
||||||
|
long userId,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<HardCardStat>(
|
||||||
|
"""
|
||||||
|
select c.id as "CardId",
|
||||||
|
coalesce((select body from card_side where card_id = c.id and kind = 'front' limit 1), '') as "Front",
|
||||||
|
count(r.id)::int as "Reviews",
|
||||||
|
coalesce(max((select lapses from card_schedule where card_id = c.id and user_id = @userId)), 0)::int as "Lapses",
|
||||||
|
avg(case when r.rating = 'again' then 1.0 else 0.0 end)::double precision as "AgainRate"
|
||||||
|
from review r
|
||||||
|
join card c on c.id = r.card_id
|
||||||
|
where r.user_id = @userId and c.deleted_at is null
|
||||||
|
group by c.id
|
||||||
|
having count(r.id) >= 2
|
||||||
|
order by avg(case when r.rating = 'again' then 1.0 else 0.0 end) desc, count(r.id) desc
|
||||||
|
limit @limit
|
||||||
|
""",
|
||||||
|
new { userId, limit });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DeckStat>> GetDeckStatsAsync(
|
||||||
|
long userId,
|
||||||
|
int days,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<DeckStat>(
|
||||||
|
"""
|
||||||
|
select d.id as "DeckId", d.name as "DeckName", l.name as "LessonName", co.name as "CourseName",
|
||||||
|
count(r.id)::int as "Reviews",
|
||||||
|
round(100.0 * count(r.id) filter (where r.rating in ('good', 'easy')) / count(r.id))::int as "Retention"
|
||||||
|
from review r
|
||||||
|
join deck d on d.id = r.deck_id
|
||||||
|
join lesson l on l.id = d.lesson_id
|
||||||
|
join course co on co.id = l.course_id
|
||||||
|
where r.user_id = @userId and r.reviewed_at >= now() - (@days * interval '1 day')
|
||||||
|
group by d.id, l.id, co.id
|
||||||
|
order by count(r.id) desc
|
||||||
|
limit @limit
|
||||||
|
""",
|
||||||
|
new { userId, days, limit });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Streak(IReadOnlyList<DateOnly> days)
|
||||||
|
{
|
||||||
|
if (days.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var expected = days[0] == today ? today : today.AddDays(-1);
|
||||||
|
if (days[0] != expected)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var streak = 0;
|
||||||
|
foreach (var day in days)
|
||||||
|
{
|
||||||
|
if (day != expected)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
streak++;
|
||||||
|
expected = expected.AddDays(-1);
|
||||||
|
}
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SummaryRow
|
||||||
|
{
|
||||||
|
public int TotalReviews { get; init; }
|
||||||
|
public int ReviewsToday { get; init; }
|
||||||
|
public int CardsSeen { get; init; }
|
||||||
|
public int DueNow { get; init; }
|
||||||
|
public int Reviews30 { get; init; }
|
||||||
|
public int Good30 { get; init; }
|
||||||
|
public long TimeSpentMs { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Flashsharp.Scheduling;
|
||||||
|
|
||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public sealed partial class Store
|
||||||
|
{
|
||||||
|
public async Task<StudyCard?> GetNextStudyCardAsync(
|
||||||
|
long userId,
|
||||||
|
long deckId,
|
||||||
|
string mode,
|
||||||
|
IReadOnlyList<long> exclude,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (mode is not ("due" or "all" or "new"))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unknown study mode: {mode}.", nameof(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<StudyRow>(
|
||||||
|
"""
|
||||||
|
select c.id as "CardId", s.due_at as "DueAt", s.repetitions as "Repetitions", s.lapses as "Lapses",
|
||||||
|
(s.user_id is null) as "IsNew"
|
||||||
|
from card_deck cd
|
||||||
|
join card c on c.id = cd.card_id
|
||||||
|
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
|
||||||
|
where cd.deck_id = @deckId
|
||||||
|
and c.deleted_at is null
|
||||||
|
and not (c.id = any(@exclude))
|
||||||
|
and (
|
||||||
|
@mode = 'all'
|
||||||
|
or (@mode = 'new' and s.user_id is null)
|
||||||
|
or (@mode = 'due' and (s.user_id is null or s.due_at <= now()))
|
||||||
|
)
|
||||||
|
order by s.due_at nulls first, c.id
|
||||||
|
limit 1
|
||||||
|
""",
|
||||||
|
new { userId, deckId, mode, exclude = exclude.ToArray() });
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = await GetCardAsync(row.CardId, cancellationToken);
|
||||||
|
if (card is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StudyCard(card, row.IsNew, row.DueAt, row.Repetitions, row.Lapses);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StudyCard?> GetStudyCardAsync(
|
||||||
|
long userId,
|
||||||
|
long cardId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<StudyRow>(
|
||||||
|
"""
|
||||||
|
select c.id as "CardId", s.due_at as "DueAt", s.repetitions as "Repetitions", s.lapses as "Lapses",
|
||||||
|
(s.user_id is null) as "IsNew"
|
||||||
|
from card c
|
||||||
|
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
|
||||||
|
where c.id = @cardId and c.deleted_at is null
|
||||||
|
""",
|
||||||
|
new { userId, cardId });
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = await GetCardAsync(row.CardId, cancellationToken);
|
||||||
|
return card is null ? null : new StudyCard(card, row.IsNew, row.DueAt, row.Repetitions, row.Lapses);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReviewOutcome> RecordReviewAsync(
|
||||||
|
long userId,
|
||||||
|
long cardId,
|
||||||
|
long? deckId,
|
||||||
|
ReviewRating rating,
|
||||||
|
int durationMs,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var reviewId = await connection.ExecuteScalarAsync<long>(
|
||||||
|
"""
|
||||||
|
insert into review (user_id, card_id, deck_id, rating, duration_ms)
|
||||||
|
values (@userId, @cardId, @deckId, @rating::review_rating, @durationMs)
|
||||||
|
returning id
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
cardId,
|
||||||
|
deckId,
|
||||||
|
rating = rating.ToString().ToLowerInvariant(),
|
||||||
|
durationMs = Math.Max(0, durationMs)
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var current = await connection.QuerySingleOrDefaultAsync<ScheduleState>(
|
||||||
|
"""
|
||||||
|
select due_at as "DueAt", interval_seconds as "IntervalSeconds", ease as "Ease",
|
||||||
|
repetitions as "Repetitions", lapses as "Lapses"
|
||||||
|
from card_schedule
|
||||||
|
where user_id = @userId and card_id = @cardId
|
||||||
|
for update
|
||||||
|
""",
|
||||||
|
new { userId, cardId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var next = Scheduler.Next(current, rating, DateTime.UtcNow);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
insert into card_schedule
|
||||||
|
(user_id, card_id, due_at, interval_seconds, ease, repetitions, lapses, last_review_id, updated_at)
|
||||||
|
values
|
||||||
|
(@userId, @cardId, @dueAt, @intervalSeconds, @ease, @repetitions, @lapses, @reviewId, now())
|
||||||
|
on conflict (user_id, card_id) do update
|
||||||
|
set due_at = excluded.due_at,
|
||||||
|
interval_seconds = excluded.interval_seconds,
|
||||||
|
ease = excluded.ease,
|
||||||
|
repetitions = excluded.repetitions,
|
||||||
|
lapses = excluded.lapses,
|
||||||
|
last_review_id = excluded.last_review_id,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
cardId,
|
||||||
|
dueAt = next.DueAt,
|
||||||
|
intervalSeconds = next.IntervalSeconds,
|
||||||
|
ease = next.Ease,
|
||||||
|
repetitions = next.Repetitions,
|
||||||
|
lapses = next.Lapses,
|
||||||
|
reviewId
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return new ReviewOutcome(next.DueAt, next.IntervalSeconds, next.Ease, next.Repetitions, next.Lapses);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class StudyRow
|
||||||
|
{
|
||||||
|
public long CardId { get; init; }
|
||||||
|
public DateTime? DueAt { get; init; }
|
||||||
|
public int Repetitions { get; init; }
|
||||||
|
public int Lapses { get; init; }
|
||||||
|
public bool IsNew { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Flashsharp.Data;
|
||||||
|
|
||||||
|
public sealed partial class Store
|
||||||
|
{
|
||||||
|
readonly Db db;
|
||||||
|
|
||||||
|
public Store(Db db)
|
||||||
|
{
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TokenKinds
|
||||||
|
{
|
||||||
|
public const string Browser = "browser";
|
||||||
|
public const string Api = "api";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using NpgsqlTypes;
|
||||||
|
|
||||||
|
namespace Flashsharp.Domain;
|
||||||
|
|
||||||
|
public enum SideKind
|
||||||
|
{
|
||||||
|
[PgName("front")] Front,
|
||||||
|
[PgName("back")] Back,
|
||||||
|
[PgName("extra")] Extra
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ReviewRating
|
||||||
|
{
|
||||||
|
[PgName("again")] Again,
|
||||||
|
[PgName("hard")] Hard,
|
||||||
|
[PgName("good")] Good,
|
||||||
|
[PgName("easy")] Easy
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ReviewRatingExtensions
|
||||||
|
{
|
||||||
|
public static string ToLabel(this ReviewRating rating) => rating switch
|
||||||
|
{
|
||||||
|
ReviewRating.Again => "Again",
|
||||||
|
ReviewRating.Hard => "Hard",
|
||||||
|
ReviewRating.Good => "Good",
|
||||||
|
ReviewRating.Easy => "Easy",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(rating))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record User(long Id, string Username);
|
||||||
|
|
||||||
|
public sealed class AuthToken
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public string Kind { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? LastUsedAt { get; set; }
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CourseSummary(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
int Position,
|
||||||
|
int LessonCount,
|
||||||
|
int DeckCount,
|
||||||
|
int CardCount,
|
||||||
|
int DueCount);
|
||||||
|
|
||||||
|
public sealed record LessonSummary(
|
||||||
|
long Id,
|
||||||
|
long CourseId,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
int Position,
|
||||||
|
int DeckCount,
|
||||||
|
int CardCount,
|
||||||
|
int DueCount);
|
||||||
|
|
||||||
|
public sealed record DeckSummary(
|
||||||
|
long Id,
|
||||||
|
long LessonId,
|
||||||
|
long CourseId,
|
||||||
|
string LessonName,
|
||||||
|
string CourseName,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
int Position,
|
||||||
|
int CardCount,
|
||||||
|
int DueCount);
|
||||||
|
|
||||||
|
public sealed record DeckContext(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
long LessonId,
|
||||||
|
string LessonName,
|
||||||
|
long CourseId,
|
||||||
|
string CourseName);
|
||||||
|
|
||||||
|
public sealed record CourseNode(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
int Position,
|
||||||
|
IReadOnlyList<LessonNode> Lessons);
|
||||||
|
|
||||||
|
public sealed record LessonNode(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
int Position,
|
||||||
|
IReadOnlyList<DeckNode> Decks);
|
||||||
|
|
||||||
|
public sealed record DeckNode(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
int Position,
|
||||||
|
int CardCount);
|
||||||
|
|
||||||
|
public sealed record Side(
|
||||||
|
long Id,
|
||||||
|
SideKind Kind,
|
||||||
|
string Label,
|
||||||
|
string Body,
|
||||||
|
int Position);
|
||||||
|
|
||||||
|
public sealed record Card(
|
||||||
|
long Id,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
IReadOnlyList<Side> Sides)
|
||||||
|
{
|
||||||
|
public Side? Front => Sides.FirstOrDefault(side => side.Kind == SideKind.Front);
|
||||||
|
public Side? Back => Sides.FirstOrDefault(side => side.Kind == SideKind.Back);
|
||||||
|
public IReadOnlyList<Side> Extras => Sides.Where(side => side.Kind == SideKind.Extra).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CardRef(
|
||||||
|
long Id,
|
||||||
|
string Front,
|
||||||
|
string? Back,
|
||||||
|
IReadOnlyList<string> Tags,
|
||||||
|
IReadOnlyList<DeckRef> Decks);
|
||||||
|
|
||||||
|
public sealed record DeckRef(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
long LessonId,
|
||||||
|
string LessonName,
|
||||||
|
long CourseId,
|
||||||
|
string CourseName);
|
||||||
|
|
||||||
|
public sealed record TagInfo(string Name, string Description, int CardCount);
|
||||||
|
|
||||||
|
public sealed record StudyCard(
|
||||||
|
Card Card,
|
||||||
|
bool IsNew,
|
||||||
|
DateTime? DueAt,
|
||||||
|
int Repetitions,
|
||||||
|
int Lapses);
|
||||||
|
|
||||||
|
public sealed record ReviewOutcome(
|
||||||
|
DateTime DueAt,
|
||||||
|
int IntervalSeconds,
|
||||||
|
double Ease,
|
||||||
|
int Repetitions,
|
||||||
|
int Lapses);
|
||||||
|
|
||||||
|
public sealed record ScheduleState(
|
||||||
|
DateTime DueAt,
|
||||||
|
int IntervalSeconds,
|
||||||
|
double Ease,
|
||||||
|
int Repetitions,
|
||||||
|
int Lapses);
|
||||||
|
|
||||||
|
public sealed record DailyStat(
|
||||||
|
DateOnly Day,
|
||||||
|
int Total,
|
||||||
|
int Again,
|
||||||
|
int Hard,
|
||||||
|
int Good,
|
||||||
|
int Easy,
|
||||||
|
long DurationMs);
|
||||||
|
|
||||||
|
public sealed record RatingStat(ReviewRating Rating, int Count);
|
||||||
|
|
||||||
|
public sealed record HardCardStat(
|
||||||
|
long CardId,
|
||||||
|
string Front,
|
||||||
|
int Reviews,
|
||||||
|
int Lapses,
|
||||||
|
double AgainRate);
|
||||||
|
|
||||||
|
public sealed record DeckStat(
|
||||||
|
long DeckId,
|
||||||
|
string DeckName,
|
||||||
|
string LessonName,
|
||||||
|
string CourseName,
|
||||||
|
int Reviews,
|
||||||
|
int Retention);
|
||||||
|
|
||||||
|
public sealed record StatsSummary(
|
||||||
|
int TotalReviews,
|
||||||
|
int ReviewsToday,
|
||||||
|
int StreakDays,
|
||||||
|
int CardsSeen,
|
||||||
|
int DueNow,
|
||||||
|
int Retention30d,
|
||||||
|
long TimeSpentMs);
|
||||||
|
|
||||||
|
public sealed record Crumb(string Label, string? Href);
|
||||||
|
|
||||||
|
public sealed record EntityRef(long Id, string Name);
|
||||||
|
|
||||||
|
public sealed record ExtraSide(string Label, string Body);
|
||||||
|
|
||||||
|
public sealed record CardDraft(
|
||||||
|
string Front,
|
||||||
|
string? Back,
|
||||||
|
IReadOnlyList<ExtraSide>? Extras,
|
||||||
|
IReadOnlyList<string>? Tags);
|
||||||
|
|
||||||
|
public sealed record BreadcrumbView(IReadOnlyList<Crumb> Crumbs, IReadOnlyList<string> Chips)
|
||||||
|
{
|
||||||
|
public static BreadcrumbView Home(params string[] chips) =>
|
||||||
|
new([new Crumb("Courses", "/")], chips);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyName>flashsharp</AssemblyName>
|
||||||
|
<RootNamespace>Flashsharp</RootNamespace>
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.89" />
|
||||||
|
<PackageReference Include="HtmlSanitizer" Version="9.2.1039" />
|
||||||
|
<PackageReference Include="Markdig" Version="1.4.2" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.3.0" />
|
||||||
|
<PackageReference Include="Npgsql" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Data/Migrations/*.sql" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using ModelContextProtocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace Flashsharp.Mcp;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public static class FlashcardTools
|
||||||
|
{
|
||||||
|
static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||||
|
};
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Return the whole course > lesson > deck tree with ids and card counts. " +
|
||||||
|
"Start here to find the deck id needed by create_card.")]
|
||||||
|
public static async Task<string> GetTree(Store store, CancellationToken cancellationToken)
|
||||||
|
=> Serialize(await store.GetTreeAsync(cancellationToken));
|
||||||
|
|
||||||
|
[McpServerTool, Description("List courses with ids, descriptions and positions.")]
|
||||||
|
public static async Task<string> ListCourses(
|
||||||
|
Store store,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Serialize(await store.ListCoursesAsync(user.GetUserId(), cancellationToken));
|
||||||
|
|
||||||
|
[McpServerTool, Description("List lessons in a course.")]
|
||||||
|
public static async Task<string> ListLessons(
|
||||||
|
Store store,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
[Description("Course id from get_tree or list_courses.")] long courseId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Serialize(await store.ListLessonsAsync(courseId, user.GetUserId(), cancellationToken));
|
||||||
|
|
||||||
|
[McpServerTool, Description("List decks in a lesson.")]
|
||||||
|
public static async Task<string> ListDecks(
|
||||||
|
Store store,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
[Description("Lesson id from get_tree or list_lessons.")] long lessonId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Serialize(await store.ListDecksAsync(lessonId, user.GetUserId(), cancellationToken));
|
||||||
|
|
||||||
|
[McpServerTool, Description("Create a course. If a course with the same name already exists, returns it instead.")]
|
||||||
|
public static Task<string> CreateCourse(
|
||||||
|
Store store,
|
||||||
|
[Description("Course name, for example 'Farsi'.")] string name,
|
||||||
|
[Description("Optional course description.")] string? description = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Guard(async () => Serialize(await store.CreateCourseAsync(name, description ?? "", cancellationToken)));
|
||||||
|
|
||||||
|
[McpServerTool, Description("Create a lesson inside a course. Reuses an existing lesson with the same name in that course.")]
|
||||||
|
public static Task<string> CreateLesson(
|
||||||
|
Store store,
|
||||||
|
[Description("Course id.")] long courseId,
|
||||||
|
[Description("Lesson name, for example 'Alphabet'.")] string name,
|
||||||
|
[Description("Optional lesson description.")] string? description = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureCourseAsync(store, courseId, cancellationToken);
|
||||||
|
return Serialize(await store.CreateLessonAsync(courseId, name, description ?? "", cancellationToken));
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description("Create a deck inside a lesson. Reuses an existing deck with the same name in that lesson.")]
|
||||||
|
public static Task<string> CreateDeck(
|
||||||
|
Store store,
|
||||||
|
[Description("Lesson id.")] long lessonId,
|
||||||
|
[Description("Deck name, for example 'Letters 1'.")] string name,
|
||||||
|
[Description("Optional deck description.")] string? description = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureLessonAsync(store, lessonId, cancellationToken);
|
||||||
|
return Serialize(await store.CreateDeckAsync(lessonId, name, description ?? "", cancellationToken));
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Create one card in a deck and return its id. The front side is what the learner sees first; " +
|
||||||
|
"the optional back side is revealed as the answer; extras are optional additional sides such as " +
|
||||||
|
"pronunciation or notes. Tags are created on demand. Call get_tree first to find the deck id.")]
|
||||||
|
public static Task<string> CreateCard(
|
||||||
|
Store store,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
[Description("Deck id from get_tree or list_decks.")] long deckId,
|
||||||
|
[Description("The front side in Markdown. This is the default side shown first.")] string front,
|
||||||
|
[Description("Optional answer side in Markdown.")] string? back = null,
|
||||||
|
[Description("Optional additional sides, each with a label and Markdown body.")] List<ExtraSideInput>? extras = null,
|
||||||
|
[Description("Optional tags, for example ['farsi', 'vocabulary'].")] List<string>? tags = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureDeckAsync(store, deckId, cancellationToken);
|
||||||
|
var id = await store.CreateCardAsync(
|
||||||
|
deckId,
|
||||||
|
user.GetUserId(),
|
||||||
|
ToDraft(front, back, extras, tags),
|
||||||
|
cancellationToken);
|
||||||
|
return Serialize(new { id });
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Create up to 200 cards in one deck in a single transaction. Prefer this for bulk imports. " +
|
||||||
|
"Returns the new card ids in input order.")]
|
||||||
|
public static Task<string> CreateCards(
|
||||||
|
Store store,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
[Description("Deck id from get_tree or list_decks.")] long deckId,
|
||||||
|
[Description("Cards to create.")] List<CardInput> cards,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureDeckAsync(store, deckId, cancellationToken);
|
||||||
|
var drafts = cards.Select(card => ToDraft(card.Front, card.Back, card.Extras, card.Tags)).ToList();
|
||||||
|
var ids = await store.CreateCardsAsync(deckId, user.GetUserId(), drafts, cancellationToken);
|
||||||
|
return Serialize(new { ids });
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description("Get a card with all sides, tags and the decks it belongs to.")]
|
||||||
|
public static async Task<string> GetCard(
|
||||||
|
Store store,
|
||||||
|
[Description("Card id.")] long cardId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var card = await store.GetCardAsync(cardId, cancellationToken);
|
||||||
|
if (card is null)
|
||||||
|
{
|
||||||
|
throw new McpException($"Card {cardId} does not exist.");
|
||||||
|
}
|
||||||
|
var cardRef = await store.GetCardRefAsync(cardId, cancellationToken);
|
||||||
|
return Serialize(new
|
||||||
|
{
|
||||||
|
card.Id,
|
||||||
|
card.CreatedAt,
|
||||||
|
Sides = card.Sides.Select(side => new { side.Kind, side.Label, side.Body }),
|
||||||
|
cardRef?.Tags,
|
||||||
|
cardRef?.Decks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Replace all sides and tags of a card. Omitted back and extras are removed. " +
|
||||||
|
"Reviews and schedule are preserved.")]
|
||||||
|
public static Task<string> UpdateCard(
|
||||||
|
Store store,
|
||||||
|
[Description("Card id.")] long cardId,
|
||||||
|
[Description("New front side in Markdown.")] string front,
|
||||||
|
[Description("New answer side in Markdown, or null to remove it.")] string? back = null,
|
||||||
|
[Description("New additional sides, or null to remove them.")] List<ExtraSideInput>? extras = null,
|
||||||
|
[Description("New tags. This replaces the existing tags.")] List<string>? tags = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await store.UpdateCardAsync(cardId, ToDraft(front, back, extras, tags), cancellationToken);
|
||||||
|
return Serialize(new { id = cardId, updated = true });
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description("Soft-delete a card. Its review history is kept.")]
|
||||||
|
public static async Task<string> DeleteCard(
|
||||||
|
Store store,
|
||||||
|
[Description("Card id.")] long cardId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await store.DeleteCardAsync(cardId, cancellationToken);
|
||||||
|
return Serialize(new { id = cardId, deleted = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Add an existing card to another deck. Cards can live in several decks.")]
|
||||||
|
public static Task<string> AddCardToDeck(
|
||||||
|
Store store,
|
||||||
|
[Description("Card id.")] long cardId,
|
||||||
|
[Description("Deck id.")] long deckId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureDeckAsync(store, deckId, cancellationToken);
|
||||||
|
await EnsureCardAsync(store, cardId, cancellationToken);
|
||||||
|
await store.AddCardToDeckAsync(cardId, deckId, cancellationToken);
|
||||||
|
return Serialize(new { cardId, deckId, added = true });
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description("Remove a card from a deck without deleting the card.")]
|
||||||
|
public static async Task<string> RemoveCardFromDeck(
|
||||||
|
Store store,
|
||||||
|
[Description("Card id.")] long cardId,
|
||||||
|
[Description("Deck id.")] long deckId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await store.RemoveCardFromDeckAsync(cardId, deckId, cancellationToken);
|
||||||
|
return Serialize(new { cardId, deckId, removed = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Search cards by text, tag, course, lesson or deck. Text search covers all sides and works " +
|
||||||
|
"with non-Latin scripts. Returns front, back, tags and deck locations.")]
|
||||||
|
public static async Task<string> SearchCards(
|
||||||
|
Store store,
|
||||||
|
[Description("Text to search for. Optional when filtering by tag or deck.")] string? query = null,
|
||||||
|
[Description("Only cards with this tag.")] string? tag = null,
|
||||||
|
[Description("Only cards in this course.")] long? courseId = null,
|
||||||
|
[Description("Only cards in this lesson.")] long? lessonId = null,
|
||||||
|
[Description("Only cards in this deck.")] long? deckId = null,
|
||||||
|
[Description("Maximum number of results, 1-100. Default 20.")] int limit = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var results = await store.SearchCardsAsync(
|
||||||
|
query,
|
||||||
|
courseId,
|
||||||
|
lessonId,
|
||||||
|
deckId,
|
||||||
|
tag,
|
||||||
|
Math.Clamp(limit, 1, 100),
|
||||||
|
cancellationToken);
|
||||||
|
return Serialize(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("List the tag vocabulary with card counts. Reuse existing tags instead of inventing near-duplicates.")]
|
||||||
|
public static async Task<string> ListTags(Store store, CancellationToken cancellationToken)
|
||||||
|
=> Serialize(await store.ListTagsAsync(cancellationToken));
|
||||||
|
|
||||||
|
[McpServerTool, Description("Create a tag, or update its description if it already exists.")]
|
||||||
|
public static Task<string> CreateTag(
|
||||||
|
Store store,
|
||||||
|
[Description("Tag name.")] string name,
|
||||||
|
[Description("Optional tag description.")] string? description = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Guard(async () => Serialize(new
|
||||||
|
{
|
||||||
|
name = await store.CreateTagAsync(name, description ?? "", cancellationToken)
|
||||||
|
}));
|
||||||
|
|
||||||
|
[McpServerTool, Description("Rename a course.")]
|
||||||
|
public static Task<string> RenameCourse(
|
||||||
|
Store store,
|
||||||
|
[Description("Course id.")] long courseId,
|
||||||
|
[Description("New name.")] string name,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureCourseAsync(store, courseId, cancellationToken);
|
||||||
|
await store.RenameCourseAsync(courseId, name, cancellationToken);
|
||||||
|
return Serialize(new { id = courseId, name });
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description("Rename a lesson.")]
|
||||||
|
public static Task<string> RenameLesson(
|
||||||
|
Store store,
|
||||||
|
[Description("Lesson id.")] long lessonId,
|
||||||
|
[Description("New name.")] string name,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureLessonAsync(store, lessonId, cancellationToken);
|
||||||
|
await store.RenameLessonAsync(lessonId, name, cancellationToken);
|
||||||
|
return Serialize(new { id = lessonId, name });
|
||||||
|
});
|
||||||
|
|
||||||
|
[McpServerTool, Description("Rename a deck.")]
|
||||||
|
public static Task<string> RenameDeck(
|
||||||
|
Store store,
|
||||||
|
[Description("Deck id.")] long deckId,
|
||||||
|
[Description("New name.")] string name,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Guard(async () =>
|
||||||
|
{
|
||||||
|
await EnsureDeckAsync(store, deckId, cancellationToken);
|
||||||
|
await store.RenameDeckAsync(deckId, name, cancellationToken);
|
||||||
|
return Serialize(new { id = deckId, name });
|
||||||
|
});
|
||||||
|
|
||||||
|
static async Task EnsureDeckAsync(Store store, long deckId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (await store.GetDeckContextAsync(deckId, cancellationToken) is null)
|
||||||
|
{
|
||||||
|
throw new McpException($"Deck {deckId} does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task EnsureCourseAsync(Store store, long courseId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await store.CourseExistsAsync(courseId, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new McpException($"Course {courseId} does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task EnsureLessonAsync(Store store, long lessonId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await store.LessonExistsAsync(lessonId, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new McpException($"Lesson {lessonId} does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task EnsureCardAsync(Store store, long cardId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await store.CardExistsAsync(cardId, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new McpException($"Card {cardId} does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<string> Guard(Func<Task<string>> action)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await action();
|
||||||
|
}
|
||||||
|
catch (McpException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new McpException(exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CardDraft ToDraft(
|
||||||
|
string front,
|
||||||
|
string? back,
|
||||||
|
IReadOnlyList<ExtraSideInput>? extras,
|
||||||
|
IReadOnlyList<string>? tags)
|
||||||
|
=> new(
|
||||||
|
front,
|
||||||
|
back,
|
||||||
|
(extras ?? []).Select(extra => new ExtraSide(extra.Label ?? "", extra.Body)).ToList(),
|
||||||
|
(tags ?? []).ToList());
|
||||||
|
|
||||||
|
static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExtraSideInput
|
||||||
|
{
|
||||||
|
[Description("Short label such as 'Pronunciation' or 'Example'.")]
|
||||||
|
public string? Label { get; init; }
|
||||||
|
|
||||||
|
[Description("Markdown body of this side.")]
|
||||||
|
public string Body { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CardInput
|
||||||
|
{
|
||||||
|
[Description("Front side in Markdown.")]
|
||||||
|
public string Front { get; init; } = "";
|
||||||
|
|
||||||
|
[Description("Optional answer side in Markdown.")]
|
||||||
|
public string? Back { get; init; }
|
||||||
|
|
||||||
|
[Description("Optional additional sides.")]
|
||||||
|
public List<ExtraSideInput>? Extras { get; init; }
|
||||||
|
|
||||||
|
[Description("Optional tags.")]
|
||||||
|
public List<string>? Tags { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@page "/courses/{id:long}"
|
||||||
|
@model Courses.DetailsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.Course.Name;
|
||||||
|
var breadcrumb = new BreadcrumbView(
|
||||||
|
[
|
||||||
|
new Crumb("Courses", "/"),
|
||||||
|
new Crumb(Model.Course.Name, null)
|
||||||
|
],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">Course</p>
|
||||||
|
<h1 class="fs-page-title" dir="auto">@Model.Course.Name</h1>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Course.Description))
|
||||||
|
{
|
||||||
|
<p class="fs-page-description" dir="auto">@Model.Course.Description</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (Model.Lessons.Count == 0)
|
||||||
|
{
|
||||||
|
<section class="fs-empty">
|
||||||
|
<p>No lessons yet. Ask your agent to create one in this course.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="fs-list">
|
||||||
|
@foreach (var lesson in Model.Lessons)
|
||||||
|
{
|
||||||
|
<a class="fs-row" asp-page="/Lessons/Details" asp-route-id="@lesson.Id">
|
||||||
|
<div class="fs-row-main">
|
||||||
|
<h2 class="fs-row-title" dir="auto">@lesson.Name</h2>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(lesson.Description))
|
||||||
|
{
|
||||||
|
<p class="fs-row-subtitle" dir="auto">@Display.Preview(lesson.Description, 140)</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="fs-row-meta">
|
||||||
|
<span>@lesson.DeckCount decks</span>
|
||||||
|
<span>@lesson.CardCount cards</span>
|
||||||
|
@if (lesson.DueCount > 0)
|
||||||
|
{
|
||||||
|
<span class="fs-due-pill">@lesson.DueCount due</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages.Courses;
|
||||||
|
|
||||||
|
public sealed class DetailsModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public CourseSummary Course { get; private set; } = null!;
|
||||||
|
|
||||||
|
public IReadOnlyList<LessonSummary> Lessons { get; private set; } = [];
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(long id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var course = await store.GetCourseAsync(id, userId, cancellationToken);
|
||||||
|
if (course is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
Course = course;
|
||||||
|
Lessons = await store.ListLessonsAsync(id, userId, cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
@page "/decks/{id:long}"
|
||||||
|
@model Decks.DetailsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.Deck.Name;
|
||||||
|
var breadcrumb = new BreadcrumbView(
|
||||||
|
[
|
||||||
|
new Crumb("Courses", "/"),
|
||||||
|
new Crumb(Model.Deck.CourseName, $"/courses/{Model.Deck.CourseId}"),
|
||||||
|
new Crumb(Model.Deck.LessonName, $"/lessons/{Model.Deck.LessonId}"),
|
||||||
|
new Crumb(Model.Deck.Name, null)
|
||||||
|
],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">Deck</p>
|
||||||
|
<h1 class="fs-page-title" dir="auto">@Model.Deck.Name</h1>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Deck.Description))
|
||||||
|
{
|
||||||
|
<p class="fs-page-description" dir="auto">@Model.Deck.Description</p>
|
||||||
|
}
|
||||||
|
<div class="fs-actions">
|
||||||
|
<a class="button fs-button" asp-page="/Study" asp-route-deckId="@Model.Deck.Id" asp-route-mode="due">
|
||||||
|
Study due (@Model.Deck.DueCount)
|
||||||
|
</a>
|
||||||
|
<a class="button is-light" asp-page="/Study" asp-route-deckId="@Model.Deck.Id" asp-route-mode="all">All cards</a>
|
||||||
|
<a class="button is-light" asp-page="/Study" asp-route-deckId="@Model.Deck.Id" asp-route-mode="new">New only</a>
|
||||||
|
<a class="button is-ghost" asp-page="/Search" asp-route-deckId="@Model.Deck.Id">Search in deck</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (Model.Cards.Count == 0)
|
||||||
|
{
|
||||||
|
<section class="fs-empty">
|
||||||
|
<p>No cards in this deck yet. Ask your agent to add some over MCP.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="fs-card-list">
|
||||||
|
@foreach (var card in Model.Cards)
|
||||||
|
{
|
||||||
|
<article class="fs-mini-card">
|
||||||
|
<div class="fs-mini-front" dir="auto">@Display.Preview(card.Front, 220)</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(card.Back))
|
||||||
|
{
|
||||||
|
<div class="fs-mini-back" dir="auto">@Display.Preview(card.Back, 220)</div>
|
||||||
|
}
|
||||||
|
<div class="fs-mini-footer">
|
||||||
|
<div class="fs-tag-list">
|
||||||
|
@foreach (var tag in card.Tags)
|
||||||
|
{
|
||||||
|
<a class="tag is-light" asp-page="/Search" asp-route-tag="@tag">@tag</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="fs-card-id">#@card.Id</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.TotalPages > 1)
|
||||||
|
{
|
||||||
|
<nav class="pagination fs-pagination" role="navigation" aria-label="Pagination">
|
||||||
|
<a class="pagination-previous" asp-page="/Decks/Details" asp-route-id="@Model.Deck.Id" asp-route-page="@(Model.PageNumber - 1)"
|
||||||
|
disabled="@(Model.PageNumber <= 1)">Previous</a>
|
||||||
|
<a class="pagination-next" asp-page="/Decks/Details" asp-route-id="@Model.Deck.Id" asp-route-page="@(Model.PageNumber + 1)"
|
||||||
|
disabled="@(Model.PageNumber >= Model.TotalPages)">Next</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
<li><span class="pagination-link is-current">@Model.PageNumber / @Model.TotalPages</span></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages.Decks;
|
||||||
|
|
||||||
|
public sealed class DetailsModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
const int PageSize = 30;
|
||||||
|
|
||||||
|
public DeckSummary Deck { get; private set; } = null!;
|
||||||
|
|
||||||
|
public IReadOnlyList<CardRef> Cards { get; private set; } = [];
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true, Name = "page")]
|
||||||
|
public int PageNumber { get; set; } = 1;
|
||||||
|
|
||||||
|
public int TotalPages { get; private set; } = 1;
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(long id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var deck = await store.GetDeckAsync(id, userId, cancellationToken);
|
||||||
|
if (deck is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
Deck = deck;
|
||||||
|
TotalPages = Math.Max(1, (int)Math.Ceiling(deck.CardCount / (double)PageSize));
|
||||||
|
PageNumber = Math.Clamp(PageNumber, 1, TotalPages);
|
||||||
|
Cards = await store.ListDeckCardsAsync(id, PageSize, (PageNumber - 1) * PageSize, cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@page
|
||||||
|
@model ErrorModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Something went wrong";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">Error @Model.Code</p>
|
||||||
|
<h1 class="title is-3">Something went wrong</h1>
|
||||||
|
<p class="subtitle is-6">The request could not be completed.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a class="button fs-button" asp-page="/Index">Back to courses</a>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[IgnoreAntiforgeryToken]
|
||||||
|
public sealed class ErrorModel : PageModel
|
||||||
|
{
|
||||||
|
public int Code { get; private set; }
|
||||||
|
|
||||||
|
public void OnGet(int? code)
|
||||||
|
{
|
||||||
|
Code = code ?? 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPost(int? code)
|
||||||
|
{
|
||||||
|
Code = code ?? 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@page
|
||||||
|
@model IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Courses";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="fs-hero">
|
||||||
|
<div class="fs-hero-copy">
|
||||||
|
<p class="fs-eyebrow">Your library</p>
|
||||||
|
<h1 class="fs-hero-title">Remember what matters.</h1>
|
||||||
|
<p class="fs-hero-text">
|
||||||
|
Courses hold lessons, lessons hold decks, decks hold cards. Cards are written by your agents
|
||||||
|
over MCP, then reviewed here one card at a time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="fs-hero-deck" aria-hidden="true">
|
||||||
|
<span class="fs-ghost-card fs-ghost-card-3"></span>
|
||||||
|
<span class="fs-ghost-card fs-ghost-card-2"></span>
|
||||||
|
<span class="fs-ghost-card fs-ghost-card-1">?</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-stat-strip" aria-label="Study summary">
|
||||||
|
<div class="fs-stat">
|
||||||
|
<span class="fs-stat-value">@Model.Stats.DueNow</span>
|
||||||
|
<span class="fs-stat-label">due now</span>
|
||||||
|
</div>
|
||||||
|
<div class="fs-stat">
|
||||||
|
<span class="fs-stat-value">@Model.Stats.ReviewsToday</span>
|
||||||
|
<span class="fs-stat-label">reviewed today</span>
|
||||||
|
</div>
|
||||||
|
<div class="fs-stat">
|
||||||
|
<span class="fs-stat-value">@Model.Stats.StreakDays</span>
|
||||||
|
<span class="fs-stat-label">day streak</span>
|
||||||
|
</div>
|
||||||
|
<div class="fs-stat">
|
||||||
|
<span class="fs-stat-value">@Model.Stats.Retention30d%</span>
|
||||||
|
<span class="fs-stat-label">30-day retention</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (Model.Courses.Count == 0)
|
||||||
|
{
|
||||||
|
<section class="fs-empty">
|
||||||
|
<p class="fs-eyebrow">Nothing here yet</p>
|
||||||
|
<h2 class="title is-4">Build your first course with an agent.</h2>
|
||||||
|
<p>
|
||||||
|
Cards are created through the MCP server. Point your agent at
|
||||||
|
<code>@Model.McpUrl</code>, create a token on the <a asp-page="/Tokens">Tokens</a> page,
|
||||||
|
then ask it to create a course, a lesson and a deck.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="fs-course-grid">
|
||||||
|
@foreach (var course in Model.Courses)
|
||||||
|
{
|
||||||
|
<a class="fs-course-card" asp-page="/Courses/Details" asp-route-id="@course.Id">
|
||||||
|
<div class="fs-course-card-top">
|
||||||
|
<h2 class="fs-course-name" dir="auto">@course.Name</h2>
|
||||||
|
@if (course.DueCount > 0)
|
||||||
|
{
|
||||||
|
<span class="fs-due-pill">@course.DueCount due</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(course.Description))
|
||||||
|
{
|
||||||
|
<p class="fs-course-description" dir="auto">@Display.Preview(course.Description, 120)</p>
|
||||||
|
}
|
||||||
|
<dl class="fs-course-meta">
|
||||||
|
<div><dt>@course.LessonCount</dt><dd>lessons</dd></div>
|
||||||
|
<div><dt>@course.DeckCount</dt><dd>decks</dd></div>
|
||||||
|
<div><dt>@course.CardCount</dt><dd>cards</dd></div>
|
||||||
|
</dl>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
public sealed class IndexModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public IReadOnlyList<CourseSummary> Courses { get; private set; } = [];
|
||||||
|
|
||||||
|
public StatsSummary Stats { get; private set; } = new(0, 0, 0, 0, 0, 0, 0);
|
||||||
|
|
||||||
|
public string McpUrl => $"{Request.Scheme}://{Request.Host}/mcp";
|
||||||
|
|
||||||
|
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
Courses = await store.ListCoursesAsync(userId, cancellationToken);
|
||||||
|
Stats = await store.GetStatsSummaryAsync(userId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@page "/lessons/{id:long}"
|
||||||
|
@model Lessons.DetailsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.Lesson.Name;
|
||||||
|
var breadcrumb = new BreadcrumbView(
|
||||||
|
[
|
||||||
|
new Crumb("Courses", "/"),
|
||||||
|
new Crumb(Model.Course.Name, $"/courses/{Model.Course.Id}"),
|
||||||
|
new Crumb(Model.Lesson.Name, null)
|
||||||
|
],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">Lesson</p>
|
||||||
|
<h1 class="fs-page-title" dir="auto">@Model.Lesson.Name</h1>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Lesson.Description))
|
||||||
|
{
|
||||||
|
<p class="fs-page-description" dir="auto">@Model.Lesson.Description</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (Model.Decks.Count == 0)
|
||||||
|
{
|
||||||
|
<section class="fs-empty">
|
||||||
|
<p>No decks yet. Ask your agent to create one in this lesson.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="fs-list">
|
||||||
|
@foreach (var deck in Model.Decks)
|
||||||
|
{
|
||||||
|
<div class="fs-row fs-row-deck">
|
||||||
|
<a class="fs-row-main" asp-page="/Decks/Details" asp-route-id="@deck.Id">
|
||||||
|
<h2 class="fs-row-title" dir="auto">@deck.Name</h2>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(deck.Description))
|
||||||
|
{
|
||||||
|
<p class="fs-row-subtitle" dir="auto">@Display.Preview(deck.Description, 140)</p>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
<div class="fs-row-meta">
|
||||||
|
<span>@deck.CardCount cards</span>
|
||||||
|
@if (deck.DueCount > 0)
|
||||||
|
{
|
||||||
|
<span class="fs-due-pill">@deck.DueCount due</span>
|
||||||
|
}
|
||||||
|
@if (deck.CardCount > 0)
|
||||||
|
{
|
||||||
|
<a class="button is-small fs-button" asp-page="/Study" asp-route-deckId="@deck.Id">Study</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages.Lessons;
|
||||||
|
|
||||||
|
public sealed class DetailsModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public LessonSummary Lesson { get; private set; } = null!;
|
||||||
|
|
||||||
|
public CourseSummary Course { get; private set; } = null!;
|
||||||
|
|
||||||
|
public IReadOnlyList<DeckSummary> Decks { get; private set; } = [];
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(long id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var lesson = await store.GetLessonAsync(id, userId, cancellationToken);
|
||||||
|
if (lesson is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var course = await store.GetCourseAsync(lesson.CourseId, userId, cancellationToken);
|
||||||
|
if (course is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
Lesson = lesson;
|
||||||
|
Course = course;
|
||||||
|
Decks = await store.ListDecksAsync(id, userId, cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@page
|
||||||
|
@model LoginModel
|
||||||
|
@{
|
||||||
|
Layout = "_AuthLayout";
|
||||||
|
ViewData["Title"] = "Sign in";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="fs-auth-card">
|
||||||
|
<div class="fs-auth-brand">
|
||||||
|
<span class="fs-brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
<span>flashsharp</span>
|
||||||
|
</div>
|
||||||
|
<p class="fs-auth-tagline">Remember what matters.</p>
|
||||||
|
|
||||||
|
@if (Model.Error is not null)
|
||||||
|
{
|
||||||
|
<div class="notification is-danger is-light" role="alert">@Model.Error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" asp-for="Username">Username</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" asp-for="Username" autocomplete="username" autofocus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" asp-for="Password">Password</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" asp-for="Password" type="password" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="button fs-button is-fullwidth" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Configuration;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
public sealed class LoginModel(Store store, AppOptions options) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty]
|
||||||
|
public string Username { get; set; } = "";
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
|
||||||
|
public string? Error { get; private set; }
|
||||||
|
|
||||||
|
public IActionResult OnGet()
|
||||||
|
=> User.Identity?.IsAuthenticated == true ? RedirectToPage("/Index") : Page();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(string? returnUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrEmpty(Password))
|
||||||
|
{
|
||||||
|
Error = "Enter a username and password.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = Username.Trim();
|
||||||
|
if (!await store.VerifyPasswordAsync(username, Password, cancellationToken))
|
||||||
|
{
|
||||||
|
Error = "Invalid username or password.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await store.GetUserByUsernameAsync(username, cancellationToken);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
Error = "Invalid username or password.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var (_, rawToken) = await store.CreateTokenAsync(
|
||||||
|
user.Id,
|
||||||
|
"Browser session",
|
||||||
|
Store.TokenKinds.Browser,
|
||||||
|
TimeSpan.FromDays(options.SessionDays),
|
||||||
|
cancellationToken);
|
||||||
|
SessionCookie.Set(Response, rawToken, Request.IsHttps, options.SessionDays);
|
||||||
|
TempData["Flash"] = $"Signed in as {user.Username}.";
|
||||||
|
|
||||||
|
return returnUrl is not null && Url.IsLocalUrl(returnUrl)
|
||||||
|
? LocalRedirect(returnUrl)
|
||||||
|
: RedirectToPage("/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@page
|
||||||
|
@model LogoutModel
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
public sealed class LogoutModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
if (User.GetTokenId() is { } tokenId)
|
||||||
|
{
|
||||||
|
await store.RevokeTokenAsync(User.GetUserId(), tokenId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionCookie.Clear(Response, Request.IsHttps);
|
||||||
|
return RedirectToPage("/Login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
@page
|
||||||
|
@model SearchModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Search";
|
||||||
|
var chips = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(Model.Q)) { chips.Add($"q: {Display.Preview(Model.Q, 30)}"); }
|
||||||
|
if (!string.IsNullOrWhiteSpace(Model.Tag)) { chips.Add($"tag: {Model.Tag}"); }
|
||||||
|
if (Model.ScopeName is not null) { chips.Add($"in: {Model.ScopeName}"); }
|
||||||
|
chips.Add($"{Model.Results.Count} result{(Model.Results.Count == 1 ? "" : "s")}");
|
||||||
|
var breadcrumb = new BreadcrumbView([new Crumb("Courses", "/"), new Crumb("Search", null)], chips);
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">Search</p>
|
||||||
|
<h1 class="fs-page-title">Find a card.</h1>
|
||||||
|
@if (Model.ScopeName is not null)
|
||||||
|
{
|
||||||
|
<p class="fs-page-description">
|
||||||
|
Searching inside <strong>@Model.ScopeName</strong>.
|
||||||
|
<a asp-page="/Search" asp-route-q="@Model.Q" asp-route-tag="@Model.Tag">Clear scope</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="get" class="fs-search-form">
|
||||||
|
@if (Model.DeckId is not null)
|
||||||
|
{
|
||||||
|
<input type="hidden" name="deckId" value="@Model.DeckId" />
|
||||||
|
}
|
||||||
|
@if (Model.LessonId is not null)
|
||||||
|
{
|
||||||
|
<input type="hidden" name="lessonId" value="@Model.LessonId" />
|
||||||
|
}
|
||||||
|
@if (Model.CourseId is not null)
|
||||||
|
{
|
||||||
|
<input type="hidden" name="courseId" value="@Model.CourseId" />
|
||||||
|
}
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" type="search" name="q" value="@Model.Q" placeholder="Word, phrase, Farsi script…" dir="auto" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button fs-button" type="submit">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="control has-icons-left">
|
||||||
|
<input class="input is-small" type="text" name="tag" value="@Model.Tag" placeholder="Filter by tag" dir="auto" />
|
||||||
|
<span class="icon is-small is-left">#</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (Model.Results.Count == 0)
|
||||||
|
{
|
||||||
|
<section class="fs-empty">
|
||||||
|
<p>
|
||||||
|
@if (string.IsNullOrWhiteSpace(Model.Q) && string.IsNullOrWhiteSpace(Model.Tag))
|
||||||
|
{
|
||||||
|
@:Type something to search across every side of every card.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@:No cards matched.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="fs-card-list">
|
||||||
|
@foreach (var card in Model.Results)
|
||||||
|
{
|
||||||
|
<article class="fs-mini-card">
|
||||||
|
<div class="fs-mini-front" dir="auto">@Display.Preview(card.Front, 260)</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(card.Back))
|
||||||
|
{
|
||||||
|
<div class="fs-mini-back" dir="auto">@Display.Preview(card.Back, 260)</div>
|
||||||
|
}
|
||||||
|
<div class="fs-mini-footer">
|
||||||
|
<div class="fs-tag-list">
|
||||||
|
@foreach (var tag in card.Tags)
|
||||||
|
{
|
||||||
|
<a class="tag is-light" asp-page="/Search" asp-route-tag="@tag">@tag</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="fs-deck-links">
|
||||||
|
@foreach (var deck in card.Decks)
|
||||||
|
{
|
||||||
|
<a asp-page="/Decks/Details" asp-route-id="@deck.Id">@deck.CourseName / @deck.LessonName / @deck.Name</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
public sealed class SearchModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public string? Q { get; private set; }
|
||||||
|
|
||||||
|
public string? Tag { get; private set; }
|
||||||
|
|
||||||
|
public long? CourseId { get; private set; }
|
||||||
|
|
||||||
|
public long? LessonId { get; private set; }
|
||||||
|
|
||||||
|
public long? DeckId { get; private set; }
|
||||||
|
|
||||||
|
public string? ScopeName { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<CardRef> Results { get; private set; } = [];
|
||||||
|
|
||||||
|
public async Task OnGetAsync(
|
||||||
|
string? q,
|
||||||
|
string? tag,
|
||||||
|
long? courseId,
|
||||||
|
long? lessonId,
|
||||||
|
long? deckId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Q = q?.Trim();
|
||||||
|
Tag = tag?.Trim();
|
||||||
|
CourseId = courseId > 0 ? courseId : null;
|
||||||
|
LessonId = lessonId > 0 ? lessonId : null;
|
||||||
|
DeckId = deckId > 0 ? deckId : null;
|
||||||
|
|
||||||
|
if (DeckId is not null)
|
||||||
|
{
|
||||||
|
var deck = await store.GetDeckContextAsync(DeckId.Value, cancellationToken);
|
||||||
|
ScopeName = deck is null ? null : $"{deck.CourseName} / {deck.LessonName} / {deck.Name}";
|
||||||
|
}
|
||||||
|
else if (LessonId is not null)
|
||||||
|
{
|
||||||
|
var lesson = await store.GetLessonAsync(LessonId.Value, User.GetUserId(), cancellationToken);
|
||||||
|
ScopeName = lesson?.Name;
|
||||||
|
}
|
||||||
|
else if (CourseId is not null)
|
||||||
|
{
|
||||||
|
var course = await store.GetCourseAsync(CourseId.Value, User.GetUserId(), cancellationToken);
|
||||||
|
ScopeName = course?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
Results = await store.SearchCardsAsync(
|
||||||
|
Q,
|
||||||
|
CourseId,
|
||||||
|
LessonId,
|
||||||
|
DeckId,
|
||||||
|
Tag,
|
||||||
|
50,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>@ViewData["Title"] · flashsharp</title>
|
||||||
|
<link rel="stylesheet" href="~/vendor/bulma.min.css" asp-append-version="true" />
|
||||||
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
|
</head>
|
||||||
|
<body class="fs-auth-body">
|
||||||
|
<main class="fs-auth-main">
|
||||||
|
@RenderBody()
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@model BreadcrumbView
|
||||||
|
|
||||||
|
<nav class="breadcrumb fs-breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<ul>
|
||||||
|
@foreach (var crumb in Model.Crumbs)
|
||||||
|
{
|
||||||
|
if (crumb.Href is null)
|
||||||
|
{
|
||||||
|
<li class="is-active"><span aria-current="page" dir="auto">@crumb.Label</span></li>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<li><a href="@crumb.Href" dir="auto">@crumb.Label</a></li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if (Model.Chips.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="fs-chips">
|
||||||
|
@foreach (var chip in Model.Chips)
|
||||||
|
{
|
||||||
|
<span class="fs-chip">@chip</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
@{
|
||||||
|
var signedIn = User.Identity?.IsAuthenticated == true;
|
||||||
|
}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>@ViewData["Title"] · flashsharp</title>
|
||||||
|
<link rel="stylesheet" href="~/vendor/bulma.min.css" asp-append-version="true" />
|
||||||
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
|
<script src="~/vendor/htmx.min.js" asp-append-version="true" defer></script>
|
||||||
|
<script src="~/js/site.js" asp-append-version="true" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="fs-header">
|
||||||
|
<div class="fs-header-inner">
|
||||||
|
<a class="fs-brand" asp-page="/Index" aria-label="flashsharp home">
|
||||||
|
<span class="fs-brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
<span>flashsharp</span>
|
||||||
|
</a>
|
||||||
|
@if (signedIn)
|
||||||
|
{
|
||||||
|
<nav class="fs-nav" aria-label="Main">
|
||||||
|
<a asp-page="/Index">Courses</a>
|
||||||
|
<a asp-page="/Search">Search</a>
|
||||||
|
<a asp-page="/Stats">Stats</a>
|
||||||
|
<a asp-page="/Tokens">Tokens</a>
|
||||||
|
<a asp-page="/Users">Users</a>
|
||||||
|
</nav>
|
||||||
|
<form method="post" asp-page="/Logout" class="fs-signout">
|
||||||
|
<button class="button is-small is-ghost" type="submit" title="Sign out">@User.Identity?.Name</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="fs-main">
|
||||||
|
@if (TempData["Flash"] is string flash)
|
||||||
|
{
|
||||||
|
<div class="notification fs-flash" role="status">@flash</div>
|
||||||
|
}
|
||||||
|
@RenderBody()
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
@model StudyCardView
|
||||||
|
@inject MarkdownRenderer Markdown
|
||||||
|
@{
|
||||||
|
var card = Model.Card;
|
||||||
|
var front = card.Card.Front;
|
||||||
|
var back = card.Card.Back;
|
||||||
|
var extras = card.Card.Extras;
|
||||||
|
var exclude = string.Join(',', Model.Exclude);
|
||||||
|
var frontLabel = string.IsNullOrWhiteSpace(front?.Label) ? "Prompt" : front.Label;
|
||||||
|
var backLabel = string.IsNullOrWhiteSpace(back?.Label) ? "Answer" : back.Label;
|
||||||
|
var ratings = new[] { ReviewRating.Again, ReviewRating.Hard, ReviewRating.Good, ReviewRating.Easy };
|
||||||
|
}
|
||||||
|
|
||||||
|
<article id="study-card" class="fs-study-card" data-started="@Model.StartedAtMs" data-revealed="@(Model.Revealed ? "true" : "false")">
|
||||||
|
<div class="fs-study-card-top">
|
||||||
|
<span class="fs-side-label">@frontLabel</span>
|
||||||
|
<span class="fs-study-meta">
|
||||||
|
@if (card.IsNew)
|
||||||
|
{
|
||||||
|
<span>new card</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>rep @card.Repetitions</span>
|
||||||
|
if (card.Lapses > 0)
|
||||||
|
{
|
||||||
|
<span>· @card.Lapses lapses</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fs-side markdown" dir="auto">
|
||||||
|
@Html.Raw(Markdown.ToHtml(front?.Body ?? ""))
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Model.Revealed)
|
||||||
|
{
|
||||||
|
<button id="reveal-button"
|
||||||
|
class="button fs-button is-fullwidth fs-reveal"
|
||||||
|
hx-get="@Url.Page("/Study", "Reveal", new
|
||||||
|
{
|
||||||
|
cardId = card.Card.Id,
|
||||||
|
deckId = Model.Deck.Id,
|
||||||
|
mode = Model.Mode,
|
||||||
|
limit = Model.Limit,
|
||||||
|
exclude
|
||||||
|
})"
|
||||||
|
hx-vals='js:{started: Number(document.getElementById("study-card")?.dataset.started || Date.now())}'
|
||||||
|
hx-target="#study-card"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Show answer <span class="fs-key">space</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (back is not null)
|
||||||
|
{
|
||||||
|
<section class="fs-answer">
|
||||||
|
<span class="fs-side-label">@backLabel</span>
|
||||||
|
<div class="fs-side markdown" dir="auto">
|
||||||
|
@Html.Raw(Markdown.ToHtml(back.Body))
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (extras.Count > 0)
|
||||||
|
{
|
||||||
|
<details class="fs-extras">
|
||||||
|
<summary>More sides (@extras.Count)</summary>
|
||||||
|
@foreach (var extra in extras)
|
||||||
|
{
|
||||||
|
<div class="fs-extra">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(extra.Label))
|
||||||
|
{
|
||||||
|
<span class="fs-side-label">@extra.Label</span>
|
||||||
|
}
|
||||||
|
<div class="fs-side markdown" dir="auto">
|
||||||
|
@Html.Raw(Markdown.ToHtml(extra.Body))
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
asp-page-handler="Grade"
|
||||||
|
class="fs-grade-form"
|
||||||
|
hx-post="@Url.Page("/Study", "Grade")"
|
||||||
|
hx-target="#study-card"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="cardId" value="@card.Card.Id" />
|
||||||
|
<input type="hidden" name="deckId" value="@Model.Deck.Id" />
|
||||||
|
<input type="hidden" name="mode" value="@Model.Mode" />
|
||||||
|
<input type="hidden" name="limit" value="@Model.Limit" />
|
||||||
|
<input type="hidden" name="exclude" value="@exclude" />
|
||||||
|
<input type="hidden" name="durationMs" value="0" />
|
||||||
|
<div class="fs-grade-grid">
|
||||||
|
@for (var index = 0; index < ratings.Length; index++)
|
||||||
|
{
|
||||||
|
var rating = ratings[index];
|
||||||
|
var slug = rating.ToString().ToLowerInvariant();
|
||||||
|
<button class="button fs-grade fs-grade-@slug" type="submit" name="rating" value="@rating" data-shortcut="@(index + 1)">
|
||||||
|
<span class="fs-grade-name">@rating.ToLabel()</span>
|
||||||
|
<span class="fs-grade-key">@(index + 1)</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="fs-study-footer">
|
||||||
|
<span>@Model.Reviewed / @Model.Limit reviewed</span>
|
||||||
|
<span class="fs-study-due">
|
||||||
|
@if (card.DueAt is { } dueAt)
|
||||||
|
{
|
||||||
|
@Display.Due(dueAt)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@:not scheduled
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@model StudyDoneView
|
||||||
|
|
||||||
|
<article id="study-card" class="fs-study-card fs-study-done">
|
||||||
|
<p class="fs-eyebrow">@(Model.Reviewed >= Model.Limit ? "Session complete" : "Deck cleared")</p>
|
||||||
|
<h2 class="fs-done-title">
|
||||||
|
@(Model.Reviewed >= Model.Limit ? "That is enough for now." : "Nothing left in this queue.")
|
||||||
|
</h2>
|
||||||
|
<p class="fs-done-text">
|
||||||
|
@Model.Reviewed card@(Model.Reviewed == 1 ? "" : "s") reviewed from <strong dir="auto">@Model.Deck.Name</strong>.
|
||||||
|
</p>
|
||||||
|
<div class="fs-actions">
|
||||||
|
<a class="button fs-button" asp-page="/Decks/Details" asp-route-id="@Model.Deck.Id">Back to deck</a>
|
||||||
|
<a class="button is-light" asp-page="/Study" asp-route-deckId="@Model.Deck.Id" asp-route-mode="all">Study all cards</a>
|
||||||
|
<a class="button is-ghost" asp-page="/Study" asp-route-deckId="@Model.Deck.Id" asp-route-mode="due">Check due again</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
@page
|
||||||
|
@model StatsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Stats";
|
||||||
|
var breadcrumb = new BreadcrumbView([new Crumb("Courses", "/"), new Crumb("Stats", null)], ["last 30 days"]);
|
||||||
|
var max = Math.Max(1, Model.Daily.Count == 0 ? 1 : Model.Daily.Max(day => day.Total));
|
||||||
|
var ratingCounts = Model.Ratings.ToDictionary(row => row.Rating, row => row.Count);
|
||||||
|
var totalRatings = Math.Max(1, Model.Ratings.Sum(row => row.Count));
|
||||||
|
var ratings = new[] { ReviewRating.Again, ReviewRating.Hard, ReviewRating.Good, ReviewRating.Easy };
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">Stats</p>
|
||||||
|
<h1 class="fs-page-title">How it is going.</h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-stat-strip fs-stat-strip-wide" aria-label="Lifetime summary">
|
||||||
|
<div class="fs-stat"><span class="fs-stat-value">@Model.Summary.TotalReviews</span><span class="fs-stat-label">reviews</span></div>
|
||||||
|
<div class="fs-stat"><span class="fs-stat-value">@Model.Summary.CardsSeen</span><span class="fs-stat-label">cards seen</span></div>
|
||||||
|
<div class="fs-stat"><span class="fs-stat-value">@Model.Summary.StreakDays</span><span class="fs-stat-label">day streak</span></div>
|
||||||
|
<div class="fs-stat"><span class="fs-stat-value">@Model.Summary.Retention30d%</span><span class="fs-stat-label">retention</span></div>
|
||||||
|
<div class="fs-stat"><span class="fs-stat-value">@Display.Duration(Model.Summary.TimeSpentMs)</span><span class="fs-stat-label">time spent</span></div>
|
||||||
|
<div class="fs-stat"><span class="fs-stat-value">@Model.Summary.DueNow</span><span class="fs-stat-label">due now</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Reviews per day</h2>
|
||||||
|
<span class="fs-panel-note">stacked by grade</span>
|
||||||
|
</div>
|
||||||
|
<div class="fs-chart" role="img" aria-label="Reviews per day for the last 30 days">
|
||||||
|
@for (var index = 0; index < Model.Daily.Count; index++)
|
||||||
|
{
|
||||||
|
var day = Model.Daily[index];
|
||||||
|
<div class="fs-bar-wrap">
|
||||||
|
<div class="fs-bar" title="@day.Day: @day.Total reviews (@day.Again again, @day.Hard hard, @day.Good good, @day.Easy easy)">
|
||||||
|
<span class="fs-bar-seg fs-bar-easy" style="height:@((day.Easy * 100.0 / max).ToString("0.###", CultureInfo.InvariantCulture))%"></span>
|
||||||
|
<span class="fs-bar-seg fs-bar-good" style="height:@((day.Good * 100.0 / max).ToString("0.###", CultureInfo.InvariantCulture))%"></span>
|
||||||
|
<span class="fs-bar-seg fs-bar-hard" style="height:@((day.Hard * 100.0 / max).ToString("0.###", CultureInfo.InvariantCulture))%"></span>
|
||||||
|
<span class="fs-bar-seg fs-bar-again" style="height:@((day.Again * 100.0 / max).ToString("0.###", CultureInfo.InvariantCulture))%"></span>
|
||||||
|
</div>
|
||||||
|
@if (index % 5 == 0 || index == Model.Daily.Count - 1)
|
||||||
|
{
|
||||||
|
<span class="fs-bar-label">@day.Day.ToString("MM-dd")</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="fs-bar-label"> </span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="fs-legend">
|
||||||
|
<span class="fs-legend-item"><i class="fs-dot fs-bar-again"></i>Again</span>
|
||||||
|
<span class="fs-legend-item"><i class="fs-dot fs-bar-hard"></i>Hard</span>
|
||||||
|
<span class="fs-legend-item"><i class="fs-dot fs-bar-good"></i>Good</span>
|
||||||
|
<span class="fs-legend-item"><i class="fs-dot fs-bar-easy"></i>Easy</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="fs-two-col">
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Grades</h2>
|
||||||
|
<span class="fs-panel-note">last 30 days</span>
|
||||||
|
</div>
|
||||||
|
@foreach (var rating in ratings)
|
||||||
|
{
|
||||||
|
var count = ratingCounts.GetValueOrDefault(rating, 0);
|
||||||
|
<div class="fs-meter">
|
||||||
|
<span class="fs-meter-label">@rating.ToLabel()</span>
|
||||||
|
<span class="fs-meter-track">
|
||||||
|
<span class="fs-meter-fill fs-grade-@rating.ToString().ToLowerInvariant()" style="width:@((count * 100.0 / totalRatings).ToString("0.###", CultureInfo.InvariantCulture))%"></span>
|
||||||
|
</span>
|
||||||
|
<span class="fs-meter-value">@count</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Stubborn cards</h2>
|
||||||
|
<span class="fs-panel-note">most “again”</span>
|
||||||
|
</div>
|
||||||
|
@if (Model.HardCards.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="fs-panel-empty">Not enough reviews yet.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ol class="fs-hard-list">
|
||||||
|
@foreach (var card in Model.HardCards)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<a asp-page="/Search" asp-route-q="@card.Front">
|
||||||
|
<span class="fs-hard-front" dir="auto">@Display.Preview(card.Front, 90)</span>
|
||||||
|
<span class="fs-hard-meta">@card.Reviews reviews · @((int)Math.Round(card.AgainRate * 100))% again · @card.Lapses lapses</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Decks</h2>
|
||||||
|
<span class="fs-panel-note">last 30 days</span>
|
||||||
|
</div>
|
||||||
|
@if (Model.DeckStats.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="fs-panel-empty">No deck activity yet.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-fullwidth fs-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Deck</th><th class="is-numeric">Reviews</th><th class="is-numeric">Retention</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var deck in Model.DeckStats)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a asp-page="/Decks/Details" asp-route-id="@deck.DeckId">@deck.DeckName</a>
|
||||||
|
<span class="fs-table-sub">@deck.CourseName / @deck.LessonName</span>
|
||||||
|
</td>
|
||||||
|
<td class="is-numeric">@deck.Reviews</td>
|
||||||
|
<td class="is-numeric">@deck.Retention%</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
public sealed class StatsModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public StatsSummary Summary { get; private set; } = new(0, 0, 0, 0, 0, 0, 0);
|
||||||
|
|
||||||
|
public IReadOnlyList<DailyStat> Daily { get; private set; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<RatingStat> Ratings { get; private set; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<HardCardStat> HardCards { get; private set; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<DeckStat> DeckStats { get; private set; } = [];
|
||||||
|
|
||||||
|
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
Summary = await store.GetStatsSummaryAsync(userId, cancellationToken);
|
||||||
|
Daily = await store.GetDailyStatsAsync(userId, 30, cancellationToken);
|
||||||
|
Ratings = await store.GetRatingStatsAsync(userId, 30, cancellationToken);
|
||||||
|
HardCards = await store.GetHardCardsAsync(userId, 8, cancellationToken);
|
||||||
|
DeckStats = await store.GetDeckStatsAsync(userId, 30, 10, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@page
|
||||||
|
@model StudyModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = $"Study · {Model.Deck.Name}";
|
||||||
|
var modeLabel = Model.Mode switch
|
||||||
|
{
|
||||||
|
"all" => "All cards",
|
||||||
|
"new" => "New cards",
|
||||||
|
_ => "Due cards"
|
||||||
|
};
|
||||||
|
var breadcrumb = new BreadcrumbView(
|
||||||
|
[
|
||||||
|
new Crumb("Courses", "/"),
|
||||||
|
new Crumb(Model.Deck.CourseName, $"/courses/{Model.Deck.CourseId}"),
|
||||||
|
new Crumb(Model.Deck.LessonName, $"/lessons/{Model.Deck.LessonId}"),
|
||||||
|
new Crumb(Model.Deck.Name, $"/decks/{Model.Deck.Id}"),
|
||||||
|
new Crumb("Study", null)
|
||||||
|
],
|
||||||
|
[$"mode: {modeLabel}", $"limit: {Model.Limit}", $"{Model.DeckStats.DueCount} due"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<header class="fs-study-head">
|
||||||
|
<p class="fs-eyebrow">Study</p>
|
||||||
|
<h1 class="fs-page-title" dir="auto">@Model.Deck.Name</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="study-stage">
|
||||||
|
@if (Model.CardView is not null)
|
||||||
|
{
|
||||||
|
<partial name="_StudyCard" model="Model.CardView" />
|
||||||
|
}
|
||||||
|
else if (Model.Done is not null)
|
||||||
|
{
|
||||||
|
<partial name="_StudyDone" model="Model.Done" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Flashsharp.Ui;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
public sealed class StudyModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public DeckContext Deck { get; private set; } = null!;
|
||||||
|
|
||||||
|
public DeckSummary DeckStats { get; private set; } = null!;
|
||||||
|
|
||||||
|
public StudyCardView? CardView { get; private set; }
|
||||||
|
|
||||||
|
public StudyDoneView? Done { get; private set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public long DeckId { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string Mode { get; set; } = "due";
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public int Limit { get; set; } = 20;
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? Exclude { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var loaded = await LoadDeckAsync(DeckId, cancellationToken);
|
||||||
|
if (loaded is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
(Deck, DeckStats) = loaded.Value;
|
||||||
|
Mode = NormalizeMode(Mode);
|
||||||
|
Limit = Math.Clamp(Limit, 1, 100);
|
||||||
|
var exclude = ParseExclude(Exclude);
|
||||||
|
|
||||||
|
var card = await store.GetNextStudyCardAsync(
|
||||||
|
User.GetUserId(),
|
||||||
|
DeckId,
|
||||||
|
Mode,
|
||||||
|
exclude,
|
||||||
|
cancellationToken);
|
||||||
|
SetView(card, false, exclude);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetRevealAsync(
|
||||||
|
long cardId,
|
||||||
|
long deckId,
|
||||||
|
string mode,
|
||||||
|
int limit,
|
||||||
|
string? exclude,
|
||||||
|
long started,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var loaded = await LoadDeckAsync(deckId, cancellationToken);
|
||||||
|
if (loaded is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
(Deck, DeckStats) = loaded.Value;
|
||||||
|
Mode = NormalizeMode(mode);
|
||||||
|
Limit = Math.Clamp(limit, 1, 100);
|
||||||
|
var excludeIds = ParseExclude(exclude);
|
||||||
|
|
||||||
|
var card = await store.GetStudyCardAsync(User.GetUserId(), cardId, cancellationToken);
|
||||||
|
if (card is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
CardView = new StudyCardView(Deck, card, true, Mode, Limit, excludeIds, started);
|
||||||
|
return Partial("_StudyCard", CardView);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostGradeAsync(
|
||||||
|
long cardId,
|
||||||
|
long deckId,
|
||||||
|
string mode,
|
||||||
|
int limit,
|
||||||
|
string? exclude,
|
||||||
|
int durationMs,
|
||||||
|
ReviewRating rating,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var loaded = await LoadDeckAsync(deckId, cancellationToken);
|
||||||
|
if (loaded is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
(Deck, DeckStats) = loaded.Value;
|
||||||
|
Mode = NormalizeMode(mode);
|
||||||
|
Limit = Math.Clamp(limit, 1, 100);
|
||||||
|
var excludeIds = ParseExclude(exclude);
|
||||||
|
if (!excludeIds.Contains(cardId))
|
||||||
|
{
|
||||||
|
excludeIds.Add(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var studyCard = await store.GetStudyCardAsync(User.GetUserId(), cardId, cancellationToken);
|
||||||
|
if (studyCard is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.RecordReviewAsync(
|
||||||
|
User.GetUserId(),
|
||||||
|
cardId,
|
||||||
|
deckId,
|
||||||
|
rating,
|
||||||
|
Math.Clamp(durationMs, 0, 3_600_000),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (excludeIds.Count >= Limit)
|
||||||
|
{
|
||||||
|
Done = new StudyDoneView(Deck, Mode, Limit, excludeIds.Count);
|
||||||
|
CardView = null;
|
||||||
|
return Partial("_StudyDone", Done);
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = await store.GetNextStudyCardAsync(
|
||||||
|
User.GetUserId(),
|
||||||
|
deckId,
|
||||||
|
Mode,
|
||||||
|
excludeIds,
|
||||||
|
cancellationToken);
|
||||||
|
SetView(next, false, excludeIds);
|
||||||
|
return CardView is not null
|
||||||
|
? Partial("_StudyCard", CardView)
|
||||||
|
: Partial("_StudyDone", Done!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetView(StudyCard? card, bool revealed, List<long> exclude)
|
||||||
|
{
|
||||||
|
if (card is null)
|
||||||
|
{
|
||||||
|
CardView = null;
|
||||||
|
Done = new StudyDoneView(Deck, Mode, Limit, exclude.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Done = null;
|
||||||
|
CardView = new StudyCardView(
|
||||||
|
Deck,
|
||||||
|
card,
|
||||||
|
revealed,
|
||||||
|
Mode,
|
||||||
|
Limit,
|
||||||
|
exclude,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<(DeckContext Deck, DeckSummary Stats)?> LoadDeckAsync(long deckId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var deck = await store.GetDeckContextAsync(deckId, cancellationToken);
|
||||||
|
if (deck is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var stats = await store.GetDeckAsync(deckId, userId, cancellationToken);
|
||||||
|
return stats is null ? null : (deck, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string NormalizeMode(string? mode) => mode is "all" or "new" ? mode : "due";
|
||||||
|
|
||||||
|
static List<long> ParseExclude(string? exclude)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(exclude))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return exclude
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(value => long.TryParse(value, out var id) ? id : 0)
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.Take(500)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@page
|
||||||
|
@model TokensModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Tokens";
|
||||||
|
var breadcrumb = new BreadcrumbView([new Crumb("Courses", "/"), new Crumb("Tokens", null)], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">Access</p>
|
||||||
|
<h1 class="fs-page-title">Agent tokens.</h1>
|
||||||
|
<p class="fs-page-description">
|
||||||
|
The MCP server lives at <code>@Model.McpUrl</code>. Create a token here, then configure your
|
||||||
|
agent with an <code>Authorization: Bearer …</code> header. Tokens are shown once and stored hashed.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (Model.NewToken is not null)
|
||||||
|
{
|
||||||
|
<section class="fs-token-reveal">
|
||||||
|
<p class="fs-eyebrow">Copy it now</p>
|
||||||
|
<code class="fs-token-value">@Model.NewToken</code>
|
||||||
|
<p class="fs-token-hint">This token will not be shown again.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Create a token</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Create" class="fs-inline-form">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="label" value="" placeholder="Label, for example “Claude Code”" maxlength="120" />
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button fs-button" type="submit">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Your API tokens</h2>
|
||||||
|
<span class="fs-panel-note">@Model.Tokens.Count active</span>
|
||||||
|
</div>
|
||||||
|
@if (Model.Tokens.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="fs-panel-empty">No agent tokens yet.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="fs-token-list">
|
||||||
|
@foreach (var token in Model.Tokens)
|
||||||
|
{
|
||||||
|
<div class="fs-token-row">
|
||||||
|
<div>
|
||||||
|
<strong>@token.Label</strong>
|
||||||
|
<span class="fs-token-meta">created @Display.Ago(token.CreatedAt)@(token.LastUsedAt is { } used ? $" · last used {Display.Ago(used)}" : " · never used")</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Revoke">
|
||||||
|
<input type="hidden" name="tokenId" value="@token.Id" />
|
||||||
|
<button class="button is-small is-light" type="submit">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-panel fs-code-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">MCP client config</h2>
|
||||||
|
</div>
|
||||||
|
<pre><code>{
|
||||||
|
"mcpServers": {
|
||||||
|
"flashsharp": {
|
||||||
|
"url": "@Model.McpUrl",
|
||||||
|
"headers": { "Authorization": "Bearer fsh_…" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
public sealed class TokensModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public IReadOnlyList<AuthToken> Tokens { get; private set; } = [];
|
||||||
|
|
||||||
|
public string? NewToken { get; private set; }
|
||||||
|
|
||||||
|
public string McpUrl => $"{Request.Scheme}://{Request.Host}/mcp";
|
||||||
|
|
||||||
|
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Tokens = await store.ListTokensAsync(User.GetUserId(), Store.TokenKinds.Api, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostCreateAsync(string? label, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var clean = (label ?? "").Trim();
|
||||||
|
if (clean.Length == 0)
|
||||||
|
{
|
||||||
|
clean = "Agent token";
|
||||||
|
}
|
||||||
|
|
||||||
|
var (_, rawToken) = await store.CreateTokenAsync(
|
||||||
|
User.GetUserId(),
|
||||||
|
clean,
|
||||||
|
Store.TokenKinds.Api,
|
||||||
|
lifetime: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
NewToken = rawToken;
|
||||||
|
TempData["Flash"] = "Token created. Copy it now — it will not be shown again.";
|
||||||
|
Tokens = await store.ListTokensAsync(User.GetUserId(), Store.TokenKinds.Api, cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostRevokeAsync(long tokenId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var revoked = await store.RevokeTokenAsync(User.GetUserId(), tokenId, cancellationToken);
|
||||||
|
TempData["Flash"] = revoked ? "Token revoked." : "Token not found.";
|
||||||
|
return RedirectToPage("/Tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@page
|
||||||
|
@model UsersModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Users";
|
||||||
|
var breadcrumb = new BreadcrumbView([new Crumb("Courses", "/"), new Crumb("Users", null)], ["all users share the library"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Breadcrumb" model="breadcrumb" />
|
||||||
|
|
||||||
|
<section class="fs-page-head">
|
||||||
|
<p class="fs-eyebrow">People</p>
|
||||||
|
<h1 class="fs-page-title">Users.</h1>
|
||||||
|
<p class="fs-page-description">
|
||||||
|
Everyone shares the same courses, lessons, decks and cards. Reviews and schedules are per user.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (Model.Error is not null)
|
||||||
|
{
|
||||||
|
<div class="notification is-danger is-light" role="alert">@Model.Error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Add a user</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Create" class="fs-user-form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Username</label>
|
||||||
|
<input class="input" name="username" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Password</label>
|
||||||
|
<input class="input" name="password" type="password" autocomplete="new-password" />
|
||||||
|
<p class="help">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
<button class="button fs-button" type="submit">Create user</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Change my password</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="ChangePassword" class="fs-user-form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Current password</label>
|
||||||
|
<input class="input" name="currentPassword" type="password" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">New password</label>
|
||||||
|
<input class="input" name="newPassword" type="password" autocomplete="new-password" />
|
||||||
|
<p class="help">At least 8 characters. Other sessions will be signed out.</p>
|
||||||
|
</div>
|
||||||
|
<button class="button fs-button" type="submit">Change password</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fs-panel">
|
||||||
|
<div class="fs-panel-head">
|
||||||
|
<h2 class="fs-panel-title">Active users</h2>
|
||||||
|
<span class="fs-panel-note">@Model.Users.Count</span>
|
||||||
|
</div>
|
||||||
|
<div class="fs-token-list">
|
||||||
|
@foreach (var user in Model.Users)
|
||||||
|
{
|
||||||
|
<div class="fs-token-row">
|
||||||
|
<div>
|
||||||
|
<strong>@user.Username</strong>
|
||||||
|
@if (user.Id == Model.CurrentUserId)
|
||||||
|
{
|
||||||
|
<span class="tag is-light ml-2">you</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (user.Id != Model.CurrentUserId)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="Deactivate">
|
||||||
|
<input type="hidden" name="userId" value="@user.Id" />
|
||||||
|
<button class="button is-small is-light" type="submit">Deactivate</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace Flashsharp.Pages;
|
||||||
|
|
||||||
|
public sealed class UsersModel(Store store) : PageModel
|
||||||
|
{
|
||||||
|
public IReadOnlyList<User> Users { get; private set; } = [];
|
||||||
|
|
||||||
|
public long CurrentUserId { get; private set; }
|
||||||
|
|
||||||
|
public string? Error { get; private set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CurrentUserId = User.GetUserId();
|
||||||
|
await LoadAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostCreateAsync(
|
||||||
|
string? username,
|
||||||
|
string? password,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CurrentUserId = User.GetUserId();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var created = await store.CreateUserAsync(username ?? "", password ?? "", cancellationToken);
|
||||||
|
TempData["Flash"] = $"Created user {created.Username}.";
|
||||||
|
return RedirectToPage("/Users");
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is ArgumentException or Npgsql.PostgresException)
|
||||||
|
{
|
||||||
|
Error = exception is Npgsql.PostgresException { SqlState: "23505" }
|
||||||
|
? "That username is already taken."
|
||||||
|
: exception.Message;
|
||||||
|
await LoadAsync(cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostChangePasswordAsync(
|
||||||
|
string? currentPassword,
|
||||||
|
string? newPassword,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CurrentUserId = User.GetUserId();
|
||||||
|
var username = User.Identity?.Name;
|
||||||
|
if (username is null || string.IsNullOrEmpty(currentPassword))
|
||||||
|
{
|
||||||
|
Error = "Enter your current password.";
|
||||||
|
await LoadAsync(cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await store.VerifyPasswordAsync(username, currentPassword, cancellationToken))
|
||||||
|
{
|
||||||
|
Error = "Current password is incorrect.";
|
||||||
|
await LoadAsync(cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await store.ChangePasswordAsync(CurrentUserId, newPassword ?? "", cancellationToken);
|
||||||
|
await store.RevokeOtherBrowserTokensAsync(CurrentUserId, User.GetTokenId(), cancellationToken);
|
||||||
|
TempData["Flash"] = "Password changed. Other sessions were signed out.";
|
||||||
|
return RedirectToPage("/Users");
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
Error = exception.Message;
|
||||||
|
await LoadAsync(cancellationToken);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeactivateAsync(long userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CurrentUserId = User.GetUserId();
|
||||||
|
if (userId == CurrentUserId)
|
||||||
|
{
|
||||||
|
TempData["Flash"] = "You cannot deactivate your own account.";
|
||||||
|
return RedirectToPage("/Users");
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.DeactivateUserAsync(userId, cancellationToken);
|
||||||
|
TempData["Flash"] = "User deactivated.";
|
||||||
|
return RedirectToPage("/Users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Users = await store.ListUsersAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@using Flashsharp
|
||||||
|
@using Flashsharp.Domain
|
||||||
|
@using Flashsharp.Ui
|
||||||
|
@using System.Globalization
|
||||||
|
@namespace Flashsharp.Pages
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Flashsharp.Auth;
|
||||||
|
using Flashsharp.Configuration;
|
||||||
|
using Flashsharp.Data;
|
||||||
|
using Flashsharp.Ui;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using ModelContextProtocol.AspNetCore;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||||
|
|
||||||
|
var options = AppOptions.FromEnvironment();
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||||
|
{
|
||||||
|
Args = args,
|
||||||
|
ContentRootPath = Directory.Exists(Path.Combine(AppContext.BaseDirectory, "wwwroot"))
|
||||||
|
? AppContext.BaseDirectory
|
||||||
|
: Directory.GetCurrentDirectory()
|
||||||
|
});
|
||||||
|
builder.WebHost.UseUrls($"http://{options.Host}:{options.Port}");
|
||||||
|
builder.Services.AddSingleton(options);
|
||||||
|
|
||||||
|
var dataDirectory = new DirectoryInfo(options.DataDirectory);
|
||||||
|
Directory.CreateDirectory(dataDirectory.FullName);
|
||||||
|
builder.Services
|
||||||
|
.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(dataDirectory)
|
||||||
|
.SetApplicationName("flashsharp");
|
||||||
|
builder.Services.AddSingleton(Db.Build(options.DatabaseUrl));
|
||||||
|
builder.Services.AddSingleton<Db>();
|
||||||
|
builder.Services.AddSingleton<Store>();
|
||||||
|
builder.Services.AddSingleton<MarkdownRenderer>();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddRazorPages();
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddAuthentication(TokenAuthenticationHandler.SchemeName)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, TokenAuthenticationHandler>(
|
||||||
|
TokenAuthenticationHandler.SchemeName,
|
||||||
|
_ => { });
|
||||||
|
|
||||||
|
builder.Services.AddAuthorizationBuilder()
|
||||||
|
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddMcpServer()
|
||||||
|
.WithHttpTransport(transport => transport.SessionMode = HttpServerSessionMode.Stateless)
|
||||||
|
.WithToolsFromAssembly();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||||
|
{
|
||||||
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseExceptionHandler("/Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
context.Response.Headers["Referrer-Policy"] = "same-origin";
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapRazorPages();
|
||||||
|
app.MapMcp("/mcp").RequireAuthorization();
|
||||||
|
app.MapGet("/healthz", async (Db db, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
await using var connection = await db.OpenAsync(cancellationToken);
|
||||||
|
await connection.ExecuteScalarAsync<int>("select 1");
|
||||||
|
return Results.Text("ok");
|
||||||
|
}).AllowAnonymous();
|
||||||
|
|
||||||
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("Flashsharp.Startup");
|
||||||
|
var db = services.GetRequiredService<Db>();
|
||||||
|
var store = services.GetRequiredService<Store>();
|
||||||
|
|
||||||
|
await Migrator.RunAsync(db, logger);
|
||||||
|
await store.SeedAsync(options.SeedUsername, options.SeedPassword, options.SeedPasswordHash);
|
||||||
|
logger.LogInformation("flashsharp ready on http://{Host}:{Port}", options.Host, options.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5033",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7256;http://localhost:5033",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Flashsharp.Domain;
|
||||||
|
|
||||||
|
namespace Flashsharp.Scheduling;
|
||||||
|
|
||||||
|
public static class Scheduler
|
||||||
|
{
|
||||||
|
const int AgainSeconds = 10 * 60;
|
||||||
|
const int HardSeconds = 12 * 60 * 60;
|
||||||
|
const int DaySeconds = 24 * 60 * 60;
|
||||||
|
const int MaxIntervalSeconds = 10 * 365 * DaySeconds;
|
||||||
|
|
||||||
|
public static ScheduleState Next(ScheduleState? current, ReviewRating rating, DateTime now)
|
||||||
|
{
|
||||||
|
var ease = current?.Ease ?? 2.5;
|
||||||
|
var repetitions = current?.Repetitions ?? 0;
|
||||||
|
var lapses = current?.Lapses ?? 0;
|
||||||
|
var interval = current?.IntervalSeconds ?? 0;
|
||||||
|
|
||||||
|
switch (rating)
|
||||||
|
{
|
||||||
|
case ReviewRating.Again:
|
||||||
|
ease = Math.Max(1.3, ease - 0.2);
|
||||||
|
repetitions = 0;
|
||||||
|
lapses++;
|
||||||
|
interval = AgainSeconds;
|
||||||
|
break;
|
||||||
|
case ReviewRating.Hard:
|
||||||
|
ease = Math.Max(1.3, ease - 0.15);
|
||||||
|
interval = repetitions == 0 ? HardSeconds : Math.Max(HardSeconds, (int)(interval * 1.2));
|
||||||
|
repetitions++;
|
||||||
|
break;
|
||||||
|
case ReviewRating.Good:
|
||||||
|
interval = repetitions switch
|
||||||
|
{
|
||||||
|
0 => DaySeconds,
|
||||||
|
1 => 3 * DaySeconds,
|
||||||
|
_ => (int)(interval * ease)
|
||||||
|
};
|
||||||
|
repetitions++;
|
||||||
|
break;
|
||||||
|
case ReviewRating.Easy:
|
||||||
|
ease = Math.Min(3.5, ease + 0.15);
|
||||||
|
interval = repetitions switch
|
||||||
|
{
|
||||||
|
0 => 3 * DaySeconds,
|
||||||
|
1 => 7 * DaySeconds,
|
||||||
|
_ => (int)(interval * ease * 1.3)
|
||||||
|
};
|
||||||
|
repetitions++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(rating));
|
||||||
|
}
|
||||||
|
|
||||||
|
interval = Math.Clamp(interval, AgainSeconds, MaxIntervalSeconds);
|
||||||
|
return new ScheduleState(now.AddSeconds(interval), interval, ease, repetitions, lapses);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
namespace Flashsharp.Ui;
|
||||||
|
|
||||||
|
public static class Display
|
||||||
|
{
|
||||||
|
public static string Ago(DateTime when)
|
||||||
|
{
|
||||||
|
var delta = DateTime.UtcNow - when;
|
||||||
|
if (delta < TimeSpan.FromMinutes(1))
|
||||||
|
{
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
if (delta < TimeSpan.FromHours(1))
|
||||||
|
{
|
||||||
|
return $"{(int)delta.TotalMinutes}m ago";
|
||||||
|
}
|
||||||
|
if (delta < TimeSpan.FromDays(1))
|
||||||
|
{
|
||||||
|
return $"{(int)delta.TotalHours}h ago";
|
||||||
|
}
|
||||||
|
if (delta < TimeSpan.FromDays(30))
|
||||||
|
{
|
||||||
|
return $"{(int)delta.TotalDays}d ago";
|
||||||
|
}
|
||||||
|
return when.ToLocalTime().ToString("yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Due(DateTime dueAt)
|
||||||
|
{
|
||||||
|
var delta = dueAt - DateTime.UtcNow;
|
||||||
|
if (delta <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return "due now";
|
||||||
|
}
|
||||||
|
if (delta < TimeSpan.FromHours(1))
|
||||||
|
{
|
||||||
|
return $"in {(int)delta.TotalMinutes}m";
|
||||||
|
}
|
||||||
|
if (delta < TimeSpan.FromDays(1))
|
||||||
|
{
|
||||||
|
return $"in {(int)delta.TotalHours}h";
|
||||||
|
}
|
||||||
|
return $"in {(int)delta.TotalDays}d";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Duration(long milliseconds)
|
||||||
|
{
|
||||||
|
if (milliseconds < 60_000)
|
||||||
|
{
|
||||||
|
return "<1 min";
|
||||||
|
}
|
||||||
|
if (milliseconds < 3_600_000)
|
||||||
|
{
|
||||||
|
return $"{milliseconds / 60_000} min";
|
||||||
|
}
|
||||||
|
return $"{milliseconds / 3_600_000}h {(milliseconds % 3_600_000) / 60_000}m";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Preview(string text, int max = 180)
|
||||||
|
{
|
||||||
|
var flat = string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
return flat.Length <= max ? flat : flat[..max].TrimEnd() + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Ganss.Xss;
|
||||||
|
using Markdig;
|
||||||
|
|
||||||
|
namespace Flashsharp.Ui;
|
||||||
|
|
||||||
|
public sealed class MarkdownRenderer
|
||||||
|
{
|
||||||
|
readonly MarkdownPipeline pipeline = new MarkdownPipelineBuilder()
|
||||||
|
.DisableHtml()
|
||||||
|
.UseAutoLinks()
|
||||||
|
.UseSoftlineBreakAsHardlineBreak()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
readonly HtmlSanitizer sanitizer = new();
|
||||||
|
|
||||||
|
public string ToHtml(string markdown) => sanitizer.Sanitize(Markdown.ToHtml(markdown ?? "", pipeline));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Flashsharp.Domain;
|
||||||
|
|
||||||
|
namespace Flashsharp.Ui;
|
||||||
|
|
||||||
|
public sealed record StudyCardView(
|
||||||
|
DeckContext Deck,
|
||||||
|
StudyCard Card,
|
||||||
|
bool Revealed,
|
||||||
|
string Mode,
|
||||||
|
int Limit,
|
||||||
|
IReadOnlyList<long> Exclude,
|
||||||
|
long StartedAtMs)
|
||||||
|
{
|
||||||
|
public int Reviewed => Exclude.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StudyDoneView(
|
||||||
|
DeckContext Deck,
|
||||||
|
string Mode,
|
||||||
|
int Limit,
|
||||||
|
int Reviewed);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"DetailedErrors": true,
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
(function () {
|
||||||
|
function studyCard() {
|
||||||
|
return document.getElementById("study-card");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampStudyCard() {
|
||||||
|
const card = studyCard();
|
||||||
|
if (card && card.dataset.revealed !== "true") {
|
||||||
|
card.dataset.started = Date.now().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", stampStudyCard);
|
||||||
|
document.body.addEventListener("htmx:afterSwap", stampStudyCard);
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (event) {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLElement && target.closest("input, textarea, select, [contenteditable='true']")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!studyCard()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === " ") {
|
||||||
|
const reveal = document.getElementById("reveal-button");
|
||||||
|
if (reveal) {
|
||||||
|
event.preventDefault();
|
||||||
|
reveal.click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["1", "2", "3", "4"].includes(event.key)) {
|
||||||
|
const grade = document.querySelector('[data-shortcut="' + event.key + '"]');
|
||||||
|
if (grade) {
|
||||||
|
event.preventDefault();
|
||||||
|
grade.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener("htmx:configRequest", function (event) {
|
||||||
|
const form = event.detail.elt && event.detail.elt.closest
|
||||||
|
? event.detail.elt.closest("form")
|
||||||
|
: null;
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = studyCard();
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = Number(card.getAttribute("data-started") || Date.now());
|
||||||
|
event.detail.parameters.durationMs = Math.max(0, Date.now() - started);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener("htmx:responseError", function () {
|
||||||
|
const card = studyCard();
|
||||||
|
if (card) {
|
||||||
|
card.classList.add("fs-study-error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
/*! bulma.io v1.0.4 | MIT License | github.com/jgthms/bulma */:root{--bulma-control-radius:var(--bulma-radius);--bulma-control-radius-small:var(--bulma-radius-small);--bulma-control-border-width:1px;--bulma-control-height:2.5em;--bulma-control-line-height:1.5;--bulma-control-padding-vertical:calc(0.5em - 1px);--bulma-control-padding-horizontal:calc(0.75em - 1px);--bulma-control-size:var(--bulma-size-normal);--bulma-control-focus-shadow-l:50%;--bulma-scheme-h:221;--bulma-scheme-s:14%;--bulma-light-l:90%;--bulma-light-invert-l:20%;--bulma-dark-l:20%;--bulma-dark-invert-l:90%;--bulma-soft-l:90%;--bulma-bold-l:20%;--bulma-soft-invert-l:20%;--bulma-bold-invert-l:90%;--bulma-hover-background-l-delta:-5%;--bulma-active-background-l-delta:-10%;--bulma-hover-border-l-delta:-10%;--bulma-active-border-l-delta:-20%;--bulma-hover-color-l-delta:-5%;--bulma-active-color-l-delta:-10%;--bulma-hover-shadow-a-delta:-0.05;--bulma-active-shadow-a-delta:-0.1;--bulma-scheme-brightness:light;--bulma-scheme-main-l:100%;--bulma-scheme-main-bis-l:98%;--bulma-scheme-main-ter-l:96%;--bulma-background-l:96%;--bulma-border-weak-l:93%;--bulma-border-l:86%;--bulma-text-weak-l:48%;--bulma-text-strong-l:21%;--bulma-text-title-l:14%;--bulma-scheme-invert-ter-l:14%;--bulma-scheme-invert-bis-l:7%;--bulma-scheme-invert-l:4%;--bulma-family-primary:Inter,SF Pro,Segoe UI,Roboto,Oxygen,Ubuntu,Helvetica Neue,Helvetica,Arial,sans-serif;--bulma-family-secondary:Inter,SF Pro,Segoe UI,Roboto,Oxygen,Ubuntu,Helvetica Neue,Helvetica,Arial,sans-serif;--bulma-family-code:Inconsolata,Hack,SF Mono,Roboto Mono,Source Code Pro,Ubuntu Mono,monospace;--bulma-size-small:0.75rem;--bulma-size-normal:1rem;--bulma-size-medium:1.25rem;--bulma-size-large:1.5rem;--bulma-weight-light:300;--bulma-weight-normal:400;--bulma-weight-medium:500;--bulma-weight-semibold:600;--bulma-weight-bold:700;--bulma-weight-extrabold:800;--bulma-block-spacing:1.5rem;--bulma-duration:294ms;--bulma-easing:ease-out;--bulma-radius-small:0.25rem;--bulma-radius:0.375rem;--bulma-radius-medium:0.5em;--bulma-radius-large:0.75rem;--bulma-radius-rounded:9999px;--bulma-speed:86ms;--bulma-arrow-color:var(--bulma-link);--bulma-loading-color:var(--bulma-border);--bulma-burger-h:var(--bulma-link-h);--bulma-burger-s:var(--bulma-link-s);--bulma-burger-l:var(--bulma-link-l);--bulma-burger-border-radius:0.5em;--bulma-burger-gap:5px;--bulma-burger-item-height:2px;--bulma-burger-item-width:20px;--bulma-white:hsla(var(--bulma-white-h),var(--bulma-white-s),var(--bulma-white-l),1);--bulma-white-base:hsla(var(--bulma-white-h),var(--bulma-white-s),var(--bulma-white-l),1);--bulma-white-rgb:255,255,255;--bulma-white-h:221deg;--bulma-white-s:14%;--bulma-white-l:100%;--bulma-white-invert-l:4%;--bulma-white-invert:#090a0c;--bulma-white-on-scheme-l:35%;--bulma-white-on-scheme:hsla(var(--bulma-white-h),var(--bulma-white-s),var(--bulma-white-on-scheme-l),1);--bulma-black:hsla(var(--bulma-black-h),var(--bulma-black-s),var(--bulma-black-l),1);--bulma-black-base:hsla(var(--bulma-black-h),var(--bulma-black-s),var(--bulma-black-l),1);--bulma-black-rgb:8.772,9.6764,11.628;--bulma-black-h:221deg;--bulma-black-s:14%;--bulma-black-l:4%;--bulma-black-invert-l:100%;--bulma-black-invert:#fff;--bulma-black-on-scheme-l:4%;--bulma-black-on-scheme:hsla(var(--bulma-black-h),var(--bulma-black-s),var(--bulma-black-on-scheme-l),1);--bulma-light:hsla(var(--bulma-light-h),var(--bulma-light-s),var(--bulma-light-l),1);--bulma-light-base:hsla(var(--bulma-light-h),var(--bulma-light-s),var(--bulma-light-l),1);--bulma-light-rgb:243.372,244.2764,246.228;--bulma-light-h:221deg;--bulma-light-s:14%;--bulma-light-l:96%;--bulma-light-invert-l:21%;--bulma-light-invert:#2e333d;--bulma-light-on-scheme-l:36%;--bulma-light-on-scheme:hsla(var(--bulma-light-h),var(--bulma-light-s),var(--bulma-light-on-scheme-l),1);--bulma-dark:hsla(var(--bulma-dark-h),var(--bulma-dark-s),var(--bulma-dark-l),1);--bulma-dark-base:hsla(var(--bulma-dark-h),var(--bulma-dark-s),var(--bulma-dark-l),1);--bulma-dark-rgb:46.053,50.8011,61.047;--bulma-dark-h:221deg;--bulma-dark-s:14%;--bulma-dark-l:21%;--bulma-dark-invert-l:96%;--bulma-dark-invert:#f3f4f6;--bulma-dark-on-scheme-l:21%;--bulma-dark-on-scheme:hsla(var(--bulma-dark-h),var(--bulma-dark-s),var(--bulma-dark-on-scheme-l),1);--bulma-text:hsla(var(--bulma-text-h),var(--bulma-text-s),var(--bulma-text-l),1);--bulma-text-base:hsla(var(--bulma-text-h),var(--bulma-text-s),var(--bulma-text-l),1);--bulma-text-rgb:63.597,70.1539,84.303;--bulma-text-h:221deg;--bulma-text-s:14%;--bulma-text-l:29%;--bulma-text-00-l:0%;--bulma-text-05-l:4%;--bulma-text-10-l:9%;--bulma-text-15-l:14%;--bulma-text-20-l:19%;--bulma-text-25-l:24%;--bulma-text-30-l:29%;--bulma-text-35-l:34%;--bulma-text-40-l:39%;--bulma-text-45-l:44%;--bulma-text-50-l:49%;--bulma-text-55-l:54%;--bulma-text-60-l:59%;--bulma-text-65-l:64%;--bulma-text-70-l:69%;--bulma-text-75-l:74%;--bulma-text-80-l:79%;--bulma-text-85-l:84%;--bulma-text-90-l:89%;--bulma-text-95-l:94%;--bulma-text-100-l:99%Line truncated
|
||||||
|
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */blockquote,body,dd,dl,dt,fieldset,figure,h1,h2,h3,h4,h5,h6,hr,html,iframe,legend,li,ol,p,pre,textarea,ul{margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:400}ul{list-style:none}button,input,select,textarea{margin:0}html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}img,video{height:auto;max-width:100%}iframe{border:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}td:not([align]),th:not([align]){text-align:inherit}:root{--bulma-body-background-color:var(--bulma-scheme-main);--bulma-body-size:1em;--bulma-body-min-width:300px;--bulma-body-rendering:optimizeLegibility;--bulma-body-family:var(--bulma-family-primary);--bulma-body-overflow-x:hidden;--bulma-body-overflow-y:scroll;--bulma-body-color:var(--bulma-text);--bulma-body-font-size:1em;--bulma-body-weight:var(--bulma-weight-normal);--bulma-body-line-height:1.5;--bulma-code-family:var(--bulma-family-code);--bulma-code-padding:0.25em 0.5em 0.25em;--bulma-code-weight:normal;--bulma-code-size:0.875em;--bulma-small-font-size:0.875em;--bulma-hr-background-color:var(--bulma-background);--bulma-hr-height:2px;--bulma-hr-margin:1.5rem 0;--bulma-strong-color:var(--bulma-text-strong);--bulma-strong-weight:var(--bulma-weight-semibold);--bulma-pre-font-size:0.875em;--bulma-pre-padding:1.25rem 1.5rem;--bulma-pre-code-font-size:1em}html{background-color:var(--bulma-body-background-color);font-size:var(--bulma-body-size);-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:var(--bulma-body-min-width);overflow-x:var(--bulma-body-overflow-x);overflow-y:var(--bulma-body-overflow-y);text-rendering:var(--bulma-body-rendering);text-size-adjust:100%}article,aside,figure,footer,header,hgroup,section{display:block}body,button,input,optgroup,select,textarea{font-family:var(--bulma-body-family)}code,pre{-moz-osx-font-smoothing:auto;-webkit-font-smoothing:auto;font-family:var(--bulma-code-family)}body{color:var(--bulma-body-color);font-size:var(--bulma-body-font-size);font-weight:var(--bulma-body-weight);line-height:var(--bulma-body-line-height)}a,button{cursor:pointer}a:focus-visible,button:focus-visible{outline-color:hsl(var(--bulma-focus-h),var(--bulma-focus-s),var(--bulma-focus-l));outline-offset:var(--bulma-focus-offset);outline-style:var(--bulma-focus-style);outline-width:var(--bulma-focus-width)}a:focus-visible:active,button:focus-visible:active{outline-width:1px}a:active,button:active{outline-width:1px}a{color:var(--bulma-link-text);cursor:pointer;text-decoration:none;transition-duration:var(--bulma-duration);transition-property:background-color,border-color,color}a strong{color:currentColor}button{appearance:none;background:none;border:none;color:inherit;font-family:inherit;font-size:1em;margin:0;padding:0;transition-duration:var(--bulma-duration);transition-property:background-color,border-color,color}code{background-color:var(--bulma-code-background);border-radius:.5em;color:var(--bulma-code);font-size:var(--bulma-code-size);font-weight:var(--bulma-code-weight);padding:var(--bulma-code-padding)}hr{background-color:var(--bulma-hr-background-color);border:none;display:block;height:var(--bulma-hr-height);margin:var(--bulma-hr-margin)}img{height:auto;max-width:100%}input[type=checkbox],input[type=radio]{vertical-align:baseline}small{font-size:var(--bulma-small-font-size)}span{font-style:inherit;font-weight:inherit}strong{color:var(--bulma-strong-color);font-weight:var(--bulma-strong-weight)}svg{height:auto;width:auto}fieldset{border:none}pre{-webkit-overflow-scrolling:touch;background-color:var(--bulma-pre-background);color:var(--bulma-pre);font-size:var(--bulma-pre-font-size);overflow-x:auto;padding:var(--bulma-pre-padding);white-space:pre;word-wrap:normal}pre code{background-color:transparent;color:currentColor;font-size:var(--bulma-pre-code-font-size);padding:0}table td,table th{vertical-align:top}table td:not([align]),table th:not([align]){text-align:inherit}table th{color:var(--bulma-text-strong)}@keyframes spinAround{0%{transform:rotate(0deg)}to{transform:rotate(359deg)}}@keyframes pulsate{50%{opacity:.5}}.navbar-link:not(.is-arrowless):after,.select:not(.is-multiple):not(.is-loading):after{border:.125em solid var(--bulma-arrow-color);border-right:0;border-top:0;content:" ";display:block;height:.625em;margin-top:-.4375em;pointer-events:none;position:absolute;top:50%;transform:rotate(-45deg);transform-origin:center;transition-duration:var(--bulma-duration);transition-property:border-color;width:.625em}.block:not(:last-child),.box:not(:last-child),.breadcrumb:not(:last-child),.buttons:not(:last-child),.card:not(:last-child),.content:not(:last-child),.field:not(:last-child),.file:not(:last-child),.fixed-grid:not(:last-child),.grid:not(:last-child),.level:not(:last-child),.media:not(:last-child),.message:not(:last-child),.notification:not(:last-child),.pagination:not(:last-child),.progress:not(:last-child),.skeleton-block:not(:last-chLine truncated
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.8"};Q.onLoad=V;Q.process=Ft;Q.on=xe;Q.off=be;Q.trigger=ae;Q.ajax=Ln;Q.find=f;Q.findAll=x;Q.closest=g;Q.remove=_;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=ze;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=j;Q.logNone=$;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:X,findThisElement:Se,filterValues:yn,swap:ze,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:q,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Ee,makeFragment:D,mergeObjects:le,makeSettleInfo:Sn,oobSwap:Te,querySelectorExt:ue,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function y(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function q(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;q(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function A(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function N(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function D(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=A(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);N(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);N(r,i.body);r.title=i.title}else{const i=L('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.protoLine truncated
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Flashsharp\Flashsharp.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Flashsharp.Ui;
|
||||||
|
|
||||||
|
namespace Flashsharp.Tests;
|
||||||
|
|
||||||
|
public sealed class MarkdownRendererTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Keeps_non_latin_text()
|
||||||
|
{
|
||||||
|
var html = new MarkdownRenderer().ToHtml("سلام دنیا");
|
||||||
|
|
||||||
|
Assert.Contains("سلام دنیا", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Removes_javascript_links()
|
||||||
|
{
|
||||||
|
var html = new MarkdownRenderer().ToHtml("[click](javascript:alert(1))");
|
||||||
|
|
||||||
|
Assert.DoesNotContain("javascript:", html, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Removes_raw_html()
|
||||||
|
{
|
||||||
|
var html = new MarkdownRenderer().ToHtml("<script>alert(1)</script>");
|
||||||
|
|
||||||
|
Assert.DoesNotContain("<script", html, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renders_markdown()
|
||||||
|
{
|
||||||
|
var html = new MarkdownRenderer().ToHtml("**bold**");
|
||||||
|
|
||||||
|
Assert.Contains("<strong>bold</strong>", html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Flashsharp.Domain;
|
||||||
|
using Flashsharp.Scheduling;
|
||||||
|
|
||||||
|
namespace Flashsharp.Tests;
|
||||||
|
|
||||||
|
public sealed class SchedulerTests
|
||||||
|
{
|
||||||
|
static readonly DateTime Now = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void First_good_review_is_due_tomorrow()
|
||||||
|
{
|
||||||
|
var next = Scheduler.Next(null, ReviewRating.Good, Now);
|
||||||
|
|
||||||
|
Assert.Equal(86_400, next.IntervalSeconds);
|
||||||
|
Assert.Equal(1, next.Repetitions);
|
||||||
|
Assert.Equal(0, next.Lapses);
|
||||||
|
Assert.Equal(2.5, next.Ease, 3);
|
||||||
|
Assert.Equal(Now.AddDays(1), next.DueAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Third_good_review_multiplies_by_ease()
|
||||||
|
{
|
||||||
|
var first = Scheduler.Next(null, ReviewRating.Good, Now);
|
||||||
|
var second = Scheduler.Next(first, ReviewRating.Good, Now);
|
||||||
|
var third = Scheduler.Next(second, ReviewRating.Good, Now);
|
||||||
|
|
||||||
|
Assert.Equal(3 * 86_400, second.IntervalSeconds);
|
||||||
|
Assert.Equal((int)(3 * 86_400 * 2.5), third.IntervalSeconds);
|
||||||
|
Assert.Equal(3, third.Repetitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Again_resets_repetitions_and_adds_a_lapse()
|
||||||
|
{
|
||||||
|
var first = Scheduler.Next(null, ReviewRating.Good, Now);
|
||||||
|
var next = Scheduler.Next(first, ReviewRating.Again, Now);
|
||||||
|
|
||||||
|
Assert.Equal(600, next.IntervalSeconds);
|
||||||
|
Assert.Equal(0, next.Repetitions);
|
||||||
|
Assert.Equal(1, next.Lapses);
|
||||||
|
Assert.Equal(2.3, next.Ease, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hard_does_not_reset_repetitions()
|
||||||
|
{
|
||||||
|
var first = Scheduler.Next(null, ReviewRating.Good, Now);
|
||||||
|
var next = Scheduler.Next(first, ReviewRating.Hard, Now);
|
||||||
|
|
||||||
|
Assert.Equal(2, next.Repetitions);
|
||||||
|
Assert.Equal(0, next.Lapses);
|
||||||
|
Assert.True(next.IntervalSeconds >= 43_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Easy_starts_at_three_days_and_raises_ease()
|
||||||
|
{
|
||||||
|
var next = Scheduler.Next(null, ReviewRating.Easy, Now);
|
||||||
|
|
||||||
|
Assert.Equal(3 * 86_400, next.IntervalSeconds);
|
||||||
|
Assert.Equal(2.65, next.Ease, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ease_never_goes_below_floor()
|
||||||
|
{
|
||||||
|
var state = Scheduler.Next(null, ReviewRating.Again, Now);
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
state = Scheduler.Next(state, ReviewRating.Again, Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(1.3, state.Ease, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user