alpha beta gamma
"; + private const int BaselineWords = 3; + + [Test] + public async Task PlainProse_IsCountedByWhitespaceSeparatedWords() + { + using TempContentDirectory dir = new(); + dir.Write("page.html", "Hello world, this\nis a test.
"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(6); + } + + [Test] + [Arguments("public static void Main() { var x = 1; }")]
+ [Arguments("Console.WriteLine(x);")]
+ [Arguments("")]
+ [Arguments("")]
+ [Arguments("deeply nested code here
pre inside a generated listing
UPPERCASE TAG LISTING")] + [Arguments("")] + [Arguments("")] + public async Task CodeListingsAndNonProseTags_DoNotAddToTheCount(string excludedHtml) + { + using TempContentDirectory dir = new(); + dir.Write("page.html", $"\n{Baseline}\n{excludedHtml}\n{Baseline}\n"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(2 * BaselineWords); + } + + [Test] + public async Task UnclosedPreAtEndOfPage_ExcludesItsContent() + { + using TempContentDirectory dir = new(); + dir.Write("page.html", $"{Baseline}
unclosed listing words here");
+
+ WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1));
+
+ await Assert.That(service.GetPageWordCount("page")).IsEqualTo(BaselineWords);
+ }
+
+ [Test]
+ public async Task InlineCodeInProse_IsCounted()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("page.html", "Call Console.WriteLine now
");
+
+ WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1));
+
+ await Assert.That(service.GetPageWordCount("page")).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task HtmlEntities_AreDecodedBeforeCounting()
+ {
+ using TempContentDirectory dir = new();
+ // Requirement: entities are decoded and the decoded text is counted like any other prose. Decoded this reads
+ // "a b AT&T List": a non-breaking space separates two words, while "AT&T" and "List" are one word each
+ // (undecoded, "a b" would be a single word and the total would be 3).
+ dir.Write("page.html", "a b AT&T List<T>
");
+
+ WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1));
+
+ await Assert.That(service.GetPageWordCount("page")).IsEqualTo(4);
+ }
+
+ [Test]
+ [Arguments("", 0)]
+ [Arguments("", 0)]
+ [Arguments(" \n ", 0)]
+ [Arguments("one two
", 2)] // fragment without /
+ public async Task EmptyOrBodylessContent_IsCountedWithoutFailing(string html, int expected)
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("page.html", html);
+
+ WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1));
+
+ await Assert.That(service.GetPageWordCount("page")).IsEqualTo(expected);
+ }
+
+ [Test]
+ public async Task UnknownKey_HasNoStats()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("present.html", $"{Baseline}");
+
+ WordCountService service = await dir.BuildAsync(Map("present", "present.html", 1, 1));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageStats("not-a-key")).IsNull();
+ await Assert.That(service.GetPageWordCount("not-a-key")).IsEqualTo(0);
+ await Assert.That(service.GetPageWordCount("present")).IsEqualTo(BaselineWords);
+ }
+ }
+
+ [Test]
+ public async Task MappedKeyWithMissingFile_CountsZeroKeepsStatsAndLogsAWarning()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("present.html", $"{Baseline}");
+ CapturingLogger logger = new();
+
+ WordCountService service = await dir.BuildAsync(
+ logger,
+ Map("present", "present.html", 1, 1),
+ Map("missing", "nonexistent.html", 1, 2));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageStats("missing")).IsNotNull();
+ await Assert.That(service.GetPageWordCount("missing")).IsEqualTo(0);
+ await Assert.That(service.GetPageWordCount("present")).IsEqualTo(BaselineWords);
+ await Assert.That(logger.Warnings).Contains(w => w.Mentions("nonexistent.html"));
+ }
+ }
+
+ // FileShare.None makes the page read fail with an IOException on Windows. Other platforms use advisory locks,
+ // so the read would succeed there and the failure cannot be provoked.
+ [Test]
+ [RunOn(OS.Windows)]
+ public async Task FileThatCannotBeRead_IsLoggedCountsZeroAndDoesNotAffectOtherPages()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", $"{Baseline}");
+ dir.Write("locked.html", $"{Baseline}");
+ dir.Write("c.html", $"{Baseline}");
+ CapturingLogger logger = new();
+
+ using FileStream exclusive = new(dir.PathOf("locked.html"), FileMode.Open, FileAccess.Read, FileShare.None);
+ WordCountService service = await dir.BuildAsync(
+ logger,
+ Map("a", "a.html", 1, 1),
+ Map("locked", "locked.html", 1, 2),
+ Map("c", "c.html", 1, 3));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords);
+ await Assert.That(service.GetPageWordCount("locked")).IsEqualTo(0);
+ await Assert.That(service.GetPageWordCount("c")).IsEqualTo(BaselineWords);
+ await Assert.That(logger.Warnings).Contains(w => w.Mentions("locked.html"));
+ }
+ }
+
+ [Test]
+ public async Task MappedPathThatIsADirectory_IsTreatedAsMissingLoggedAndDoesNotAffectOtherPages()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", $"{Baseline}");
+ dir.Write("c.html", $"{Baseline}");
+ // A directory where a page file should be. File.Exists is false for it, so this covers the missing/non-file path, not the read-failure catch.
+ Directory.CreateDirectory(dir.PathOf("locked.html"));
+ CapturingLogger logger = new();
+
+ WordCountService service = await dir.BuildAsync(
+ logger,
+ Map("a", "a.html", 1, 1),
+ Map("locked", "locked.html", 1, 2),
+ Map("c", "c.html", 1, 3));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords);
+ await Assert.That(service.GetPageWordCount("locked")).IsEqualTo(0);
+ await Assert.That(service.GetPageWordCount("c")).IsEqualTo(BaselineWords);
+ await Assert.That(service.GetPageStats("c")?.WordsBeforePage).IsEqualTo(BaselineWords);
+ await Assert.That(logger.Warnings).Contains(w => w.Mentions("locked.html"));
+ }
+ }
+
+ private const string AbsolutePathToken = "{absolute path of the outside file}";
+
+ [Test]
+ [Arguments("../outside.html")]
+ [Arguments("..\\outside.html")]
+ [Arguments("sub/../../outside.html")]
+ [Arguments("sub\\..\\..\\outside.html")]
+ [Arguments("../content-evil/outside.html")] // sibling directory sharing the content root's name as a prefix
+ [Arguments("..\\content-evil\\outside.html")]
+ [Arguments(AbsolutePathToken)]
+ public async Task PagePathLeadingOutsideTheContentRoot_IsNeverCountedAndIsLogged(string pagePath)
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("ok.html", $"{Baseline}");
+ // A real, readable file outside the content root with distinctive words: if it were read, the count would show it.
+ dir.WriteOutside("outside.html", "outside words must never be counted
");
+ dir.WriteOutside("content-evil/outside.html", "outside words must never be counted
");
+ string resolvedPath = pagePath == AbsolutePathToken ? dir.OutsidePathOf("outside.html") : pagePath;
+ CapturingLogger logger = new();
+
+ WordCountService service = await dir.BuildAsync(
+ logger,
+ Map("ok", "ok.html", 1, 1),
+ Map("escape", resolvedPath, 1, 2));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageWordCount("escape")).IsEqualTo(0);
+ await Assert.That(service.GetPageWordCount("ok")).IsEqualTo(BaselineWords);
+ await Assert.That(service.GetBookWordCount()).IsEqualTo(BaselineWords);
+ await Assert.That(logger.Warnings).IsNotEmpty().Because("a skipped mapping must not be silent");
+ }
+ }
+
+ [Test]
+ public async Task PageStats_ReportPageChapterAndBookOffsets()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", "one two
"); // 2
+ dir.Write("b.html", "three
"); // 1
+ dir.Write("c.html", "four five six
"); // 3 (chapter 2)
+
+ WordCountService service = await dir.BuildAsync(
+ Map("a", "a.html", 1, 1),
+ Map("b", "b.html", 1, 2),
+ Map("c", "c.html", 2, 1));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageStats("a")).IsEqualTo(new PageWordStats(2, 3, 0, 0, 6));
+ await Assert.That(service.GetPageStats("b")).IsEqualTo(new PageWordStats(1, 3, 2, 2, 6));
+ await Assert.That(service.GetPageStats("c")).IsEqualTo(new PageWordStats(3, 3, 3, 0, 6));
+ await Assert.That(service.GetBookWordCount()).IsEqualTo(6);
+ }
+ }
+
+ [Test]
+ public async Task MappingsInAnyInputOrder_YieldReadingOrderOffsets()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", "one two
");
+ dir.Write("b.html", "three
");
+ dir.Write("c.html", "four five six
");
+
+ WordCountService service = await dir.BuildAsync(
+ Map("c", "c.html", 2, 1),
+ Map("b", "b.html", 1, 2),
+ Map("a", "a.html", 1, 1));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageStats("a")?.WordsBeforePage).IsEqualTo(0);
+ await Assert.That(service.GetPageStats("b")?.WordsBeforePage).IsEqualTo(2);
+ await Assert.That(service.GetPageStats("b")?.ChapterStartWords).IsEqualTo(2);
+ await Assert.That(service.GetPageStats("c")?.WordsBeforePage).IsEqualTo(3);
+ await Assert.That(service.GetPageStats("c")?.ChapterStartWords).IsEqualTo(0);
+ }
+ }
+
+ [Test]
+ public async Task SeveralMappingsOfOneFile_CountOnceAndShareStatsAcrossAllKeys()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", "one two three
");
+ dir.Write("b.html", "four five
");
+
+ WordCountService service = await dir.BuildAsync(
+ Map("page-one", "a.html", 1, 1, keys: ["page-one", "page-one-alias"]),
+ Map("page-one-heading", "./a.html", 1, 1, order: 2),
+ Map("page-two", "b.html", 1, 2));
+
+ PageWordStats expectedFirst = new(3, 5, 0, 0, 5);
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageStats("page-one")).IsEqualTo(expectedFirst);
+ await Assert.That(service.GetPageStats("page-one-alias")).IsEqualTo(expectedFirst);
+ await Assert.That(service.GetPageStats("page-one-heading")).IsEqualTo(expectedFirst);
+ await Assert.That(service.GetPageStats("page-two")).IsEqualTo(new PageWordStats(2, 5, 3, 3, 5));
+ await Assert.That(service.GetBookWordCount()).IsEqualTo(5);
+ }
+ }
+
+ [Test]
+ public async Task KeyCollision_FirstMappingInReadingOrderWins()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("first.html", "one two
");
+ dir.Write("second.html", "three four five
");
+
+ WordCountService service = await dir.BuildAsync(
+ Map("shared", "second.html", 1, 2),
+ Map("shared", "first.html", 1, 1));
+
+ await Assert.That(service.GetPageWordCount("shared")).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task BeforeCacheIsBuilt_QueriesReturnNoData()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", $"{Baseline}");
+
+ WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1));
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageStats("a")).IsNull();
+ await Assert.That(service.GetPageWordCount("a")).IsEqualTo(0);
+ await Assert.That(service.GetBookWordCount()).IsEqualTo(0);
+ }
+ }
+
+ [Test]
+ public async Task HostStartup_BuildsTheCacheBeforeRequestsAreServed()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", $"{Baseline}");
+ WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1));
+
+ await service.StartingAsync(CancellationToken.None);
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords);
+ }
+ }
+
+ [Test]
+ public async Task Build_HonorsCancellation()
+ {
+ using TempContentDirectory dir = new();
+ dir.Write("a.html", $"{Baseline}");
+ WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1));
+ using CancellationTokenSource cts = new();
+ await cts.CancelAsync();
+
+ await Assert.That(async () => await service.BuildAsync(cts.Token)).Throws();
+ }
+
+ // ---- Helpers ----
+
+ private static SiteMapping Map(string key, string relativePath, int chapter, int page, int order = 1, string[]? keys = null) =>
+ new(
+ keys: [.. keys ?? [key]],
+ primaryKey: key,
+ pagePath: relativePath.Split('/', '\\').Where(s => s.Length > 0).ToArray(),
+ chapterNumber: chapter,
+ pageNumber: page,
+ orderOnPage: order,
+ chapterTitle: $"Chapter {chapter}",
+ rawHeading: key,
+ anchorId: key,
+ indentLevel: 0);
+
+ /// A throwaway content root that deletes itself when disposed.
+ private sealed class TempContentDirectory : IDisposable
+ {
+ // Content lives one level below the temp root so "../outside.html" stays inside a directory we own.
+ private readonly string _root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ private readonly string _contentRoot;
+
+ public TempContentDirectory()
+ {
+ _contentRoot = Path.Combine(_root, "content");
+ Directory.CreateDirectory(_contentRoot);
+ }
+
+ public string PathOf(string relativePath) => Path.Combine(_contentRoot, relativePath);
+
+ public void Write(string relativePath, string html) => File.WriteAllText(PathOf(relativePath), html);
+
+ /// The same relative path resolved against the temp root, i.e. outside (beside) the content root.
+ public string OutsidePathOf(string relativePath) => Path.Combine(_root, relativePath);
+
+ public void WriteOutside(string relativePath, string html)
+ {
+ string path = OutsidePathOf(relativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, html);
+ }
+
+ public WordCountService CreateService(ILogger logger, params SiteMapping[] mappings)
+ {
+ Mock siteMappings = new();
+ siteMappings.Setup(s => s.SiteMappings).Returns(mappings);
+ Mock environment = new();
+ environment.Setup(e => e.ContentRootPath).Returns(_contentRoot);
+ return new WordCountService(siteMappings.Object, environment.Object, logger);
+ }
+
+ public Task BuildAsync(params SiteMapping[] mappings) =>
+ BuildAsync(NullLogger.Instance, mappings);
+
+ public async Task BuildAsync(ILogger logger, params SiteMapping[] mappings)
+ {
+ WordCountService service = CreateService(logger, mappings);
+ await service.BuildAsync();
+ return service;
+ }
+
+ public void Dispose() => Directory.Delete(_root, recursive: true);
+ }
+
+ private sealed record LogEntry(LogLevel Level, string Message, IReadOnlyList> Properties)
+ {
+ /// True when the message or any structured property value contains .
+ public bool Mentions(string text) =>
+ Message.Contains(text, StringComparison.Ordinal)
+ || Properties.Any(p => p.Value?.ToString()?.Contains(text, StringComparison.Ordinal) == true);
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public List Entries { get; } = [];
+
+ public IEnumerable Warnings => Entries.Where(e => e.Level == LogLevel.Warning);
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ IReadOnlyList> properties =
+ state as IReadOnlyList> ?? [];
+ Entries.Add(new LogEntry(logLevel, formatter(state, exception), properties));
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs
index aaf81356..a56d4e9d 100644
--- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs
+++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs
@@ -11,6 +11,7 @@ public class EssentialCSharpWebContext(DbContextOptions DataProtectionKeys { get; set; } = null!;
public DbSet McpApiTokens { get; set; } = null!;
+ public DbSet UserReadingProfiles { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs
index 671957b1..4debe28f 100644
--- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs
@@ -1,13 +1,17 @@
using System.Text.Json;
using EssentialCSharp.Web.Areas.Identity.Data;
+using EssentialCSharp.Web.Data;
+using EssentialCSharp.Web.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
public partial class DownloadPersonalDataModel(
UserManager userManager,
+ EssentialCSharpWebContext context,
ILogger logger) : PageModel
{
public IActionResult OnGet()
@@ -45,6 +49,16 @@ public async Task OnPostAsync()
personalData.Add($"Authenticator Key", authenticatorKey);
}
+ UserReadingProfile? readingProfile = await context.UserReadingProfiles
+ .AsNoTracking()
+ .FirstOrDefaultAsync(p => p.UserId == user.Id);
+ if (readingProfile is not null)
+ {
+ personalData.Add("Reading total words read", readingProfile.TotalWordsRead.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ personalData.Add("Reading total active seconds", readingProfile.TotalActiveSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ personalData.Add("Reading profile last updated (UTC)", readingProfile.UpdatedAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture));
+ }
+
Response.Headers.Append("Content-Disposition", "attachment; filename=PersonalData.json");
return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json");
}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml
index f63c961f..30022914 100644
--- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml
@@ -3,6 +3,7 @@
@{
ViewData["Title"] = "Profile";
ViewData["ActivePage"] = ManageNavPages.Index;
+ ViewData["NeedsReadingCsrf"] = true;
}
@ViewData["Title"]
@@ -35,9 +36,22 @@ ViewData["ActivePage"] = ManageNavPages.Index;
+
+
+ Reading Speed
+
+ Essential C# tracks your reading speed to estimate how long chapters and the book will take you.
+ Resetting clears all recorded data and returns to the default estimate.
+
+
+
@section Scripts {
+
}
diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs
index 1437d956..16da1e5f 100644
--- a/EssentialCSharp.Web/Controllers/HomeController.cs
+++ b/EssentialCSharp.Web/Controllers/HomeController.cs
@@ -60,11 +60,13 @@ public IActionResult Index()
string headHtml = doc.DocumentNode.Element("html")?.Element("head")?.InnerHtml ?? string.Empty;
string html = doc.DocumentNode.Element("html")?.Element("body")?.InnerHtml ?? string.Empty;
+ // PrimaryKey is not serialized in sitemap.json, so it is null after deserialization; Keys is authoritative.
+ string pageKey = siteMapping.Keys.FirstOrDefault() ?? siteMapping.PrimaryKey ?? string.Empty;
ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading;
ViewData["Description"] = chapterDescriptionService.GetDescription(siteMapping);
ViewData["CanonicalUrl"] = $"{siteSettings.Value.BaseUrl.TrimEnd('/')}{Request.PathBase}{canonicalPath}";
ViewBag.NextPage = FlipPage(siteMapping!.ChapterNumber, siteMapping.PageNumber, true);
- ViewBag.CurrentPageKey = siteMapping.PrimaryKey;
+ ViewBag.CurrentPageKey = pageKey;
ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false);
ViewBag.HeadContents = headHtml;
ViewBag.Contents = html;
diff --git a/EssentialCSharp.Web/Controllers/ReadingAntiforgeryFailureHeaderAttribute.cs b/EssentialCSharp.Web/Controllers/ReadingAntiforgeryFailureHeaderAttribute.cs
new file mode 100644
index 00000000..57f132df
--- /dev/null
+++ b/EssentialCSharp.Web/Controllers/ReadingAntiforgeryFailureHeaderAttribute.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.Core.Infrastructure;
+
+namespace EssentialCSharp.Web.Controllers;
+
+///
+/// Marks a rejected antiforgery token on the response with : 1 . This is the
+/// explicit, stable signal the reading client uses to tell "the token is no longer valid, stop tracking"
+/// from a 400 caused by the request body (or by a proxy/WAF/server in front of the app).
+/// Always-run so it also sees the result produced by the global antiforgery authorization filter, which
+/// short-circuits before the action executes; ordered first because [ApiController] otherwise replaces that
+/// result with a problem document before this filter can recognize it.
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public sealed class ReadingAntiforgeryFailureHeaderAttribute : Attribute, IAlwaysRunResultFilter, IOrderedFilter
+{
+ public const string HeaderName = "X-Antiforgery-Failure";
+
+ public int Order => int.MinValue;
+
+ public void OnResultExecuting(ResultExecutingContext context)
+ {
+ if (context.Result is IAntiforgeryValidationFailedResult)
+ {
+ context.HttpContext.Response.Headers[HeaderName] = "1";
+ }
+ }
+
+ public void OnResultExecuted(ResultExecutedContext context)
+ {
+ }
+}
diff --git a/EssentialCSharp.Web/Controllers/ReadingController.cs b/EssentialCSharp.Web/Controllers/ReadingController.cs
new file mode 100644
index 00000000..0cc94c01
--- /dev/null
+++ b/EssentialCSharp.Web/Controllers/ReadingController.cs
@@ -0,0 +1,68 @@
+using System.Security.Claims;
+using EssentialCSharp.Web.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace EssentialCSharp.Web.Controllers;
+
+// The rate limit is class-level so every action is limited: /api/reading is exempt from the global limiter in Program.cs.
+[ApiController]
+[Authorize]
+[Route("api/reading")]
+[ReadingAntiforgeryFailureHeader]
+[EnableRateLimiting(WriteRateLimitPolicy)]
+public sealed class ReadingController(IReadingProfileService readingProfileService) : ControllerBase
+{
+ public const string WriteRateLimitPolicy = "ReadingWrite";
+
+ private const int MaxSessionRequestBytes = 32 * 1024;
+
+ [HttpPost("session")]
+ [RequestSizeLimit(MaxSessionRequestBytes)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task> PostSession(
+ [FromBody] ReadingSessionRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (GetUserId() is not { } userId)
+ {
+ return Unauthorized();
+ }
+
+ ReadingInterval[] intervals = request.Intervals
+ .Select(i => new ReadingInterval(i.PageKey, i.WordsRead, i.ActiveSeconds))
+ .ToArray();
+
+ RecordSessionResult result = await readingProfileService.RecordSessionAsync(userId, intervals, cancellationToken);
+ if (result.Profile is null)
+ {
+ // The service rejects the first interval whose page is unknown, so the first match is that interval.
+ int index = request.Intervals.FindIndex(i => i.PageKey == result.UnknownPageKey);
+ ModelState.AddModelError(
+ $"{nameof(ReadingSessionRequest.Intervals)}[{index}].{nameof(ReadingIntervalRequest.PageKey)}",
+ $"Unknown page key '{result.UnknownPageKey}'.");
+ return ValidationProblem(ModelState);
+ }
+
+ return result.Profile;
+ }
+
+ [HttpPost("reset")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task Reset(CancellationToken cancellationToken)
+ {
+ if (GetUserId() is not { } userId)
+ {
+ return Unauthorized();
+ }
+
+ await readingProfileService.ResetAsync(userId, cancellationToken);
+ return NoContent();
+ }
+
+ private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
+}
diff --git a/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs
new file mode 100644
index 00000000..a17a18ab
--- /dev/null
+++ b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs
@@ -0,0 +1,39 @@
+using System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.Controllers;
+
+public sealed class ReadingIntervalRequest : IValidatableObject
+{
+ public const int PageKeyMaxLength = 256;
+ public const int MaxWordsRead = 100_000;
+
+ /// The page's site-mapping key.
+ [Required]
+ [StringLength(PageKeyMaxLength, MinimumLength = 1)]
+ public string PageKey { get; set; } = string.Empty;
+
+ ///
+ /// Active (non-idle, visible) seconds spent on the page. The upper bound is
+ /// ReadingOptions.MaxActiveSecondsPerInterval , enforced in .
+ ///
+ [Range(1, int.MaxValue)]
+ public int ActiveSeconds { get; set; }
+
+ /// Prose words read during the interval.
+ [Range(1, MaxWordsRead)]
+ public int WordsRead { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ int maxActiveSeconds = validationContext.GetRequiredService>().Value.MaxActiveSecondsPerInterval;
+ if (ActiveSeconds > maxActiveSeconds)
+ {
+ yield return new ValidationResult(
+ $"An interval may contain at most {maxActiveSeconds} active seconds.",
+ [nameof(ActiveSeconds)]);
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs b/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs
new file mode 100644
index 00000000..376099d6
--- /dev/null
+++ b/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs
@@ -0,0 +1,38 @@
+using System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.Controllers;
+
+public sealed class ReadingSessionRequest : IValidatableObject
+{
+ ///
+ /// Reading intervals to record. The upper bound is configured by
+ /// ReadingOptions.MaxIntervalsPerRequest .
+ ///
+ [Required]
+ [MinLength(1)]
+ public List Intervals { get; set; } = [];
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ int maxIntervals = validationContext.GetRequiredService>().Value.MaxIntervalsPerRequest;
+ if (Intervals.Count > maxIntervals)
+ {
+ yield return new ValidationResult(
+ $"A session may contain at most {maxIntervals} intervals.",
+ [nameof(Intervals)]);
+ }
+
+ for (int i = 0; i < Intervals.Count; i++)
+ {
+ if (Intervals[i] is null)
+ {
+ yield return new ValidationResult(
+ "Intervals must not contain null entries.",
+ [$"{nameof(Intervals)}[{i}]"]);
+ }
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs
new file mode 100644
index 00000000..71c36124
--- /dev/null
+++ b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs
@@ -0,0 +1,398 @@
+//
+using System;
+using EssentialCSharp.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace EssentialCSharp.Web.Migrations
+{
+ [DbContext(typeof(EssentialCSharpWebContext))]
+ [Migration("20261106060723_AddUserReadingProfile")]
+ partial class AddUserReadingProfile
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("FirstName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LastName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("ReferralCount")
+ .HasColumnType("int");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("datetime2");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("RevokedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("varbinary(32)");
+
+ b.Property("UsageCount")
+ .HasColumnType("bigint");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("McpApiTokens");
+ });
+
+ modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b =>
+ {
+ b.Property("UserId")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("TotalActiveSeconds")
+ .HasColumnType("bigint");
+
+ b.Property("TotalWordsRead")
+ .HasColumnType("bigint");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.HasKey("UserId");
+
+ b.ToTable("UserReadingProfiles");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("ProviderKey")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RoleId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LoginProvider")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Name")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b =>
+ {
+ b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b =>
+ {
+ b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs
new file mode 100644
index 00000000..f94e245e
--- /dev/null
+++ b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs
@@ -0,0 +1,42 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace EssentialCSharp.Web.Migrations
+{
+ ///
+ public partial class AddUserReadingProfile : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "UserReadingProfiles",
+ columns: table => new
+ {
+ UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false),
+ TotalWordsRead = table.Column(type: "bigint", nullable: false),
+ TotalActiveSeconds = table.Column(type: "bigint", nullable: false),
+ UpdatedAtUtc = table.Column(type: "datetime2", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_UserReadingProfiles", x => x.UserId);
+ table.ForeignKey(
+ name: "FK_UserReadingProfiles_AspNetUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "UserReadingProfiles");
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs
index a7f7c2b2..edc3d757 100644
--- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs
+++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs
@@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("ProductVersion", "10.0.14")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -141,6 +141,26 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("McpApiTokens");
});
+ modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b =>
+ {
+ b.Property("UserId")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("TotalActiveSeconds")
+ .HasColumnType("bigint");
+
+ b.Property("TotalWordsRead")
+ .HasColumnType("bigint");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.HasKey("UserId");
+
+ b.ToTable("UserReadingProfiles");
+ });
+
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property("Id")
@@ -308,6 +328,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("User");
});
+ modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b =>
+ {
+ b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
diff --git a/EssentialCSharp.Web/Models/UserReadingProfile.cs b/EssentialCSharp.Web/Models/UserReadingProfile.cs
new file mode 100644
index 00000000..a19673ea
--- /dev/null
+++ b/EssentialCSharp.Web/Models/UserReadingProfile.cs
@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace EssentialCSharp.Web.Models;
+
+///
+/// Aggregate reading profile for a single user.
+/// WPM is always derived as TotalWordsRead / (TotalActiveSeconds / 60.0) ;
+/// it is never stored directly so there is no floating-point drift over time.
+///
+[PrimaryKey(nameof(UserId))]
+public class UserReadingProfile
+{
+ /// Primary key; also the FK to .
+ [MaxLength(450)]
+ public required string UserId { get; set; }
+
+ /// Cumulative prose words credited across all accepted reading intervals.
+ public long TotalWordsRead { get; set; }
+
+ /// Cumulative active seconds credited across all accepted reading intervals.
+ public long TotalActiveSeconds { get; set; }
+
+ public DateTime UpdatedAtUtc { get; set; }
+
+ [ForeignKey(nameof(UserId))]
+ public EssentialCSharpWebUser? User { get; set; }
+
+ /// Derives the words-per-minute estimate. Returns null if there is no active time.
+ public static double? DeriveWpm(long totalWordsRead, long totalActiveSeconds) =>
+ totalActiveSeconds > 0 ? totalWordsRead / (totalActiveSeconds / 60.0) : null;
+}
diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs
index 1cb4de2a..ca4a3f17 100644
--- a/EssentialCSharp.Web/Program.cs
+++ b/EssentialCSharp.Web/Program.cs
@@ -6,6 +6,7 @@
using EssentialCSharp.Web.Areas.Identity.Data;
using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators;
using EssentialCSharp.Web.Auth;
+using EssentialCSharp.Web.Controllers;
using EssentialCSharp.Web.Data;
using EssentialCSharp.Web.Extensions;
using EssentialCSharp.Web.Helpers;
@@ -271,6 +272,16 @@ private static void Main(string[] args)
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ // One instance serves both the query interface and the startup hook that builds the cache.
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(sp => sp.GetRequiredService());
+ builder.Services.AddHostedService(sp => sp.GetRequiredService());
+ builder.Services.AddOptions()
+ .BindConfiguration(ReadingOptions.SectionName)
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+ builder.Services.AddSingleton, ReadingOptionsValidator>();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
// Add AI Chat services using configuration-driven backend selection.
@@ -323,6 +334,10 @@ private static void Main(string[] args)
if (IsMcpTransportRequest(httpContext.Request))
return RateLimitPartition.GetNoLimiter("mcp-transport");
+ // Reading API endpoints are limited by their own named policies.
+ if (httpContext.Request.Path.StartsWithSegments("/api/reading"))
+ return RateLimitPartition.GetNoLimiter("reading-api");
+
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
@@ -356,6 +371,17 @@ private static void Main(string[] args)
});
});
+ // Reading tracker: per-user limit for the authenticated reading endpoints.
+ options.AddPolicy(ReadingController.WriteRateLimitPolicy, httpContext =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: $"reading-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
+ factory: _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 20,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0
+ }));
+
// Combined per-minute burst (10/min) + per-hour cap (150/hr) for book content pages.
// A scraper cycling through the full ~400-page book needs 2+ hours at minimum.
// See Services/ContentRateLimiterPolicy.cs for implementation.
diff --git a/EssentialCSharp.Web/Services/IReadingProfileService.cs b/EssentialCSharp.Web/Services/IReadingProfileService.cs
new file mode 100644
index 00000000..1d3bc12b
--- /dev/null
+++ b/EssentialCSharp.Web/Services/IReadingProfileService.cs
@@ -0,0 +1,23 @@
+namespace EssentialCSharp.Web.Services;
+
+/// A single reading interval reported by a client.
+public sealed record ReadingInterval(string PageKey, int WordsRead, int ActiveSeconds);
+
+///
+/// A user's aggregate reading totals. Serialized as-is by the reading API
+/// (totalWordsRead , totalActiveSeconds , wpm ).
+///
+public sealed record ReadingProfileSummary(long TotalWordsRead, long TotalActiveSeconds, double? Wpm);
+
+/// Outcome of recording a session: either the updated profile or the first unknown page key.
+public sealed record RecordSessionResult(ReadingProfileSummary? Profile, string? UnknownPageKey);
+
+public interface IReadingProfileService
+{
+ Task RecordSessionAsync(
+ string userId, IReadOnlyList intervals, CancellationToken cancellationToken);
+
+ Task GetProfileAsync(string userId, CancellationToken cancellationToken);
+
+ Task ResetAsync(string userId, CancellationToken cancellationToken);
+}
diff --git a/EssentialCSharp.Web/Services/IWordCountService.cs b/EssentialCSharp.Web/Services/IWordCountService.cs
new file mode 100644
index 00000000..8ab40420
--- /dev/null
+++ b/EssentialCSharp.Web/Services/IWordCountService.cs
@@ -0,0 +1,39 @@
+namespace EssentialCSharp.Web.Services;
+
+///
+/// Provides pre-computed prose word counts for content pages, enabling
+/// Kindle-style reading time estimates.
+///
+///
+/// The cache is built when the host starts, before requests are served. Before it is built
+/// every query returns "no data": returns ,
+/// counts are 0.
+///
+public interface IWordCountService
+{
+ ///
+ /// Gets all word statistics for a page key, or if the key is unknown
+ /// (or the cache is not built yet). A known key whose content file is missing or unreadable
+ /// yields stats with zero page words.
+ ///
+ PageWordStats? GetPageStats(string pageKey);
+
+ /// Gets the prose word count for a specific page key (0 if unknown).
+ int GetPageWordCount(string pageKey);
+
+ /// Gets the total prose word count for the entire book.
+ int GetBookWordCount();
+}
+
+/// Immutable prose word statistics for one page.
+/// Prose words on the page.
+/// Prose words in the page's whole chapter.
+/// Prose words on all pages before this one across the book.
+/// Prose words on pages before this one within its chapter.
+/// Prose words in the whole book.
+public sealed record PageWordStats(
+ int PageWords,
+ int ChapterWords,
+ int WordsBeforePage,
+ int ChapterStartWords,
+ int BookWords);
diff --git a/EssentialCSharp.Web/Services/ReadingOptions.cs b/EssentialCSharp.Web/Services/ReadingOptions.cs
new file mode 100644
index 00000000..21c12777
--- /dev/null
+++ b/EssentialCSharp.Web/Services/ReadingOptions.cs
@@ -0,0 +1,96 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.Services;
+
+///
+/// Tunables for the reading-time tracker (mirrors Kindle's ReadingTimer values).
+/// This is the single source for every reading constant: the client receives the relevant
+/// values through window.READING_CONTEXT and keeps no copies of its own.
+///
+///
+/// The client splits unsent time into intervals of at most and flushes after
+/// active seconds, so both must stay within
+/// . The client outbox holds at most 20 intervals, so
+/// must be at least 20 (a batch over the server maximum is rejected wholesale).
+/// enforces these at startup.
+///
+public sealed class ReadingOptions
+{
+ public const string SectionName = "Reading";
+
+ /// Intervals faster than this many words per minute are implausible and are ignored.
+ [Range(1, 10_000)]
+ public int HardCutoffWpm { get; init; } = 900;
+
+ ///
+ /// Intervals slower than this fraction of the reader's reference WPM are credited
+ /// as if they had been read at exactly that fraction of the reference speed.
+ ///
+ [Range(0.0, 1.0)]
+ public double SlowOutlierFactor { get; init; } = 0.25;
+
+ /// Maximum number of intervals accepted in a single request.
+ [Range(1, 1_000)]
+ public int MaxIntervalsPerRequest { get; init; } = 50;
+
+ /// Reading speed (words per minute) the client assumes until the reader has a derived speed.
+ [Range(1, 10_000)]
+ public int DefaultWpm { get; init; } = 200;
+
+ ///
+ /// Client-side ceiling on how fast scrolling may be credited as reading (words per minute).
+ /// Must not exceed , otherwise the server would discard intervals the client credits.
+ ///
+ [Range(1, 10_000)]
+ public int MaxCreditWpm { get; init; } = 800;
+
+ /// Seconds without user activity after which the client stops counting active time.
+ [Range(1, 86_400)]
+ public int IdleThresholdSeconds { get; init; } = 300;
+
+ /// Active seconds after which the client flushes an interval while the reader stays on the page.
+ [Range(1, 3_600)]
+ public int PeriodicFlushActiveSeconds { get; init; } = 60;
+
+ /// Largest activeSeconds the server accepts for one interval; larger values are rejected.
+ [Range(1, 86_400)]
+ public int MaxActiveSecondsPerInterval { get; init; } = 1800;
+}
+
+/// Cross-field validation that data annotations cannot express.
+public sealed class ReadingOptionsValidator : IValidateOptions
+{
+ /// Size of the client's interval outbox.
+ public const int MinIntervalsPerRequest = 20;
+
+ public ValidateOptionsResult Validate(string? name, ReadingOptions options)
+ {
+ List? failures = null;
+ if (options.MaxCreditWpm > options.HardCutoffWpm)
+ {
+ (failures ??= []).Add(
+ $"{nameof(ReadingOptions.MaxCreditWpm)} ({options.MaxCreditWpm}) must not exceed {nameof(ReadingOptions.HardCutoffWpm)} ({options.HardCutoffWpm}).");
+ }
+
+ if (options.PeriodicFlushActiveSeconds > options.MaxActiveSecondsPerInterval)
+ {
+ (failures ??= []).Add(
+ $"{nameof(ReadingOptions.PeriodicFlushActiveSeconds)} must not exceed {nameof(ReadingOptions.MaxActiveSecondsPerInterval)}.");
+ }
+
+ if (options.IdleThresholdSeconds > options.MaxActiveSecondsPerInterval)
+ {
+ (failures ??= []).Add(
+ $"{nameof(ReadingOptions.IdleThresholdSeconds)} must not exceed {nameof(ReadingOptions.MaxActiveSecondsPerInterval)}.");
+ }
+
+ if (options.MaxIntervalsPerRequest < MinIntervalsPerRequest)
+ {
+ (failures ??= []).Add(
+ $"{nameof(ReadingOptions.MaxIntervalsPerRequest)} must be at least {MinIntervalsPerRequest} (the client outbox size).");
+ }
+
+ return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
+ }
+}
diff --git a/EssentialCSharp.Web/Services/ReadingProfileService.cs b/EssentialCSharp.Web/Services/ReadingProfileService.cs
new file mode 100644
index 00000000..d58412cb
--- /dev/null
+++ b/EssentialCSharp.Web/Services/ReadingProfileService.cs
@@ -0,0 +1,138 @@
+using EssentialCSharp.Web.Data;
+using EssentialCSharp.Web.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.Services;
+
+public sealed partial class ReadingProfileService(
+ EssentialCSharpWebContext context,
+ IWordCountService wordCountService,
+ IOptions options,
+ ILogger logger) : IReadingProfileService
+{
+ private readonly ReadingOptions _options = options.Value;
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Ignored interval for {PageKey}: {Words} words in {Seconds}s is not credited")]
+ private static partial void LogIntervalIgnored(ILogger logger, string pageKey, int words, int seconds);
+
+ ///
+ /// Not idempotent: if a client retries a request whose response was lost, the same intervals are credited
+ /// twice. This is accepted (no idempotency store); the effect is a small skew in the derived WPM.
+ /// The reference WPM used for the slow-outlier rule is read once before the batch is written, so concurrent
+ /// requests from the same user may evaluate against a slightly stale reference. This is also accepted.
+ ///
+ public async Task RecordSessionAsync(
+ string userId, IReadOnlyList intervals, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(intervals);
+
+ int[] pageWords = new int[intervals.Count];
+ for (int i = 0; i < intervals.Count; i++)
+ {
+ pageWords[i] = wordCountService.GetPageWordCount(intervals[i].PageKey);
+ if (pageWords[i] <= 0)
+ {
+ return new RecordSessionResult(null, intervals[i].PageKey);
+ }
+ }
+
+ DateTime now = DateTime.UtcNow;
+
+ // One consistent reference speed for the whole batch.
+ ReadingProfileSummary current = await GetProfileAsync(userId, cancellationToken);
+ double referenceWpm = current.Wpm ?? 0;
+
+ long deltaWords = 0;
+ long deltaSeconds = 0;
+ for (int i = 0; i < intervals.Count; i++)
+ {
+ ReadingInterval interval = intervals[i];
+ int words = Math.Min(interval.WordsRead, pageWords[i]);
+ ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, interval.ActiveSeconds, referenceWpm, _options);
+ if (credit is null)
+ {
+ LogIntervalIgnored(logger, interval.PageKey, words, interval.ActiveSeconds);
+ continue;
+ }
+
+ deltaWords += credit.Value.Words;
+ deltaSeconds += credit.Value.Seconds;
+ }
+
+ if (deltaWords > 0 || deltaSeconds > 0)
+ {
+ await AddToProfileAsync(userId, deltaWords, deltaSeconds, now, cancellationToken);
+ }
+
+ return new RecordSessionResult(await GetProfileAsync(userId, cancellationToken), null);
+ }
+
+ public async Task GetProfileAsync(string userId, CancellationToken cancellationToken)
+ {
+ var totals = await context.UserReadingProfiles
+ .AsNoTracking()
+ .Where(p => p.UserId == userId)
+ .Select(p => new { p.TotalWordsRead, p.TotalActiveSeconds })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ return totals is null
+ ? new ReadingProfileSummary(0, 0, null)
+ : new ReadingProfileSummary(
+ totals.TotalWordsRead,
+ totals.TotalActiveSeconds,
+ UserReadingProfile.DeriveWpm(totals.TotalWordsRead, totals.TotalActiveSeconds));
+ }
+
+ public async Task ResetAsync(string userId, CancellationToken cancellationToken) =>
+ await context.UserReadingProfiles
+ .Where(p => p.UserId == userId)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ ///
+ /// Adds the deltas to the user's profile, creating the row on first use, without a provider-specific upsert:
+ /// try an atomic UPDATE ... SET x = x + delta ; if no row exists insert one; if a concurrent request
+ /// inserted it first (duplicate key), retry the update once. Concurrent requests therefore never lose an increment.
+ ///
+ private async Task AddToProfileAsync(
+ string userId, long words, long seconds, DateTime now, CancellationToken cancellationToken)
+ {
+ if (await TryAddToExistingAsync(userId, words, seconds, now, cancellationToken) > 0)
+ {
+ return;
+ }
+
+ var created = new UserReadingProfile
+ {
+ UserId = userId,
+ TotalWordsRead = words,
+ TotalActiveSeconds = seconds,
+ UpdatedAtUtc = now,
+ };
+ context.UserReadingProfiles.Add(created);
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken);
+ }
+ catch (DbUpdateException)
+ {
+ // Most likely a concurrent request created the row between our UPDATE and INSERT.
+ context.Entry(created).State = EntityState.Detached;
+ if (await TryAddToExistingAsync(userId, words, seconds, now, cancellationToken) == 0)
+ {
+ throw; // Not a duplicate key (for example the user no longer exists).
+ }
+ }
+ }
+
+ private Task TryAddToExistingAsync(
+ string userId, long words, long seconds, DateTime now, CancellationToken cancellationToken) =>
+ context.UserReadingProfiles
+ .Where(p => p.UserId == userId)
+ .ExecuteUpdateAsync(
+ setters => setters
+ .SetProperty(p => p.TotalWordsRead, p => p.TotalWordsRead + words)
+ .SetProperty(p => p.TotalActiveSeconds, p => p.TotalActiveSeconds + seconds)
+ .SetProperty(p => p.UpdatedAtUtc, now),
+ cancellationToken);
+}
diff --git a/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs
new file mode 100644
index 00000000..6a18309e
--- /dev/null
+++ b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs
@@ -0,0 +1,44 @@
+namespace EssentialCSharp.Web.Services;
+
+/// Words and seconds credited to a reader for one accepted interval.
+public readonly record struct ReadingCredit(int Words, int Seconds);
+
+///
+/// Pure outlier rules applied to each reported reading interval.
+///
+public static class ReadingSpeedPolicy
+{
+ ///
+ /// Decides how much of an interval to credit.
+ /// Returns when the interval is not usable (non-positive input or faster
+ /// than ). Intervals slower than
+ /// times are credited
+ /// with fewer seconds than their wall-clock time so idle stretches do not drag the average down.
+ ///
+ /// The reader's current WPM, or 0 when unknown.
+ public static ReadingCredit? Evaluate(int words, int seconds, double referenceWpm, ReadingOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (words <= 0 || seconds <= 0)
+ {
+ return null;
+ }
+
+ double intervalWpm = words / (seconds / 60.0);
+ if (intervalWpm > options.HardCutoffWpm)
+ {
+ return null;
+ }
+
+ double slowThreshold = options.SlowOutlierFactor * referenceWpm;
+ if (referenceWpm > 0 && intervalWpm < slowThreshold)
+ {
+ // words >= 1 and slowThreshold > 0 here, so the ceiling is at least 1.
+ double clampedSeconds = Math.Ceiling(words / slowThreshold * 60.0);
+ return new ReadingCredit(words, (int)Math.Min(seconds, clampedSeconds));
+ }
+
+ return new ReadingCredit(words, seconds);
+ }
+}
diff --git a/EssentialCSharp.Web/Services/WordCountService.cs b/EssentialCSharp.Web/Services/WordCountService.cs
new file mode 100644
index 00000000..7f9a33b5
--- /dev/null
+++ b/EssentialCSharp.Web/Services/WordCountService.cs
@@ -0,0 +1,185 @@
+using System.Collections.Frozen;
+using HtmlAgilityPack;
+using Microsoft.Extensions.Hosting;
+
+namespace EssentialCSharp.Web.Services;
+
+///
+/// Computes prose-only word counts for all content pages when the host starts and caches
+/// them in an immutable snapshot. <pre> , <script> , <style>
+/// and generated .code-block-section listings are excluded because they are read very
+/// differently from prose and would skew WPM estimates. Inline <code> inside prose
+/// is counted, since readers do read those identifiers as part of the sentence.
+///
+public sealed partial class WordCountService(
+ ISiteMappingService siteMappingService,
+ IWebHostEnvironment hostingEnvironment,
+ ILogger logger) : IWordCountService, IHostedLifecycleService
+{
+ private const string ExcludedXPath =
+ "//pre | //script | //style | //*[contains(concat(' ', normalize-space(@class), ' '), ' code-block-section ')]";
+
+ private volatile Snapshot _snapshot = new(FrozenDictionary.Empty, 0);
+
+ public PageWordStats? GetPageStats(string pageKey) =>
+ _snapshot.Pages.GetValueOrDefault(pageKey);
+
+ public int GetPageWordCount(string pageKey) => GetPageStats(pageKey)?.PageWords ?? 0;
+
+ public int GetBookWordCount() => _snapshot.BookWords;
+
+ ///
+ /// Reads every mapped content page and publishes the word-count snapshot. A page that is
+ /// missing or cannot be parsed is logged and counted as zero words; it never fails the build.
+ ///
+ public Task BuildAsync(CancellationToken cancellationToken = default)
+ {
+ // Walk mappings in canonical reading order: chapter -> page -> order-on-page.
+ IEnumerable orderedMappings = siteMappingService.SiteMappings
+ .OrderBy(m => m.ChapterNumber)
+ .ThenBy(m => m.PageNumber)
+ .ThenBy(m => m.OrderOnPage);
+
+ // One entry per distinct file, in reading order. Later mappings of the same file only add keys.
+ Dictionary filesByPath = new(StringComparer.Ordinal);
+ List files = [];
+ Dictionary fileByKey = new(StringComparer.Ordinal);
+
+ foreach (SiteMapping mapping in orderedMappings)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string? path = ResolvePath(mapping);
+ if (path is null)
+ {
+ LogPathEscapesRoot(logger, (mapping.Keys ?? []).FirstOrDefault() ?? mapping.PrimaryKey);
+ continue;
+ }
+
+ if (!filesByPath.TryGetValue(path, out FileEntry? entry))
+ {
+ entry = new FileEntry(mapping.ChapterNumber, CountProseWords(path));
+ filesByPath[path] = entry;
+ files.Add(entry);
+ }
+
+ foreach (string key in (mapping.Keys ?? []).Append(mapping.PrimaryKey))
+ {
+ // First mapping wins if the sitemap contains a key collision.
+ if (!string.IsNullOrWhiteSpace(key))
+ {
+ fileByKey.TryAdd(key, entry);
+ }
+ }
+ }
+
+ Dictionary chapterTotals = [];
+ foreach (FileEntry file in files)
+ {
+ chapterTotals[file.Chapter] = chapterTotals.GetValueOrDefault(file.Chapter) + file.Words;
+ }
+ int bookWords = chapterTotals.Values.Sum();
+
+ Dictionary statsByFile = new(ReferenceEqualityComparer.Instance);
+ Dictionary chapterRunning = [];
+ int wordsBefore = 0;
+ foreach (FileEntry file in files)
+ {
+ int chapterStart = chapterRunning.GetValueOrDefault(file.Chapter);
+ statsByFile[file] = new PageWordStats(file.Words, chapterTotals[file.Chapter], wordsBefore, chapterStart, bookWords);
+ chapterRunning[file.Chapter] = chapterStart + file.Words;
+ wordsBefore += file.Words;
+ }
+
+ _snapshot = new Snapshot(
+ fileByKey.ToFrozenDictionary(kv => kv.Key, kv => statsByFile[kv.Value], StringComparer.Ordinal),
+ bookWords);
+
+ return Task.CompletedTask;
+ }
+
+ // ---- Hosting: build before the host starts accepting requests ----
+
+ public Task StartingAsync(CancellationToken cancellationToken) => BuildAsync(cancellationToken);
+ public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ // ---- Implementation ----
+
+ /// Returns a normalized, '/'-separated absolute path, or null if it leaves the content root.
+ private string? ResolvePath(SiteMapping mapping)
+ {
+ string root = Path.GetFullPath(hostingEnvironment.ContentRootPath);
+ string full = Path.GetFullPath(Path.Join(root, Path.Join(mapping.PagePath)));
+ string relative = Path.GetRelativePath(root, full);
+ return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)
+ ? null
+ : full.Replace('\\', '/');
+ }
+
+ private int CountProseWords(string filePath)
+ {
+ try
+ {
+ if (!File.Exists(filePath))
+ {
+ LogFileMissing(logger, filePath);
+ return 0;
+ }
+
+ HtmlDocument doc = new();
+ doc.Load(filePath);
+
+ HtmlNode root = doc.DocumentNode.SelectSingleNode("//body") ?? doc.DocumentNode;
+
+ foreach (HtmlNode node in root.SelectNodes(ExcludedXPath)?.ToList() ?? [])
+ {
+ node.Remove();
+ }
+
+ return CountWords(HtmlEntity.DeEntitize(root.InnerText));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException)
+ {
+ LogFileUnreadable(logger, ex, filePath);
+ return 0;
+ }
+ }
+
+ private static int CountWords(string text)
+ {
+ int count = 0;
+ bool inWord = false;
+ foreach (char c in text)
+ {
+ if (char.IsWhiteSpace(c))
+ {
+ inWord = false;
+ }
+ else if (!inWord)
+ {
+ inWord = true;
+ count++;
+ }
+ }
+ return count;
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Skipping word count for mapping '{Key}': page path escapes the content root.")]
+ private static partial void LogPathEscapesRoot(ILogger logger, string? key);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Content file '{FilePath}' not found; counting it as 0 words.")]
+ private static partial void LogFileMissing(ILogger logger, string filePath);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not read content file '{FilePath}'; counting it as 0 words.")]
+ private static partial void LogFileUnreadable(ILogger logger, Exception exception, string filePath);
+
+ private sealed record FileEntry(int Chapter, int Words);
+
+ private sealed record Snapshot(
+ FrozenDictionary Pages,
+ int BookWords);
+}
diff --git a/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs
new file mode 100644
index 00000000..f3809836
--- /dev/null
+++ b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs
@@ -0,0 +1,85 @@
+using System.Security.Claims;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using EssentialCSharp.Web.Services;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.ViewComponents;
+
+///
+/// Emits window.READING_CONTEXT for the reading-time tracker. Renders nothing on pages that
+/// are not book content (no ViewBag.CurrentPageKey ), such as Identity pages and errors.
+///
+public sealed partial class ReadingContextViewComponent(
+ IWordCountService wordCountService,
+ IReadingProfileService readingProfileService,
+ SignInManager signInManager,
+ IOptions options,
+ ILogger logger) : ViewComponent
+{
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the reading profile for the reading context; continuing without a personal WPM")]
+ private static partial void LogProfileUnavailable(ILogger logger, Exception exception);
+
+ public async Task InvokeAsync()
+ {
+ if (ViewData["CurrentPageKey"] is not string { Length: > 0 } pageKey)
+ {
+ return Content(string.Empty);
+ }
+
+ bool isAuthenticated = signInManager.IsSignedIn(UserClaimsPrincipal);
+ string? userId = isAuthenticated ? UserClaimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) : null;
+ int? wpm = userId is null ? null : await GetWpmAsync(userId);
+ ReadingOptions reading = options.Value;
+
+ PageWordStats? stats = wordCountService.GetPageStats(pageKey);
+ return View(new ReadingContextViewModel(
+ IsAuthenticated: isAuthenticated,
+ PageKey: pageKey,
+ PageWords: stats?.PageWords ?? 0,
+ ChapterWords: stats?.ChapterWords ?? 0,
+ WordsBeforePage: stats?.WordsBeforePage ?? 0,
+ ChapterStartWords: stats?.ChapterStartWords ?? 0,
+ BookWords: stats?.BookWords ?? wordCountService.GetBookWordCount(),
+ Wpm: wpm,
+ DefaultWpm: reading.DefaultWpm,
+ MaxCreditWpm: reading.MaxCreditWpm,
+ IdleThresholdSeconds: reading.IdleThresholdSeconds,
+ PeriodicFlushActiveSeconds: reading.PeriodicFlushActiveSeconds));
+ }
+
+ /// The signed-in reader's derived WPM rounded to whole words per minute, or null when unknown.
+ private async Task GetWpmAsync(string userId)
+ {
+ try
+ {
+ ReadingProfileSummary profile = await readingProfileService.GetProfileAsync(userId, HttpContext.RequestAborted);
+ return profile.Wpm is { } value ? (int)Math.Round(value) : null;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // A reading-profile outage must not break rendering of book pages.
+ LogProfileUnavailable(logger, ex);
+ return null;
+ }
+ }
+}
+
+///
+/// Serialized (camelCase) as window.READING_CONTEXT ; property names are a contract with the client.
+/// is null for anonymous readers or readers with no recorded reading time.
+///
+public record ReadingContextViewModel(
+ bool IsAuthenticated,
+ string PageKey,
+ int PageWords,
+ int ChapterWords,
+ int WordsBeforePage,
+ int ChapterStartWords,
+ int BookWords,
+ int? Wpm,
+ int DefaultWpm,
+ int MaxCreditWpm,
+ int IdleThresholdSeconds,
+ int PeriodicFlushActiveSeconds);
diff --git a/EssentialCSharp.Web/Views/Home/Index.cshtml b/EssentialCSharp.Web/Views/Home/Index.cshtml
index af43c343..0c03ced6 100644
--- a/EssentialCSharp.Web/Views/Home/Index.cshtml
+++ b/EssentialCSharp.Web/Views/Home/Index.cshtml
@@ -7,4 +7,6 @@
@ViewBag.PageTitle
}
-@Html.Raw(ViewBag.Contents)
+
+ @Html.Raw(ViewBag.Contents)
+
diff --git a/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml b/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml
new file mode 100644
index 00000000..585baea1
--- /dev/null
+++ b/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml
@@ -0,0 +1,4 @@
+@model EssentialCSharp.Web.ViewComponents.ReadingContextViewModel
+
diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml
index a855c9b4..debd66a5 100644
--- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml
+++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml
@@ -6,10 +6,12 @@
@using EssentialCSharp.Common
@using Microsoft.AspNetCore.Identity
@using EssentialCSharp.Web.Areas.Identity.Data
+@using Microsoft.AspNetCore.Antiforgery
@using Microsoft.Extensions.Configuration
@using Microsoft.Extensions.Options
@inject ISiteMappingService _SiteMappings
@inject SignInManager SignInManager
+@inject IAntiforgery Antiforgery
@inject IConfiguration Configuration
@inject IOptions _CaptchaOptions
@inject IOptions SiteSettings
@@ -28,6 +30,12 @@
}
+ @if (SignInManager.IsSignedIn(User) && (ViewData["CurrentPageKey"] is string { Length: > 0 } || ViewData["NeedsReadingCsrf"] is true))
+ {
+ // Only the reading tracker (content pages) and the reset button (ViewData["NeedsReadingCsrf"]) call the
+ // antiforgery-protected reading API; GetAndStoreTokens sets cookie/no-cache headers, so never call it elsewhere.
+
+ }
@if (Context.Response.StatusCode < 400 && !Context.Request.Path.StartsWithSegments("/Identity"))
@@ -222,6 +230,7 @@
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey);
+ @await Component.InvokeAsync("ReadingContext")
diff --git a/EssentialCSharp.Web/package-lock.json b/EssentialCSharp.Web/package-lock.json
index 0d5f0b8a..a648b26d 100644
--- a/EssentialCSharp.Web/package-lock.json
+++ b/EssentialCSharp.Web/package-lock.json
@@ -18,7 +18,9 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.10",
- "vite": "8.3.5"
+ "happy-dom": "21.0.4",
+ "vite": "8.3.5",
+ "vitest": "5.1.0"
},
"engines": {
"node": ">=22.0.0"
@@ -70,12 +72,31 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@mdi/font": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
@@ -202,9 +223,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -222,9 +240,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -242,9 +257,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -262,9 +274,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -282,9 +291,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -302,9 +308,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -372,12 +375,58 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
+ "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.6",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
+ "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz",
@@ -395,6 +444,60 @@
"vue": "^3.2.25 || ^3.6.0-rc.10"
}
},
+ "node_modules/@vitest/mocker": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz",
+ "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.1.0",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.4.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9 || ^3.0.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
+ "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz",
+ "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==",
+ "dev": true,
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@vue/compiler-core": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz",
@@ -587,6 +690,36 @@
"@zxcvbn-ts/dictionary-compression": "^3.0.1"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -624,12 +757,27 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true
+ },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@@ -670,6 +818,24 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/happy-dom": {
+ "version": "21.0.4",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz",
+ "integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/jquery": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
@@ -819,9 +985,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -843,9 +1006,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -867,9 +1027,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -891,9 +1048,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -988,6 +1142,19 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/obug": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
+ "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1077,6 +1244,30 @@
"node": ">=0.10.0"
}
},
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true
+ },
+ "node_modules/tinybench": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz",
+ "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==",
+ "dev": true,
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1094,6 +1285,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true
+ },
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
@@ -1172,6 +1369,97 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz",
+ "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==",
+ "dev": true,
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.1.0",
+ "chai": "^6.3.0",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.4.3",
+ "obug": "^3.0.0",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.3.0",
+ "tinybench": "^6.2.2",
+ "tinyexec": "^1.3.1",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.1.0",
+ "@vitest/browser-preview": "5.1.0",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.1.0",
+ "@vitest/coverage-v8": "5.1.0",
+ "@vitest/ui": "5.1.0",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
+ "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
"node_modules/vue": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz",
@@ -1222,6 +1510,48 @@
"optional": true
}
}
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
+ "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/EssentialCSharp.Web/package.json b/EssentialCSharp.Web/package.json
index 107fe10d..ccb798f3 100644
--- a/EssentialCSharp.Web/package.json
+++ b/EssentialCSharp.Web/package.json
@@ -7,7 +7,8 @@
},
"scripts": {
"build": "vite build",
- "dev": "vite"
+ "dev": "vite",
+ "test": "vitest run"
},
"dependencies": {
"@mdi/font": "7.4.47",
@@ -22,6 +23,8 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.10",
- "vite": "8.3.5"
+ "happy-dom": "21.0.4",
+ "vite": "8.3.5",
+ "vitest": "5.1.0"
}
}
diff --git a/EssentialCSharp.Web/src/App.vue b/EssentialCSharp.Web/src/App.vue
index ca7929b9..df994d3c 100644
--- a/EssentialCSharp.Web/src/App.vue
+++ b/EssentialCSharp.Web/src/App.vue
@@ -8,10 +8,18 @@ import SnackbarMessage from "./components/SnackbarMessage.vue";
import ChatWidget from "./components/ChatWidget.vue";
import CodeRunnerModal from "./components/CodeRunnerModal.vue";
import { useSiteShell } from "./composables/useSiteShell.js";
+import { READING_TRACKER_KEY, useReadingTracker } from "./composables/useReadingTracker.js";
+import { getReadingContext } from "./utils/readingContext.js";
const shell = reactive(useSiteShell());
provide("shell", shell);
+// Only content pages emit window.READING_CONTEXT; elsewhere the tracker is not created at all.
+const readingContext = getReadingContext();
+if (readingContext) {
+ provide(READING_TRACKER_KEY, useReadingTracker(readingContext));
+}
+
function updateLayoutClasses() {
const siteLayout = document.getElementById("site-layout");
if (!siteLayout) {
diff --git a/EssentialCSharp.Web/src/components/HeaderStatus.vue b/EssentialCSharp.Web/src/components/HeaderStatus.vue
index 8f837b13..768ca491 100644
--- a/EssentialCSharp.Web/src/components/HeaderStatus.vue
+++ b/EssentialCSharp.Web/src/components/HeaderStatus.vue
@@ -1,5 +1,6 @@
+
+
+
+