Phase 2: read API and the MudBlazor pages #4

Merged
k.renner merged 2 commits from feat/read-api into main 2026-09-24 13:38:51 +00:00
22 changed files with 1085 additions and 66 deletions
+103
View File
@@ -0,0 +1,103 @@
using Wiki.Application.Retrieval;
using Wiki.Core.Model;
using Wiki.Core.Parsing;
using Wiki.Core.Ports;
namespace Wiki.Application.Reading;
public sealed record NoteSummary(string Slug, string Title, IReadOnlyList<string> Tags, DateOnly? Updated, float? Score);
public sealed record NoteView(
Document Document,
IReadOnlyList<Link> Outgoing,
IReadOnlyList<Link> Backlinks,
IReadOnlyList<SourceRef> Sources,
IReadOnlyList<string> UnresolvedLinks);
/// <summary>A `sources:` entry, resolved to a raw document when it names one.</summary>
public sealed record SourceRef(string Text, DocumentKind? Kind, string? Slug);
public sealed record RawQueueEntry(string Slug, string Title, DateOnly? Captured, string? Source);
public sealed record ReportSummary(string Slug, string Title, string? Type, DateOnly? Generated, string? Question);
/// <summary>Read side of the vault, straight from the catalog. Search goes through the rank use case.</summary>
public sealed class NoteQueries(IDocumentCatalog catalog, RankUseCase rank)
{
public async Task<IReadOnlyList<NoteSummary>> ListAsync(string? tag, int limit, CancellationToken ct)
{
var docs = await catalog.ListDocumentsAsync(DocumentKind.Wiki, ct);
IEnumerable<Document> q = docs;
if (!string.IsNullOrWhiteSpace(tag))
q = q.Where(d => d.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase));
return q.OrderByDescending(d => d.Updated ?? DateOnly.MinValue).ThenBy(d => d.Slug)
.Take(Math.Clamp(limit, 1, 500))
.Select(d => new NoteSummary(d.Slug, d.Title, d.Tags, d.Updated, null))
.ToList();
}
public async Task<IReadOnlyList<NoteSummary>> SearchAsync(IEmbeddingModel model, string query, int limit, CancellationToken ct)
{
var rows = await rank.RankAsync(model, query, limit, ct);
var result = new List<NoteSummary>(rows.Count);
foreach (var r in rows)
{
var d = await catalog.GetAsync(DocumentKind.Wiki, r.Slug, ct);
result.Add(new NoteSummary(r.Slug, r.Title, d?.Tags ?? [], d?.Updated, r.Score));
}
return result;
}
public async Task<NoteView?> GetAsync(string slug, CancellationToken ct)
{
var doc = await catalog.GetAsync(DocumentKind.Wiki, slug, ct);
if (doc is null) return null;
var outgoing = await catalog.OutgoingLinksAsync(slug, ct);
var backlinks = await catalog.BacklinksAsync(slug, ct);
var unresolved = new List<string>();
foreach (var target in outgoing.Select(l => l.ToSlug).Distinct())
{
if (await catalog.GetAsync(DocumentKind.Wiki, target, ct) is null) unresolved.Add(target);
}
var sources = new List<SourceRef>(doc.Sources.Count);
foreach (var s in doc.Sources)
sources.Add(await ResolveSourceAsync(s, ct));
return new NoteView(doc, outgoing, backlinks, sources, unresolved);
}
public Task<IReadOnlyDictionary<string, int>> TagsAsync(CancellationToken ct) => catalog.TagCountsAsync(ct);
public async Task<IReadOnlyList<RawQueueEntry>> RawQueueAsync(CancellationToken ct)
{
var docs = await catalog.ListDocumentsAsync(DocumentKind.Raw, ct);
return docs.OrderBy(d => d.Slug)
.Select(d => new RawQueueEntry(d.Slug, d.Title, DocumentParser.ParseDate(Scalar(d, "captured")), Scalar(d, "source")))
.ToList();
}
public async Task<IReadOnlyList<ReportSummary>> ReportsAsync(string? type, int limit, CancellationToken ct)
{
var docs = await catalog.ListDocumentsAsync(DocumentKind.Output, ct);
IEnumerable<Document> q = docs;
if (!string.IsNullOrWhiteSpace(type))
q = q.Where(d => string.Equals(d.Type, type, StringComparison.OrdinalIgnoreCase));
return q.OrderByDescending(d => d.Slug)
.Take(Math.Clamp(limit, 1, 500))
.Select(d => new ReportSummary(d.Slug, d.Title, d.Type, DocumentParser.ParseDate(Scalar(d, "generated")) ?? DateFromSlug(d.Slug), Scalar(d, "question")))
.ToList();
}
public Task<Document?> GetDocumentAsync(DocumentKind kind, string slug, CancellationToken ct) => catalog.GetAsync(kind, slug, ct);
private async Task<SourceRef> ResolveSourceAsync(string source, CancellationToken ct)
{
var classified = DocumentParser.Classify(source);
if (classified is { } c && await catalog.GetAsync(c.Kind, c.Slug, ct) is not null)
return new SourceRef(source, c.Kind, c.Slug);
return new SourceRef(source, null, null);
}
private static string? Scalar(Document d, string key) => d.Frontmatter.TryGetValue(key, out var v) ? v.Scalar : null;
private static DateOnly? DateFromSlug(string slug) => slug.Length >= 10 ? DocumentParser.ParseDate(slug[..10]) : null;
}
+1
View File
@@ -9,6 +9,7 @@ public static class ApiV1
public static void MapApiV1(this WebApplication app)
{
var v1 = app.MapGroup("/api/v1");
v1.MapReadEndpoints();
v1.MapGet("/status", async (StatusQuery status, CancellationToken ct) =>
Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).AllowAnonymous();
+67
View File
@@ -0,0 +1,67 @@
using Wiki.Application.Reading;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Host.Api;
/// <summary>The read API: notes, a note with its graph, tags, the raw queue and the reports.</summary>
public static class ReadEndpoints
{
public static void MapReadEndpoints(this RouteGroupBuilder v1)
{
v1.MapGet("/notes", async (NoteQueries queries, IEmbeddingModel active, IServiceProvider services, string? q, string? tag, int? limit, string? model, CancellationToken ct) =>
{
var take = limit ?? 50;
if (!string.IsNullOrWhiteSpace(q))
{
var m = active;
if (!string.IsNullOrEmpty(model))
{
m = services.GetKeyedService<IEmbeddingModel>(model);
if (m is null) return Results.BadRequest(new { error = $"unknown model '{model}'" });
}
return Results.Ok(await queries.SearchAsync(m, q, take, ct));
}
return Results.Ok(await queries.ListAsync(tag, take, ct));
}).AllowAnonymous();
v1.MapGet("/notes/{slug}", async (NoteQueries queries, string slug, CancellationToken ct) =>
{
var view = await queries.GetAsync(slug, ct);
if (view is null) return Results.NotFound(new { error = "no such note" });
var d = view.Document;
return Results.Ok(new
{
d.Slug, d.Title, d.Type, d.Tags, d.Aliases, d.Related, d.Updated, d.ContentHash, d.Path,
frontmatter = d.Frontmatter.ToDictionary(kv => kv.Key, kv => kv.Value.IsList ? (object)kv.Value.List! : kv.Value.Scalar),
d.Body,
links = view.Outgoing.Select(l => new { l.ToSlug, kind = l.Kind.ToString(), l.Line }),
backlinks = view.Backlinks.Select(l => new { l.FromSlug, kind = l.Kind.ToString(), l.Line }),
sources = view.Sources.Select(s => new { s.Text, kind = s.Kind?.ToString(), s.Slug }),
unresolved = view.UnresolvedLinks,
});
}).AllowAnonymous();
v1.MapGet("/tags", async (NoteQueries queries, CancellationToken ct) =>
Results.Ok((await queries.TagsAsync(ct)).OrderByDescending(kv => kv.Value).ThenBy(kv => kv.Key).Select(kv => new { tag = kv.Key, count = kv.Value })))
.AllowAnonymous();
v1.MapGet("/raw", async (NoteQueries queries, CancellationToken ct) =>
Results.Ok(await queries.RawQueueAsync(ct))).AllowAnonymous();
v1.MapGet("/raw/{slug}", async (NoteQueries queries, string slug, CancellationToken ct) =>
{
var doc = await queries.GetDocumentAsync(DocumentKind.Raw, slug, ct) ?? await queries.GetDocumentAsync(DocumentKind.RawProcessed, slug, ct);
return doc is null ? Results.NotFound(new { error = "no such raw source" }) : Results.Ok(new { doc.Slug, doc.Title, doc.Kind, doc.Path, doc.Body });
}).AllowAnonymous();
v1.MapGet("/reports", async (NoteQueries queries, string? type, int? limit, CancellationToken ct) =>
Results.Ok(await queries.ReportsAsync(type, limit ?? 50, ct))).AllowAnonymous();
v1.MapGet("/reports/{slug}", async (NoteQueries queries, string slug, CancellationToken ct) =>
{
var doc = await queries.GetDocumentAsync(DocumentKind.Output, slug, ct);
return doc is null ? Results.NotFound(new { error = "no such report" }) : Results.Ok(new { doc.Slug, doc.Title, doc.Type, doc.Path, doc.Sources, doc.Body });
}).AllowAnonymous();
}
}
@@ -7,10 +7,17 @@
<MudLayout>
<MudAppBar Elevation="1">
<MudText Typo="Typo.h6">Wiki</MudText>
<MudSpacer />
<MudLink Href="/" Color="Color.Inherit" Class="mx-2">Status</MudLink>
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(() => _drawerOpen = !_drawerOpen)" />
<MudText Typo="Typo.h6" Class="ms-2">Wiki</MudText>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="1" Variant="DrawerVariant.Mini" OpenMiniOnHover>
<MudNavMenu Color="Color.Primary" Bordered="false">
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Outlined.Search">Search</MudNavLink>
<MudNavLink Href="browse" Icon="@Icons.Material.Outlined.Label">Browse</MudNavLink>
<MudNavLink Href="maintenance" Icon="@Icons.Material.Outlined.Build">Maintenance</MudNavLink>
<MudNavLink Href="status" Icon="@Icons.Material.Outlined.MonitorHeart">Status</MudNavLink>
</MudNavMenu>
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
@Body
@@ -23,3 +30,7 @@
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
private bool _drawerOpen = true;
}
@@ -0,0 +1,70 @@
@page "/browse"
@rendermode InteractiveServer
@inject NoteQueries Notes
<PageTitle>Browse</PageTitle>
<MudGrid>
<MudItem xs="12" md="3">
<MudText Typo="Typo.h6" GutterBottom="true">Tags</MudText>
@if (_tags is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudList T="string">
<MudListItem T="string" Href="/browse" Text="All notes" Icon="@Icons.Material.Outlined.List" />
@foreach (var (tag, count) in _tags)
{
<MudListItem T="string" Href="@($"/browse?tag={Uri.EscapeDataString(tag)}")" Text="@($"{tag} ({count})")" />
}
</MudList>
}
</MudItem>
<MudItem xs="12" md="9">
<MudText Typo="Typo.h4" GutterBottom="true">@(_currentTag is null ? "Recently updated" : $"Tag: {_currentTag}")</MudText>
@if (_notes is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (_notes.Count == 0)
{
<MudAlert Severity="Severity.Info">No notes.</MudAlert>
}
else
{
@foreach (var note in _notes)
{
<NoteCard Note="note" />
}
}
</MudItem>
</MudGrid>
@code {
[SupplyParameterFromQuery(Name = "tag")]
public string? Tag { get; set; }
private IReadOnlyList<(string Tag, int Count)>? _tags;
private IReadOnlyList<NoteSummary>? _notes;
private string? _currentTag;
private bool _initialized;
protected override async Task OnParametersSetAsync()
{
_tags ??= (await Notes.TagsAsync(CancellationToken.None))
.OrderByDescending(kv => kv.Value)
.ThenBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => (kv.Key, kv.Value))
.ToList();
var normalized = string.IsNullOrWhiteSpace(Tag) ? null : Tag;
if (_initialized && normalized == _currentTag) return;
_initialized = true;
_currentTag = normalized;
_notes = _currentTag is null
? await Notes.ListAsync(null, 100, CancellationToken.None)
: await Notes.ListAsync(_currentTag, 200, CancellationToken.None);
}
}
+66 -63
View File
@@ -1,78 +1,81 @@
@page "/"
@rendermode InteractiveServer
@using Wiki.Application.Status
@inject StatusQuery Status
@inject NoteQueries Notes
@inject IEmbeddingModel Embedding
<PageTitle>Wiki status</PageTitle>
<PageTitle>Search</PageTitle>
<MudText Typo="Typo.h4" GutterBottom="true">Status</MudText>
<MudText Typo="Typo.h4" GutterBottom="true">Search</MudText>
@if (_status is null)
<form @onsubmit="SearchAsync" @onsubmit:preventDefault="true">
<MudStack Row AlignItems="AlignItems.Start" Spacing="2" Class="mb-4">
<MudTextField T="string" @bind-Value="_query" Immediate="true" Label="Search the wiki" Variant="Variant.Outlined"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Class="flex-grow-1" />
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@_loading">Search</MudButton>
</MudStack>
</form>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" />
<MudProgressLinear Indeterminate="true" Class="mb-4" />
}
else
@if (_error is not null)
{
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Index</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr><td>Ready</td><td>@(_status.Ready ? "yes" : "no")</td></tr>
<tr><td>Commit</td><td><code>@(_status.Commit is { Length: > 8 } c ? c[..8] : _status.Commit)</code></td></tr>
<tr><td>Synced</td><td>@(_status.SyncedAt?.ToString("u") ?? "never") (@(_status.AgeSeconds?.ToString("0") ?? "-") s ago)</td></tr>
<tr><td>Syncing</td><td>@(_status.Syncing ? "yes" : "no")</td></tr>
<tr><td>Notes</td><td>@_status.Notes</td></tr>
<tr><td>Raw queue</td><td>@_status.RawQueue</td></tr>
<tr><td>Outputs</td><td>@_status.Outputs</td></tr>
@if (_status.LastError is not null)
{
<tr><td>Last error</td><td><MudText Color="Color.Error">@_status.LastError</MudText></td></tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Embedding models</MudText>
<MudSimpleTable Dense="true">
<tbody>
@foreach (var i in _status.Indexes)
{
<tr><td>@i.ModelId @(i.Active ? "(active)" : "")</td><td>@i.Notes vectors</td></tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Adapters</MudText>
<MudSimpleTable Dense="true">
<tbody>
@foreach (var a in _status.Adapters)
{
<tr>
<td>@a.Adapter</td>
<td><MudChip T="string" Color="@(a.Ok ? Color.Success : Color.Error)" Size="Size.Small">@(a.Ok ? "ok" : "failing")</MudChip></td>
<td>@a.Latency.TotalMilliseconds.ToString("0") ms</td>
<td>@a.Detail</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
<MudAlert Severity="Severity.Error" Class="mb-4">Search failed: @_error</MudAlert>
}
@if (_results is not null)
{
if (string.IsNullOrWhiteSpace(_query))
{
<MudText Typo="Typo.subtitle2" Class="mb-2">Recently updated</MudText>
}
if (_results.Count == 0)
{
<MudAlert Severity="Severity.Info">No notes found.</MudAlert>
}
else
{
@foreach (var note in _results)
{
<NoteCard Note="note" />
}
}
}
@code {
private ServiceStatus? _status;
private string _query = "";
private bool _loading;
private string? _error;
private IReadOnlyList<NoteSummary>? _results;
protected override async Task OnInitializedAsync()
protected override async Task OnInitializedAsync() => await LoadRecentAsync();
private async Task LoadRecentAsync() => _results = await Notes.ListAsync(null, 30, CancellationToken.None);
private async Task SearchAsync()
{
_status = await Status.GetAsync(includeAdapterChecks: true, CancellationToken.None);
_error = null;
if (string.IsNullOrWhiteSpace(_query))
{
await LoadRecentAsync();
return;
}
_loading = true;
_results = null;
try
{
_results = await Notes.SearchAsync(Embedding, _query, 20, CancellationToken.None);
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_loading = false;
}
}
}
@@ -0,0 +1,93 @@
@page "/maintenance"
@rendermode InteractiveServer
@inject NoteQueries Notes
@inject StatusQuery Status
<PageTitle>Maintenance</PageTitle>
<MudText Typo="Typo.h4" GutterBottom="true">Maintenance</MudText>
<MudText Typo="Typo.h5" Class="mt-4" GutterBottom="true">Raw queue</MudText>
@if (_raw is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (_raw.Count == 0)
{
<MudAlert Severity="Severity.Success">Empty, nothing waiting for distillation.</MudAlert>
}
else
{
<MudList T="string">
@foreach (var r in _raw)
{
<MudListItem T="string">
<MudLink Href="@($"/raw/{r.Slug}")">@r.Title</MudLink>
@if (r.Captured is { } c)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">captured @c.ToString("yyyy-MM-dd")</MudText>
}
@if (r.Source is not null)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">(@r.Source)</MudText>
}
</MudListItem>
}
</MudList>
}
<MudText Typo="Typo.h5" Class="mt-6" GutterBottom="true">Reports</MudText>
@if (_reports is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (_reports.Count == 0)
{
<MudAlert Severity="Severity.Info">No reports yet.</MudAlert>
}
else
{
<MudList T="string">
@foreach (var r in _reports)
{
<MudListItem T="string">
@if (r.Type is not null)
{
<MudChip T="string" Size="Size.Small" Class="mr-2">@r.Type</MudChip>
}
<MudLink Href="@($"/reports/{r.Slug}")">@r.Title</MudLink>
@if (r.Generated is { } g)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">@g.ToString("yyyy-MM-dd")</MudText>
}
@if (r.Question is not null)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2"><em>@r.Question</em></MudText>
}
</MudListItem>
}
</MudList>
}
<MudText Typo="Typo.h5" Class="mt-6" GutterBottom="true">Index status</MudText>
@if (_status is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<StatusPanel Status="_status" />
}
@code {
private IReadOnlyList<RawQueueEntry>? _raw;
private IReadOnlyList<ReportSummary>? _reports;
private ServiceStatus? _status;
protected override async Task OnInitializedAsync()
{
_raw = await Notes.RawQueueAsync(CancellationToken.None);
_reports = await Notes.ReportsAsync(null, 30, CancellationToken.None);
_status = await Status.GetAsync(includeAdapterChecks: true, CancellationToken.None);
}
}
+101
View File
@@ -0,0 +1,101 @@
@page "/notes/{slug}"
@rendermode InteractiveServer
@inject NoteQueries Notes
<PageTitle>@(_view?.Document.Title ?? Slug)</PageTitle>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (_view is null)
{
<MudAlert Severity="Severity.Error">No such note: <code>@Slug</code></MudAlert>
}
else
{
var doc = _view.Document;
<MudText Typo="Typo.h4" GutterBottom="true">@doc.Title</MudText>
<MudStack Row Wrap="Wrap.Wrap" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
@if (doc.Type is not null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Secondary">@doc.Type</MudChip>
}
<TagChips Tags="doc.Tags" />
@if (doc.Updated is { } updated)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Updated @updated.ToString("yyyy-MM-dd")</MudText>
}
@if (doc.Aliases.Count > 0)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Also known as: @string.Join(", ", doc.Aliases)</MudText>
}
</MudStack>
<div class="wiki-body">
@((MarkupString)_html!)
</div>
@if (_view.Sources.Count > 0)
{
<MudText Typo="Typo.h6" Class="mt-6">Sources</MudText>
<MudList T="string" Dense="true">
@foreach (var s in _view.Sources)
{
<MudListItem T="string">
@if (s.Slug is not null)
{
<MudLink Href="@($"/raw/{s.Slug}")">@s.Text</MudLink>
}
else
{
@s.Text
}
</MudListItem>
}
</MudList>
}
@if (_view.Backlinks.Count > 0)
{
<MudText Typo="Typo.h6" Class="mt-6">Backlinks</MudText>
@foreach (var group in _view.Backlinks.GroupBy(l => l.Kind))
{
<MudText Typo="Typo.subtitle2" Class="mt-2">@(group.Key == LinkKind.WikiLink ? "Linked from" : "Related from")</MudText>
<MudList T="string" Dense="true">
@foreach (var link in group)
{
<MudListItem T="string" Href="@($"/notes/{link.FromSlug}")" Text="@link.FromSlug" />
}
</MudList>
}
}
@if (_view.Outgoing.Count > 0)
{
<MudText Typo="Typo.h6" Class="mt-6">Outgoing links</MudText>
<MudList T="string" Dense="true">
@foreach (var link in _view.Outgoing)
{
<MudListItem T="string" Href="@($"/notes/{link.ToSlug}")" Text="@link.ToSlug" />
}
</MudList>
}
}
@code {
[Parameter]
public string Slug { get; set; } = "";
private bool _loading = true;
private NoteView? _view;
private string? _html;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_view = await Notes.GetAsync(Slug, CancellationToken.None);
_html = _view is null ? null : WikiMarkdown.ToHtml(_view.Document.Body, _view.UnresolvedLinks.ToHashSet());
_loading = false;
}
}
+55
View File
@@ -0,0 +1,55 @@
@page "/raw/{slug}"
@rendermode InteractiveServer
@inject NoteQueries Notes
<PageTitle>@(_doc?.Title ?? Slug)</PageTitle>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (_doc is null)
{
<MudAlert Severity="Severity.Error">No such raw source: <code>@Slug</code></MudAlert>
}
else
{
<MudText Typo="Typo.h4" GutterBottom="true">@_doc.Title</MudText>
<MudStack Row Wrap="Wrap.Wrap" Spacing="4" Class="mb-4">
@if (Scalar(_doc, "source") is { } source)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Source: @source</MudText>
}
@if (Scalar(_doc, "captured") is { } captured)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Captured: @captured</MudText>
}
@if (Scalar(_doc, "captured_by") is { } capturedBy)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">By: @capturedBy</MudText>
}
</MudStack>
<div class="wiki-body">
@((MarkupString)_html!)
</div>
}
@code {
[Parameter]
public string Slug { get; set; } = "";
private bool _loading = true;
private Document? _doc;
private string? _html;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_doc = await Notes.GetDocumentAsync(DocumentKind.Raw, Slug, CancellationToken.None);
_html = _doc is null ? null : WikiMarkdown.ToHtml(_doc.Body, new HashSet<string>());
_loading = false;
}
private static string? Scalar(Document d, string key) => d.Frontmatter.TryGetValue(key, out var v) ? v.Scalar : null;
}
@@ -0,0 +1,64 @@
@page "/reports/{slug}"
@rendermode InteractiveServer
@inject NoteQueries Notes
<PageTitle>@(_doc?.Title ?? Slug)</PageTitle>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (_doc is null)
{
<MudAlert Severity="Severity.Error">No such report: <code>@Slug</code></MudAlert>
}
else
{
<MudText Typo="Typo.h4" GutterBottom="true">@_doc.Title</MudText>
<MudStack Row Wrap="Wrap.Wrap" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
@if (_doc.Type is not null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Secondary">@_doc.Type</MudChip>
}
@if (Scalar(_doc, "question") is { } question)
{
<MudText Typo="Typo.body2"><em>@question</em></MudText>
}
</MudStack>
<div class="wiki-body">
@((MarkupString)_html!)
</div>
@if (_doc.Sources.Count > 0)
{
<MudText Typo="Typo.h6" Class="mt-6">Sources</MudText>
<MudList T="string" Dense="true">
@foreach (var s in _doc.Sources)
{
<MudListItem T="string">@s</MudListItem>
}
</MudList>
}
}
@code {
[Parameter]
public string Slug { get; set; } = "";
private bool _loading = true;
private Document? _doc;
private string? _html;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_doc = await Notes.GetDocumentAsync(DocumentKind.Output, Slug, CancellationToken.None);
// Reports are the model's own prose: wikilinks are rendered as resolved note links
// without checking the catalog, per the task's "assuming they exist".
_html = _doc is null ? null : WikiMarkdown.ToHtml(_doc.Body, new HashSet<string>());
_loading = false;
}
private static string? Scalar(Document d, string key) => d.Frontmatter.TryGetValue(key, out var v) ? v.Scalar : null;
}
@@ -0,0 +1,25 @@
@page "/status"
@rendermode InteractiveServer
@inject StatusQuery StatusQuery
<PageTitle>Wiki status</PageTitle>
<MudText Typo="Typo.h4" GutterBottom="true">Status</MudText>
@if (_status is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<StatusPanel Status="_status" />
}
@code {
private ServiceStatus? _status;
protected override async Task OnInitializedAsync()
{
_status = await StatusQuery.GetAsync(includeAdapterChecks: true, CancellationToken.None);
}
}
@@ -0,0 +1,22 @@
<MudPaper Class="pa-4 mb-3" Outlined="true">
<MudStack Row AlignItems="AlignItems.Center" Spacing="2">
<MudLink Href="@($"/notes/{Note.Slug}")" Typo="Typo.h6">@Note.Title</MudLink>
@if (Note.Score is { } score)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@score.ToString("0.00")</MudChip>
}
</MudStack>
<MudStack Row AlignItems="AlignItems.Center" Spacing="2" Class="mt-2">
<TagChips Tags="Note.Tags" />
@if (Note.Updated is { } updated)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Updated @updated.ToString("yyyy-MM-dd")</MudText>
}
</MudStack>
</MudPaper>
@code {
/// <summary>One search or listing result: title (links to the note), score, tags and updated date.</summary>
[Parameter, EditorRequired]
public NoteSummary Note { get; set; } = null!;
}
@@ -0,0 +1,59 @@
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Index</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr><td>Ready</td><td>@(Status.Ready ? "yes" : "no")</td></tr>
<tr><td>Commit</td><td><code>@(Status.Commit is { Length: > 8 } c ? c[..8] : Status.Commit)</code></td></tr>
<tr><td>Synced</td><td>@(Status.SyncedAt?.ToString("u") ?? "never") (@(Status.AgeSeconds?.ToString("0") ?? "-") s ago)</td></tr>
<tr><td>Syncing</td><td>@(Status.Syncing ? "yes" : "no")</td></tr>
<tr><td>Notes</td><td>@Status.Notes</td></tr>
<tr><td>Raw queue</td><td>@Status.RawQueue</td></tr>
<tr><td>Outputs</td><td>@Status.Outputs</td></tr>
@if (Status.LastError is not null)
{
<tr><td>Last error</td><td><MudText Color="Color.Error">@Status.LastError</MudText></td></tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Embedding models</MudText>
<MudSimpleTable Dense="true">
<tbody>
@foreach (var i in Status.Indexes)
{
<tr><td>@i.ModelId @(i.Active ? "(active)" : "")</td><td>@i.Notes vectors</td></tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Adapters</MudText>
<MudSimpleTable Dense="true">
<tbody>
@foreach (var a in Status.Adapters)
{
<tr>
<td>@a.Adapter</td>
<td><MudChip T="string" Color="@(a.Ok ? Color.Success : Color.Error)" Size="Size.Small">@(a.Ok ? "ok" : "failing")</MudChip></td>
<td>@a.Latency.TotalMilliseconds.ToString("0") ms</td>
<td>@a.Detail</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
@code {
/// <summary>Index readiness, per-model vector counts and adapter health, shared by /status and /maintenance.</summary>
[Parameter, EditorRequired]
public ServiceStatus Status { get; set; } = null!;
}
@@ -0,0 +1,15 @@
@if (Tags.Count > 0)
{
<MudStack Row Wrap="Wrap.Wrap" Spacing="1">
@foreach (var tag in Tags)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Href="@($"/browse?tag={Uri.EscapeDataString(tag)}")">@tag</MudChip>
}
</MudStack>
}
@code {
/// <summary>Tags rendered as small chips, each linking to the tag's browse page.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<string> Tags { get; set; } = [];
}
+6
View File
@@ -7,6 +7,12 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using Wiki.Application.Reading
@using Wiki.Application.Status
@using Wiki.Core.Model
@using Wiki.Core.Ports
@using Wiki.Host
@using Wiki.Host.Components
@using Wiki.Host.Components.Layout
@using Wiki.Host.Components.Shared
@using Wiki.Host.Rendering
+1
View File
@@ -65,6 +65,7 @@ builder.Services.AddSingleton(new SyncOptions());
builder.Services.AddSingleton<SyncUseCase>();
builder.Services.AddSingleton<SyncCoordinator>();
builder.Services.AddSingleton<RankUseCase>();
builder.Services.AddSingleton<Wiki.Application.Reading.NoteQueries>();
builder.Services.AddSingleton<StatusQuery>();
builder.Services.AddSingleton<SyncBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncBackgroundService>());
+101
View File
@@ -0,0 +1,101 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Markdig;
namespace Wiki.Host.Rendering;
/// <summary>
/// Renders a vault document body to HTML: `[[slug]]`, `[[slug|alias]]` and `[[slug#heading]]`
/// wikilinks are rewritten to note links (or a muted "no such note" span when unresolved)
/// before the text goes through Markdig. Fenced code blocks and inline code spans are left
/// untouched, exactly like <see cref="Wiki.Core.Parsing.WikiLinks"/> treats them for extraction.
/// </summary>
public static partial class WikiMarkdown
{
[GeneratedRegex(@"\[\[([^\]\[|#]+)(?:#[^\]\[|]*)?(?:\|([^\]\[]*))?\]\]")]
private static partial Regex LinkPattern();
[GeneratedRegex("`[^`]*`")]
private static partial Regex InlineCode();
private const char Marker = '\u0001';
private static readonly MarkdownPipeline Pipeline =
new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
/// <summary>Turns a document body into HTML safe to render via <c>MarkupString</c>.</summary>
public static string ToHtml(string body, ISet<string> unresolvedSlugs)
{
var tokens = new List<(string Slug, string Text, bool Resolved)>();
var rewritten = Rewrite(body, unresolvedSlugs, tokens);
var html = Markdown.ToHtml(rewritten, Pipeline);
return Restore(html, tokens);
}
private static string Rewrite(string body, ISet<string> unresolvedSlugs, List<(string, string, bool)> tokens)
{
var sb = new StringBuilder();
var fenced = false;
var lines = body.Replace("\r\n", "\n").Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmed = line.TrimStart();
if (trimmed.StartsWith("```", StringComparison.Ordinal) || trimmed.StartsWith("~~~", StringComparison.Ordinal))
{
fenced = !fenced;
sb.Append(line);
}
else if (fenced)
{
sb.Append(line);
}
else
{
sb.Append(RewriteLine(line, unresolvedSlugs, tokens));
}
if (i < lines.Length - 1) sb.Append('\n');
}
return sb.ToString();
}
/// <summary>Rewrites wikilinks outside of inline code spans; inline code passes through verbatim.</summary>
private static string RewriteLine(string line, ISet<string> unresolvedSlugs, List<(string, string, bool)> tokens)
{
var result = new StringBuilder();
var pos = 0;
foreach (Match codeMatch in InlineCode().Matches(line))
{
result.Append(RewriteSegment(line[pos..codeMatch.Index], unresolvedSlugs, tokens));
result.Append(codeMatch.Value);
pos = codeMatch.Index + codeMatch.Length;
}
result.Append(RewriteSegment(line[pos..], unresolvedSlugs, tokens));
return result.ToString();
}
private static string RewriteSegment(string segment, ISet<string> unresolvedSlugs, List<(string, string, bool)> tokens) =>
LinkPattern().Replace(segment, m =>
{
var slug = m.Groups[1].Value.Trim();
var alias = m.Groups[2].Success ? m.Groups[2].Value.Trim() : null;
var text = string.IsNullOrEmpty(alias) ? slug : alias;
var resolved = !unresolvedSlugs.Contains(slug);
tokens.Add((slug, text, resolved));
return $"{Marker}{tokens.Count - 1}{Marker}";
});
private static string Restore(string html, List<(string Slug, string Text, bool Resolved)> tokens)
{
for (var i = 0; i < tokens.Count; i++)
{
var (slug, text, resolved) = tokens[i];
var replacement = resolved
? $"<a href=\"/notes/{WebUtility.UrlEncode(slug)}\">{WebUtility.HtmlEncode(text)}</a>"
: $"<span class=\"wiki-unresolved\" title=\"no such note\">{WebUtility.HtmlEncode(text)}</span>";
html = html.Replace($"{Marker}{i}{Marker}", replacement, StringComparison.Ordinal);
}
return html;
}
}
+1
View File
@@ -10,6 +10,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="1.4.0" />
<PackageReference Include="MudBlazor" Version="9.10.0" />
</ItemGroup>
+33
View File
@@ -35,4 +35,37 @@ h1:focus {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
.wiki-body pre {
overflow-x: auto;
padding: 0.75rem 1rem;
border-radius: 4px;
background: var(--mud-palette-background-grey);
}
.wiki-body code {
background: var(--mud-palette-background-grey);
padding: 0.1rem 0.3rem;
border-radius: 4px;
}
.wiki-body pre code {
background: none;
padding: 0;
}
.wiki-body table {
border-collapse: collapse;
}
.wiki-body table td, .wiki-body table th {
border: 1px solid var(--mud-palette-table-lines);
padding: 0.4rem 0.6rem;
}
.wiki-unresolved {
color: var(--mud-palette-text-disabled);
text-decoration: line-through dotted;
cursor: help;
}
@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging.Abstractions;
using Wiki.Application.Reading;
using Wiki.Application.Retrieval;
using Wiki.Application.Sync;
using Wiki.Core.Model;
namespace Wiki.Application.Tests;
public class NoteQueriesTests
{
private readonly FakeStore _store = new();
private readonly FakeCatalog _catalog = new();
private readonly FakeIndex _index = new();
private readonly FakeEmbeddings _model = new("m1");
private NoteQueries Queries => new(_catalog, new RankUseCase(_index, _catalog));
private async Task SeedAsync()
{
_store.Files["wiki/udm-dns.md"] = "---\ntitle: UDM intercepts DNS\ntype: wiki\ntags: [dns, udm]\nsources: [raw/_processed/2026-09-01-dns.md, \"live check\"]\nrelated: [ceph-osd]\nupdated: 2026-09-16\n---\n\n# UDM intercepts DNS\n\nSee [[ceph-osd]] and [[missing-note]].\n";
_store.Files["wiki/ceph-osd.md"] = "---\ntitle: Ceph OSD\ntype: wiki\ntags: [ceph]\nupdated: 2026-09-10\n---\n\n# Ceph OSD\n\nOSD.\n";
_store.Files["raw/_processed/2026-09-01-dns.md"] = "---\ntitle: DNS capture\ntype: raw\nsource: dig session\ncaptured: 2026-09-01\n---\n\ncapture\n";
_store.Files["raw/2026-09-23-pending.md"] = "---\ntitle: Pending\ntype: raw\nsource: shell\ncaptured: 2026-09-23\n---\n\npending\n";
_store.Files["output/2026-09-20-answer.md"] = "---\ntitle: An answer\ntype: output\nquestion: Does the UDM intercept DNS?\ngenerated: 2026-09-20\n---\n\nYes.\n";
_store.Files["output/2026-09-21-wiki-audit.md"] = "---\ntitle: Wiki audit\ntype: audit\ngenerated: 2026-09-21\n---\n\nreport\n";
var sync = new SyncUseCase(_store, _catalog, _index, [_model], TimeProvider.System, NullLogger<SyncUseCase>.Instance);
await sync.RunAsync(false, CancellationToken.None);
}
[Fact]
public async Task ListsByUpdatedAndFiltersByTag()
{
await SeedAsync();
var all = await Queries.ListAsync(null, 10, CancellationToken.None);
Assert.Equal(["udm-dns", "ceph-osd"], all.Select(n => n.Slug));
var ceph = await Queries.ListAsync("CEPH", 10, CancellationToken.None);
Assert.Equal(["ceph-osd"], ceph.Select(n => n.Slug));
}
[Fact]
public async Task NoteViewResolvesLinksBacklinksAndSources()
{
await SeedAsync();
var view = await Queries.GetAsync("udm-dns", CancellationToken.None);
Assert.NotNull(view);
Assert.Equal(["ceph-osd", "ceph-osd", "missing-note"], view.Outgoing.Select(l => l.ToSlug));
Assert.Equal(["missing-note"], view.UnresolvedLinks);
Assert.Equal([(DocumentKind.RawProcessed, "2026-09-01-dns"), (null, null)], view.Sources.Select(s => (s.Kind, s.Slug)));
var ceph = await Queries.GetAsync("ceph-osd", CancellationToken.None);
Assert.Equal(2, ceph!.Backlinks.Count);
Assert.Null(await Queries.GetAsync("nope", CancellationToken.None));
}
[Fact]
public async Task TagsRawQueueAndReports()
{
await SeedAsync();
var tags = await Queries.TagsAsync(CancellationToken.None);
Assert.Equal(1, tags["dns"]);
var raw = await Queries.RawQueueAsync(CancellationToken.None);
var entry = Assert.Single(raw);
Assert.Equal(("2026-09-23-pending", new DateOnly(2026, 9, 23), "shell"), (entry.Slug, entry.Captured, entry.Source));
var reports = await Queries.ReportsAsync(null, 10, CancellationToken.None);
Assert.Equal(["2026-09-21-wiki-audit", "2026-09-20-answer"], reports.Select(r => r.Slug));
var outputs = await Queries.ReportsAsync("output", 10, CancellationToken.None);
Assert.Equal("Does the UDM intercept DNS?", Assert.Single(outputs).Question);
}
[Fact]
public async Task SearchCarriesScoresAndTags()
{
await SeedAsync();
var udm = (await _catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!;
var hits = await Queries.SearchAsync(_model, Wiki.Core.Parsing.EmbeddingText.For(udm), 2, CancellationToken.None);
Assert.Equal("udm-dns", hits[0].Slug);
Assert.Equal(["dns", "udm"], hits[0].Tags);
Assert.NotNull(hits[0].Score);
}
}
+41
View File
@@ -125,6 +125,47 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture<WikiHostFact
Assert.False(skipped!.Queued);
}
[Fact]
public async Task ReadApiServesNotesTagsRawAndReports()
{
factory.Store.Files["wiki/ceph-osd.md"] = "---\ntitle: Ceph OSD\ntype: wiki\ntags: [ceph]\nrelated: [udm-dns]\nsources: [raw/_processed/2026-09-01-dns.md]\nupdated: 2026-09-10\n---\n\n# Ceph OSD\n\nSee [[udm-dns]].\n";
factory.Store.Files["wiki/udm-dns.md"] = "---\ntitle: UDM intercepts DNS\ntype: wiki\ntags: [dns]\n---\n\n# UDM intercepts DNS\n\nThe UDM answers.\n";
factory.Store.Files["raw/2026-09-23-pending.md"] = "---\ntitle: Pending\ntype: raw\nsource: shell\ncaptured: 2026-09-23\n---\n\npending\n";
factory.Store.Files["output/2026-09-21-wiki-audit.md"] = "---\ntitle: Wiki audit\ntype: audit\ngenerated: 2026-09-21\n---\n\nreport\n";
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync?force=true");
req.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey);
Assert.Equal(HttpStatusCode.OK, (await Client.SendAsync(req)).StatusCode);
var notes = await Client.GetFromJsonAsync<List<NoteRow>>("/api/v1/notes?tag=ceph");
Assert.Equal("ceph-osd", Assert.Single(notes!).Slug);
var note = await Client.GetFromJsonAsync<NoteBody>("/api/v1/notes/ceph-osd");
Assert.NotNull(note);
Assert.Contains("See [[udm-dns]]", note.Body);
Assert.Equal(2, note.Links.Count);
Assert.Equal(HttpStatusCode.NotFound, (await Client.GetAsync("/api/v1/notes/nope")).StatusCode);
var back = await Client.GetFromJsonAsync<NoteBody>("/api/v1/notes/udm-dns");
Assert.Equal(2, back!.Backlinks.Count);
var tags = await Client.GetFromJsonAsync<List<TagRow>>("/api/v1/tags");
Assert.Contains(tags!, t => t.Tag == "ceph" && t.Count == 1);
var raw = await Client.GetFromJsonAsync<List<RawRow>>("/api/v1/raw");
Assert.Equal("2026-09-23-pending", Assert.Single(raw!).Slug);
var reports = await Client.GetFromJsonAsync<List<ReportRow>>("/api/v1/reports?type=audit");
Assert.Equal("Wiki audit", Assert.Single(reports!).Title);
Assert.Equal(HttpStatusCode.OK, (await Client.GetAsync("/api/v1/reports/2026-09-21-wiki-audit")).StatusCode);
}
private sealed record NoteRow(string Slug, string Title);
private sealed record LinkRow(string? ToSlug, string? FromSlug, string Kind);
private sealed record NoteBody(string Slug, string Body, List<LinkRow> Links, List<LinkRow> Backlinks);
private sealed record TagRow(string Tag, int Count);
private sealed record RawRow(string Slug, string Title);
private sealed record ReportRow(string Slug, string Title, string? Type);
[Fact]
public async Task StatusListsAdaptersAndIndexes()
{
+68
View File
@@ -0,0 +1,68 @@
using Wiki.Host.Rendering;
namespace Wiki.Host.Tests;
public class WikiMarkdownTests
{
[Fact]
public void RewritesAResolvedWikilinkToANoteLink()
{
var html = WikiMarkdown.ToHtml("See [[udm-dns]].", new HashSet<string>());
Assert.Contains("<a href=\"/notes/udm-dns\">udm-dns</a>", html);
}
[Fact]
public void UsesTheAliasAsLinkTextWhenGiven()
{
var html = WikiMarkdown.ToHtml("See [[udm-dns|the UDM note]].", new HashSet<string>());
Assert.Contains("<a href=\"/notes/udm-dns\">the UDM note</a>", html);
}
[Fact]
public void DropsTheHeadingFromTheLinkTextButKeepsTheSlugAsTarget()
{
var html = WikiMarkdown.ToHtml("See [[udm-dns#split-horizon]].", new HashSet<string>());
Assert.Contains("<a href=\"/notes/udm-dns\">udm-dns</a>", html);
}
[Fact]
public void RendersAnUnresolvedLinkAsAMutedSpanInsteadOfAnAnchor()
{
var html = WikiMarkdown.ToHtml("See [[nope]].", new HashSet<string> { "nope" });
Assert.Contains("<span class=\"wiki-unresolved\" title=\"no such note\">nope</span>", html);
Assert.DoesNotContain("<a href=\"/notes/nope\">", html);
}
[Fact]
public void DoesNotRewriteWikilinksInsideAFencedCodeBlock()
{
var html = WikiMarkdown.ToHtml("```\n[[udm-dns]]\n```", new HashSet<string>());
Assert.DoesNotContain("<a href", html);
Assert.Contains("[[udm-dns]]", html);
}
[Fact]
public void DoesNotRewriteWikilinksInsideInlineCode()
{
var html = WikiMarkdown.ToHtml("Use `[[udm-dns]]` as the syntax.", new HashSet<string>());
Assert.DoesNotContain("<a href", html);
Assert.Contains("<code>[[udm-dns]]</code>", html);
}
[Fact]
public void PlainMarkdownStillRendersHeadingsAndLists()
{
var html = WikiMarkdown.ToHtml("# Title\n\n- one\n- two\n", new HashSet<string>());
Assert.Contains("<h1", html);
Assert.Contains("Title</h1>", html);
Assert.Contains("<li>one</li>", html);
Assert.Contains("<li>two</li>", html);
}
[Fact]
public void HtmlEncodesLinkTextToAvoidInjectionThroughAliases()
{
var html = WikiMarkdown.ToHtml("See [[udm-dns|<script>alert(1)</script>]].", new HashSet<string>());
Assert.DoesNotContain("<script>", html);
}
}