Build flashsharp flashcard app

This commit is contained in:
didier committed 2026-10-22 21:18:32 -04:00
commit c97759b04c
69 files changed
+6959

No files matched your search

+488
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
}
+159
View File
@@ -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;
};
};
};
};
};
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.401",
"rollForward": "latestFeature"
}
}
+62
View File
@@ -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="
}
]
+22
View File
@@ -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);
}
}
+26
View File
@@ -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();
}
}
+25
View File
@@ -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();
+67
View File
@@ -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());
}
}
}
+272
View File
@@ -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; } = "";
}
}
+467
View File
@@ -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; } = "";
}
}
+339
View File
@@ -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; }
}
}
+188
View File
@@ -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; }
}
}
+161
View File
@@ -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; }
}
}
+17
View File
@@ -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";
}
}
+211
View File
@@ -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);
}
+25
View File
@@ -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>
+361
View File
@@ -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();
}
}
+76
View File
@@ -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();
}
}
+13
View File
@@ -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>
+22
View File
@@ -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;
}
}
+79
View File
@@ -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>
}
+22
View File
@@ -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();
}
}
+35
View File
@@ -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>
+59
View File
@@ -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");
}
}
+2
View File
@@ -0,0 +1,2 @@
@page
@model LogoutModel
+23
View File
@@ -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");
}
}
+99
View File
@@ -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>
}
+63
View File
@@ -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>
+140
View File
@@ -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">&nbsp;</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>
+29
View File
@@ -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);
}
}
+38
View File
@@ -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>
+192
View File
@@ -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();
}
}
+85
View File
@@ -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>
+49
View File
@@ -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");
}
}
+85
View File
@@ -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>
+99
View File
@@ -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);
}
}
+6
View File
@@ -0,0 +1,6 @@
@using Flashsharp
@using Flashsharp.Domain
@using Flashsharp.Ui
@using System.Globalization
@namespace Flashsharp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+3
View File
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+99
View File
@@ -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"
}
}
}
}
+58
View File
@@ -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);
}
}
+63
View File
@@ -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() + "…";
}
}
+17
View File
@@ -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));
}
+21
View File
@@ -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"
}
}
}
+9
View File
@@ -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

+71
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+77
View File
@@ -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);
}
}