Phase 2: read API and the MudBlazor pages #4
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||
<PackageReference Include="MudBlazor" Version="9.10.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user