From 2cd171d3c50fe2b96a636fd2feb2e67a439c5035 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 13:45:55 +0200 Subject: [PATCH 1/2] read api: notes with links, backlinks and resolved sources; tags; raw queue; reports Co-Authored-By: Claude Fable 5.1 --- src/Wiki.Application/Reading/NoteQueries.cs | 103 ++++++++++++++++++ src/Wiki.Host/Api/ApiV1.cs | 1 + src/Wiki.Host/Api/ReadEndpoints.cs | 67 ++++++++++++ src/Wiki.Host/Program.cs | 1 + .../NoteQueriesTests.cs | 79 ++++++++++++++ test/Wiki.Host.Tests/EndpointTests.cs | 41 +++++++ 6 files changed, 292 insertions(+) create mode 100644 src/Wiki.Application/Reading/NoteQueries.cs create mode 100644 src/Wiki.Host/Api/ReadEndpoints.cs create mode 100644 test/Wiki.Application.Tests/NoteQueriesTests.cs diff --git a/src/Wiki.Application/Reading/NoteQueries.cs b/src/Wiki.Application/Reading/NoteQueries.cs new file mode 100644 index 0000000..4cc6587 --- /dev/null +++ b/src/Wiki.Application/Reading/NoteQueries.cs @@ -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 Tags, DateOnly? Updated, float? Score); + +public sealed record NoteView( + Document Document, + IReadOnlyList Outgoing, + IReadOnlyList Backlinks, + IReadOnlyList Sources, + IReadOnlyList UnresolvedLinks); + +/// A `sources:` entry, resolved to a raw document when it names one. +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); + +/// Read side of the vault, straight from the catalog. Search goes through the rank use case. +public sealed class NoteQueries(IDocumentCatalog catalog, RankUseCase rank) +{ + public async Task> ListAsync(string? tag, int limit, CancellationToken ct) + { + var docs = await catalog.ListDocumentsAsync(DocumentKind.Wiki, ct); + IEnumerable 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> SearchAsync(IEmbeddingModel model, string query, int limit, CancellationToken ct) + { + var rows = await rank.RankAsync(model, query, limit, ct); + var result = new List(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 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(); + 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(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> TagsAsync(CancellationToken ct) => catalog.TagCountsAsync(ct); + + public async Task> 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> ReportsAsync(string? type, int limit, CancellationToken ct) + { + var docs = await catalog.ListDocumentsAsync(DocumentKind.Output, ct); + IEnumerable 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 GetDocumentAsync(DocumentKind kind, string slug, CancellationToken ct) => catalog.GetAsync(kind, slug, ct); + + private async Task 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; +} diff --git a/src/Wiki.Host/Api/ApiV1.cs b/src/Wiki.Host/Api/ApiV1.cs index 546db0f..f7e7dff 100644 --- a/src/Wiki.Host/Api/ApiV1.cs +++ b/src/Wiki.Host/Api/ApiV1.cs @@ -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(); diff --git a/src/Wiki.Host/Api/ReadEndpoints.cs b/src/Wiki.Host/Api/ReadEndpoints.cs new file mode 100644 index 0000000..a367240 --- /dev/null +++ b/src/Wiki.Host/Api/ReadEndpoints.cs @@ -0,0 +1,67 @@ +using Wiki.Application.Reading; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Host.Api; + +/// The read API: notes, a note with its graph, tags, the raw queue and the reports. +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(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(); + } +} diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index ecf0d9e..1ec622f 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -65,6 +65,7 @@ builder.Services.AddSingleton(new SyncOptions()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/test/Wiki.Application.Tests/NoteQueriesTests.cs b/test/Wiki.Application.Tests/NoteQueriesTests.cs new file mode 100644 index 0000000..f2685f2 --- /dev/null +++ b/test/Wiki.Application.Tests/NoteQueriesTests.cs @@ -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.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); + } +} diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index d6b9827..a148f8c 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -125,6 +125,47 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture>("/api/v1/notes?tag=ceph"); + Assert.Equal("ceph-osd", Assert.Single(notes!).Slug); + + var note = await Client.GetFromJsonAsync("/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("/api/v1/notes/udm-dns"); + Assert.Equal(2, back!.Backlinks.Count); + + var tags = await Client.GetFromJsonAsync>("/api/v1/tags"); + Assert.Contains(tags!, t => t.Tag == "ceph" && t.Count == 1); + + var raw = await Client.GetFromJsonAsync>("/api/v1/raw"); + Assert.Equal("2026-09-23-pending", Assert.Single(raw!).Slug); + + var reports = await Client.GetFromJsonAsync>("/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 Links, List 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() { -- 2.54.0 From 123850f975316280049791dc79af26e673ff5cc8 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 13:57:14 +0200 Subject: [PATCH 2/2] feat(ui): add MudBlazor read pages for search, notes, browse, raw, reports and maintenance Adds / (search, with recent notes as default view), /notes/{slug}, /browse, /raw/{slug}, /reports/{slug} and /maintenance, all InteractiveServer, built on NoteQueries and IEmbeddingModel injected directly (no HTTP). Moves the former status page from / to /status and factors its markup into a shared StatusPanel used by both /status and /maintenance. Adds a MudDrawer/NavMenu nav in MainLayout. Wikilinks ([[slug]], [[slug|alias]], [[slug#heading]]) are rewritten to note links (or a muted "no such note" span when unresolved) by a new Wiki.Host.Rendering.WikiMarkdown, before the body goes through Markdig with DisableHtml() so note content can't inject script. Fenced code blocks and inline code spans are left untouched. Covered by WikiMarkdownTests. Co-Authored-By: Claude Sonnet 5 --- .../Components/Layout/MainLayout.razor | 17 ++- src/Wiki.Host/Components/Pages/Browse.razor | 70 ++++++++++ src/Wiki.Host/Components/Pages/Home.razor | 129 +++++++++--------- .../Components/Pages/Maintenance.razor | 93 +++++++++++++ src/Wiki.Host/Components/Pages/Note.razor | 101 ++++++++++++++ src/Wiki.Host/Components/Pages/Raw.razor | 55 ++++++++ src/Wiki.Host/Components/Pages/Report.razor | 64 +++++++++ src/Wiki.Host/Components/Pages/Status.razor | 25 ++++ .../Components/Shared/NoteCard.razor | 22 +++ .../Components/Shared/StatusPanel.razor | 59 ++++++++ .../Components/Shared/TagChips.razor | 15 ++ src/Wiki.Host/Components/_Imports.razor | 6 + src/Wiki.Host/Rendering/WikiMarkdown.cs | 101 ++++++++++++++ src/Wiki.Host/Wiki.Host.csproj | 1 + src/Wiki.Host/wwwroot/app.css | 33 +++++ test/Wiki.Host.Tests/WikiMarkdownTests.cs | 68 +++++++++ 16 files changed, 793 insertions(+), 66 deletions(-) create mode 100644 src/Wiki.Host/Components/Pages/Browse.razor create mode 100644 src/Wiki.Host/Components/Pages/Maintenance.razor create mode 100644 src/Wiki.Host/Components/Pages/Note.razor create mode 100644 src/Wiki.Host/Components/Pages/Raw.razor create mode 100644 src/Wiki.Host/Components/Pages/Report.razor create mode 100644 src/Wiki.Host/Components/Pages/Status.razor create mode 100644 src/Wiki.Host/Components/Shared/NoteCard.razor create mode 100644 src/Wiki.Host/Components/Shared/StatusPanel.razor create mode 100644 src/Wiki.Host/Components/Shared/TagChips.razor create mode 100644 src/Wiki.Host/Rendering/WikiMarkdown.cs create mode 100644 test/Wiki.Host.Tests/WikiMarkdownTests.cs diff --git a/src/Wiki.Host/Components/Layout/MainLayout.razor b/src/Wiki.Host/Components/Layout/MainLayout.razor index 35310bf..c22f8bd 100644 --- a/src/Wiki.Host/Components/Layout/MainLayout.razor +++ b/src/Wiki.Host/Components/Layout/MainLayout.razor @@ -7,10 +7,17 @@ - Wiki - - Status + + Wiki + + + Search + Browse + Maintenance + Status + + @Body @@ -23,3 +30,7 @@ Reload 🗙 + +@code { + private bool _drawerOpen = true; +} diff --git a/src/Wiki.Host/Components/Pages/Browse.razor b/src/Wiki.Host/Components/Pages/Browse.razor new file mode 100644 index 0000000..d41f120 --- /dev/null +++ b/src/Wiki.Host/Components/Pages/Browse.razor @@ -0,0 +1,70 @@ +@page "/browse" +@rendermode InteractiveServer +@inject NoteQueries Notes + +Browse + + + + Tags + @if (_tags is null) + { + + } + else + { + + + @foreach (var (tag, count) in _tags) + { + + } + + } + + + @(_currentTag is null ? "Recently updated" : $"Tag: {_currentTag}") + @if (_notes is null) + { + + } + else if (_notes.Count == 0) + { + No notes. + } + else + { + @foreach (var note in _notes) + { + + } + } + + + +@code { + [SupplyParameterFromQuery(Name = "tag")] + public string? Tag { get; set; } + + private IReadOnlyList<(string Tag, int Count)>? _tags; + private IReadOnlyList? _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); + } +} diff --git a/src/Wiki.Host/Components/Pages/Home.razor b/src/Wiki.Host/Components/Pages/Home.razor index eb503f3..eb9b757 100644 --- a/src/Wiki.Host/Components/Pages/Home.razor +++ b/src/Wiki.Host/Components/Pages/Home.razor @@ -1,78 +1,81 @@ @page "/" @rendermode InteractiveServer -@using Wiki.Application.Status -@inject StatusQuery Status +@inject NoteQueries Notes +@inject IEmbeddingModel Embedding -Wiki status +Search -Status +Search -@if (_status is null) +
+ + + Search + +
+ +@if (_loading) { - + } -else + +@if (_error is not null) { - - - - Index - - - Ready@(_status.Ready ? "yes" : "no") - Commit@(_status.Commit is { Length: > 8 } c ? c[..8] : _status.Commit) - Synced@(_status.SyncedAt?.ToString("u") ?? "never") (@(_status.AgeSeconds?.ToString("0") ?? "-") s ago) - Syncing@(_status.Syncing ? "yes" : "no") - Notes@_status.Notes - Raw queue@_status.RawQueue - Outputs@_status.Outputs - @if (_status.LastError is not null) - { - Last error@_status.LastError - } - - - - - - - Embedding models - - - @foreach (var i in _status.Indexes) - { - @i.ModelId @(i.Active ? "(active)" : "")@i.Notes vectors - } - - - - - - - Adapters - - - @foreach (var a in _status.Adapters) - { - - @a.Adapter - @(a.Ok ? "ok" : "failing") - @a.Latency.TotalMilliseconds.ToString("0") ms - @a.Detail - - } - - - - - + Search failed: @_error +} + +@if (_results is not null) +{ + if (string.IsNullOrWhiteSpace(_query)) + { + Recently updated + } + if (_results.Count == 0) + { + No notes found. + } + else + { + @foreach (var note in _results) + { + + } + } } @code { - private ServiceStatus? _status; + private string _query = ""; + private bool _loading; + private string? _error; + private IReadOnlyList? _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; + } } } diff --git a/src/Wiki.Host/Components/Pages/Maintenance.razor b/src/Wiki.Host/Components/Pages/Maintenance.razor new file mode 100644 index 0000000..651d641 --- /dev/null +++ b/src/Wiki.Host/Components/Pages/Maintenance.razor @@ -0,0 +1,93 @@ +@page "/maintenance" +@rendermode InteractiveServer +@inject NoteQueries Notes +@inject StatusQuery Status + +Maintenance + +Maintenance + +Raw queue +@if (_raw is null) +{ + +} +else if (_raw.Count == 0) +{ + Empty, nothing waiting for distillation. +} +else +{ + + @foreach (var r in _raw) + { + + @r.Title + @if (r.Captured is { } c) + { + captured @c.ToString("yyyy-MM-dd") + } + @if (r.Source is not null) + { + (@r.Source) + } + + } + +} + +Reports +@if (_reports is null) +{ + +} +else if (_reports.Count == 0) +{ + No reports yet. +} +else +{ + + @foreach (var r in _reports) + { + + @if (r.Type is not null) + { + @r.Type + } + @r.Title + @if (r.Generated is { } g) + { + @g.ToString("yyyy-MM-dd") + } + @if (r.Question is not null) + { + @r.Question + } + + } + +} + +Index status +@if (_status is null) +{ + +} +else +{ + +} + +@code { + private IReadOnlyList? _raw; + private IReadOnlyList? _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); + } +} diff --git a/src/Wiki.Host/Components/Pages/Note.razor b/src/Wiki.Host/Components/Pages/Note.razor new file mode 100644 index 0000000..3065af0 --- /dev/null +++ b/src/Wiki.Host/Components/Pages/Note.razor @@ -0,0 +1,101 @@ +@page "/notes/{slug}" +@rendermode InteractiveServer +@inject NoteQueries Notes + +@(_view?.Document.Title ?? Slug) + +@if (_loading) +{ + +} +else if (_view is null) +{ + No such note: @Slug +} +else +{ + var doc = _view.Document; + @doc.Title + + @if (doc.Type is not null) + { + @doc.Type + } + + @if (doc.Updated is { } updated) + { + Updated @updated.ToString("yyyy-MM-dd") + } + @if (doc.Aliases.Count > 0) + { + Also known as: @string.Join(", ", doc.Aliases) + } + + +
+ @((MarkupString)_html!) +
+ + @if (_view.Sources.Count > 0) + { + Sources + + @foreach (var s in _view.Sources) + { + + @if (s.Slug is not null) + { + @s.Text + } + else + { + @s.Text + } + + } + + } + + @if (_view.Backlinks.Count > 0) + { + Backlinks + @foreach (var group in _view.Backlinks.GroupBy(l => l.Kind)) + { + @(group.Key == LinkKind.WikiLink ? "Linked from" : "Related from") + + @foreach (var link in group) + { + + } + + } + } + + @if (_view.Outgoing.Count > 0) + { + Outgoing links + + @foreach (var link in _view.Outgoing) + { + + } + + } +} + +@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; + } +} diff --git a/src/Wiki.Host/Components/Pages/Raw.razor b/src/Wiki.Host/Components/Pages/Raw.razor new file mode 100644 index 0000000..d6f49ed --- /dev/null +++ b/src/Wiki.Host/Components/Pages/Raw.razor @@ -0,0 +1,55 @@ +@page "/raw/{slug}" +@rendermode InteractiveServer +@inject NoteQueries Notes + +@(_doc?.Title ?? Slug) + +@if (_loading) +{ + +} +else if (_doc is null) +{ + No such raw source: @Slug +} +else +{ + @_doc.Title + + @if (Scalar(_doc, "source") is { } source) + { + Source: @source + } + @if (Scalar(_doc, "captured") is { } captured) + { + Captured: @captured + } + @if (Scalar(_doc, "captured_by") is { } capturedBy) + { + By: @capturedBy + } + + +
+ @((MarkupString)_html!) +
+} + +@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()); + _loading = false; + } + + private static string? Scalar(Document d, string key) => d.Frontmatter.TryGetValue(key, out var v) ? v.Scalar : null; +} diff --git a/src/Wiki.Host/Components/Pages/Report.razor b/src/Wiki.Host/Components/Pages/Report.razor new file mode 100644 index 0000000..6c1d48a --- /dev/null +++ b/src/Wiki.Host/Components/Pages/Report.razor @@ -0,0 +1,64 @@ +@page "/reports/{slug}" +@rendermode InteractiveServer +@inject NoteQueries Notes + +@(_doc?.Title ?? Slug) + +@if (_loading) +{ + +} +else if (_doc is null) +{ + No such report: @Slug +} +else +{ + @_doc.Title + + @if (_doc.Type is not null) + { + @_doc.Type + } + @if (Scalar(_doc, "question") is { } question) + { + @question + } + + +
+ @((MarkupString)_html!) +
+ + @if (_doc.Sources.Count > 0) + { + Sources + + @foreach (var s in _doc.Sources) + { + @s + } + + } +} + +@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()); + _loading = false; + } + + private static string? Scalar(Document d, string key) => d.Frontmatter.TryGetValue(key, out var v) ? v.Scalar : null; +} diff --git a/src/Wiki.Host/Components/Pages/Status.razor b/src/Wiki.Host/Components/Pages/Status.razor new file mode 100644 index 0000000..0b90cbb --- /dev/null +++ b/src/Wiki.Host/Components/Pages/Status.razor @@ -0,0 +1,25 @@ +@page "/status" +@rendermode InteractiveServer +@inject StatusQuery StatusQuery + +Wiki status + +Status + +@if (_status is null) +{ + +} +else +{ + +} + +@code { + private ServiceStatus? _status; + + protected override async Task OnInitializedAsync() + { + _status = await StatusQuery.GetAsync(includeAdapterChecks: true, CancellationToken.None); + } +} diff --git a/src/Wiki.Host/Components/Shared/NoteCard.razor b/src/Wiki.Host/Components/Shared/NoteCard.razor new file mode 100644 index 0000000..5e227a7 --- /dev/null +++ b/src/Wiki.Host/Components/Shared/NoteCard.razor @@ -0,0 +1,22 @@ + + + @Note.Title + @if (Note.Score is { } score) + { + @score.ToString("0.00") + } + + + + @if (Note.Updated is { } updated) + { + Updated @updated.ToString("yyyy-MM-dd") + } + + + +@code { + /// One search or listing result: title (links to the note), score, tags and updated date. + [Parameter, EditorRequired] + public NoteSummary Note { get; set; } = null!; +} diff --git a/src/Wiki.Host/Components/Shared/StatusPanel.razor b/src/Wiki.Host/Components/Shared/StatusPanel.razor new file mode 100644 index 0000000..e02fcf2 --- /dev/null +++ b/src/Wiki.Host/Components/Shared/StatusPanel.razor @@ -0,0 +1,59 @@ + + + + Index + + + Ready@(Status.Ready ? "yes" : "no") + Commit@(Status.Commit is { Length: > 8 } c ? c[..8] : Status.Commit) + Synced@(Status.SyncedAt?.ToString("u") ?? "never") (@(Status.AgeSeconds?.ToString("0") ?? "-") s ago) + Syncing@(Status.Syncing ? "yes" : "no") + Notes@Status.Notes + Raw queue@Status.RawQueue + Outputs@Status.Outputs + @if (Status.LastError is not null) + { + Last error@Status.LastError + } + + + + + + + Embedding models + + + @foreach (var i in Status.Indexes) + { + @i.ModelId @(i.Active ? "(active)" : "")@i.Notes vectors + } + + + + + + + Adapters + + + @foreach (var a in Status.Adapters) + { + + @a.Adapter + @(a.Ok ? "ok" : "failing") + @a.Latency.TotalMilliseconds.ToString("0") ms + @a.Detail + + } + + + + + + +@code { + /// Index readiness, per-model vector counts and adapter health, shared by /status and /maintenance. + [Parameter, EditorRequired] + public ServiceStatus Status { get; set; } = null!; +} diff --git a/src/Wiki.Host/Components/Shared/TagChips.razor b/src/Wiki.Host/Components/Shared/TagChips.razor new file mode 100644 index 0000000..73abe60 --- /dev/null +++ b/src/Wiki.Host/Components/Shared/TagChips.razor @@ -0,0 +1,15 @@ +@if (Tags.Count > 0) +{ + + @foreach (var tag in Tags) + { + @tag + } + +} + +@code { + /// Tags rendered as small chips, each linking to the tag's browse page. + [Parameter, EditorRequired] + public IReadOnlyList Tags { get; set; } = []; +} diff --git a/src/Wiki.Host/Components/_Imports.razor b/src/Wiki.Host/Components/_Imports.razor index 5f50cd4..712ea26 100644 --- a/src/Wiki.Host/Components/_Imports.razor +++ b/src/Wiki.Host/Components/_Imports.razor @@ -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 diff --git a/src/Wiki.Host/Rendering/WikiMarkdown.cs b/src/Wiki.Host/Rendering/WikiMarkdown.cs new file mode 100644 index 0000000..7247579 --- /dev/null +++ b/src/Wiki.Host/Rendering/WikiMarkdown.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using Markdig; + +namespace Wiki.Host.Rendering; + +/// +/// 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 treats them for extraction. +/// +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(); + + /// Turns a document body into HTML safe to render via MarkupString. + public static string ToHtml(string body, ISet 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 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(); + } + + /// Rewrites wikilinks outside of inline code spans; inline code passes through verbatim. + private static string RewriteLine(string line, ISet 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 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 + ? $"{WebUtility.HtmlEncode(text)}" + : $"{WebUtility.HtmlEncode(text)}"; + html = html.Replace($"{Marker}{i}{Marker}", replacement, StringComparison.Ordinal); + } + return html; + } +} diff --git a/src/Wiki.Host/Wiki.Host.csproj b/src/Wiki.Host/Wiki.Host.csproj index 2a9ccc1..76c283d 100644 --- a/src/Wiki.Host/Wiki.Host.csproj +++ b/src/Wiki.Host/Wiki.Host.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Wiki.Host/wwwroot/app.css b/src/Wiki.Host/wwwroot/app.css index 5388357..466bfb4 100644 --- a/src/Wiki.Host/wwwroot/app.css +++ b/src/Wiki.Host/wwwroot/app.css @@ -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; } \ No newline at end of file diff --git a/test/Wiki.Host.Tests/WikiMarkdownTests.cs b/test/Wiki.Host.Tests/WikiMarkdownTests.cs new file mode 100644 index 0000000..c787d14 --- /dev/null +++ b/test/Wiki.Host.Tests/WikiMarkdownTests.cs @@ -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()); + Assert.Contains("udm-dns", html); + } + + [Fact] + public void UsesTheAliasAsLinkTextWhenGiven() + { + var html = WikiMarkdown.ToHtml("See [[udm-dns|the UDM note]].", new HashSet()); + Assert.Contains("the UDM note", html); + } + + [Fact] + public void DropsTheHeadingFromTheLinkTextButKeepsTheSlugAsTarget() + { + var html = WikiMarkdown.ToHtml("See [[udm-dns#split-horizon]].", new HashSet()); + Assert.Contains("udm-dns", html); + } + + [Fact] + public void RendersAnUnresolvedLinkAsAMutedSpanInsteadOfAnAnchor() + { + var html = WikiMarkdown.ToHtml("See [[nope]].", new HashSet { "nope" }); + Assert.Contains("nope", html); + Assert.DoesNotContain("", html); + } + + [Fact] + public void DoesNotRewriteWikilinksInsideAFencedCodeBlock() + { + var html = WikiMarkdown.ToHtml("```\n[[udm-dns]]\n```", new HashSet()); + Assert.DoesNotContain("()); + Assert.DoesNotContain("[[udm-dns]]", html); + } + + [Fact] + public void PlainMarkdownStillRendersHeadingsAndLists() + { + var html = WikiMarkdown.ToHtml("# Title\n\n- one\n- two\n", new HashSet()); + Assert.Contains("", html); + Assert.Contains("
  • one
  • ", html); + Assert.Contains("
  • two
  • ", html); + } + + [Fact] + public void HtmlEncodesLinkTextToAvoidInjectionThroughAliases() + { + var html = WikiMarkdown.ToHtml("See [[udm-dns|]].", new HashSet()); + Assert.DoesNotContain("