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