feat(facts): pictures as part of a statement, kept next to the raw source #13
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Wiki.Core.Model;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
@@ -26,8 +27,24 @@ public sealed class AnthropicFactDrafter : IFactDrafter
|
||||
throw new ArgumentException("the statement is empty", nameof(statement));
|
||||
|
||||
var (system, user) = FactDraftPrompt.Build(statement);
|
||||
var content = new JsonArray();
|
||||
foreach (var a in statement.Attachments)
|
||||
{
|
||||
content.Add(new JsonObject
|
||||
{
|
||||
["type"] = "image",
|
||||
["source"] = new JsonObject
|
||||
{
|
||||
["type"] = "base64",
|
||||
["media_type"] = a.MediaType,
|
||||
["data"] = Convert.ToBase64String(a.Bytes),
|
||||
},
|
||||
});
|
||||
}
|
||||
content.Add(new JsonObject { ["type"] = "text", ["text"] = user });
|
||||
|
||||
var text = new StringBuilder();
|
||||
await foreach (var chunk in _transport.StreamAsync(system, user, ModelId, ct).ConfigureAwait(false))
|
||||
await foreach (var chunk in _transport.StreamAsync(system, content, ModelId, ct).ConfigureAwait(false))
|
||||
{
|
||||
if (chunk is AnswerChunk.Text t) text.Append(t.Delta);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class FactDraftPrompt
|
||||
"title: a short noun phrase in the language of the statement, no trailing period. " +
|
||||
"content: the statement restated faithfully as complete sentences in the language of the statement; keep every detail and every exception, add nothing that was not said, at least 40 characters. " +
|
||||
"tags: one to five lowercase ASCII slugs (a-z, 0-9, hyphen) naming the topics, English. " +
|
||||
"Images attached to the statement are part of it: transcribe every identifier printed on them exactly as printed (manufacturer, model, type, serial number, ratings, dates), mark characters you cannot read as [unreadable], never guess. " +
|
||||
"No markdown, no code fences, no commentary.";
|
||||
|
||||
public static (string System, string User) Build(FactStatement statement)
|
||||
@@ -23,6 +24,8 @@ public static class FactDraftPrompt
|
||||
user.Append("Stated by ").Append(statement.By.Name)
|
||||
.Append(" on ").Append(statement.StatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(":\n\n");
|
||||
user.Append(statement.Text);
|
||||
if (statement.Attachments.Count > 0)
|
||||
user.Append("\n\n(").Append(statement.Attachments.Count.ToString(CultureInfo.InvariantCulture)).Append(" image(s) attached.)");
|
||||
return (SystemPrompt, user.ToString());
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,16 @@ internal sealed class MessagesTransport
|
||||
_delay = delay ?? Task.Delay;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AnswerChunk> StreamAsync(string system, string user, string modelId, [EnumeratorCancellation] CancellationToken ct)
|
||||
/// <summary>One user turn of text.</summary>
|
||||
public IAsyncEnumerable<AnswerChunk> StreamAsync(string system, string user, string modelId, CancellationToken ct) =>
|
||||
StreamAsync(system, JsonValue.Create(user), modelId, ct);
|
||||
|
||||
/// <summary>One user turn of content blocks (text, images).</summary>
|
||||
public async IAsyncEnumerable<AnswerChunk> StreamAsync(string system, JsonNode userContent, string modelId, [EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var body = BuildRequestBody(system, user);
|
||||
var body = BuildRequestBody(system, userContent);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var response = await PostWithRetryAsync(body, ct).ConfigureAwait(false);
|
||||
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
@@ -133,7 +138,7 @@ internal sealed class MessagesTransport
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject BuildRequestBody(string system, string user) => new()
|
||||
private JsonObject BuildRequestBody(string system, JsonNode userContent) => new()
|
||||
{
|
||||
["model"] = _options.Model,
|
||||
["max_tokens"] = _options.MaxTokens,
|
||||
@@ -143,7 +148,7 @@ internal sealed class MessagesTransport
|
||||
new JsonObject
|
||||
{
|
||||
["role"] = "user",
|
||||
["content"] = user,
|
||||
["content"] = userContent,
|
||||
},
|
||||
},
|
||||
["stream"] = true,
|
||||
|
||||
@@ -79,13 +79,19 @@ public sealed class GiteaRepository : INoteStore
|
||||
}
|
||||
|
||||
/// <summary>POST contents: Gitea answers 422 when the path already exists on the branch.</summary>
|
||||
public async Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct)
|
||||
public Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct) =>
|
||||
CreateAsync(path, Encoding.UTF8.GetBytes(text), message, ct);
|
||||
|
||||
public Uri? BrowseUrl(string path) =>
|
||||
new($"{_options.BaseUrl.TrimEnd('/')}/{_options.Repository}/src/branch/{_options.Branch}/{path}");
|
||||
|
||||
public async Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct)
|
||||
{
|
||||
var repo = GiteaUrls.EncodeSegments(_options.Repository);
|
||||
var encodedPath = GiteaUrls.EncodeSegments(path);
|
||||
var request = new
|
||||
{
|
||||
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
|
||||
content = Convert.ToBase64String(content),
|
||||
message,
|
||||
branch = _options.Branch,
|
||||
};
|
||||
|
||||
@@ -24,28 +24,42 @@ public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock)
|
||||
var now = clock.GetUtcNow();
|
||||
var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
var slug = Slugify(fact.Title);
|
||||
var text = BuildNote(fact, by, datePrefix);
|
||||
var message = $"raw: {fact.Title}";
|
||||
|
||||
var path = $"raw/{datePrefix}-{slug}.md";
|
||||
for (var attempt = 2; ; attempt++)
|
||||
{
|
||||
// Attachments take the note's stem, so they sort next to it and survive a suffix.
|
||||
var stem = path[(path.LastIndexOf('/') + 1)..^3];
|
||||
var names = fact.Attachments.Select((a, i) => $"{stem}-{i + 1}.{a.Extension}").ToList();
|
||||
var text = BuildNote(fact, by, datePrefix, names);
|
||||
StoreWrite written;
|
||||
try
|
||||
{
|
||||
var written = await store.CreateAsync(path, text, message, ct);
|
||||
return new FactReceipt(written.Path, written.Commit, now);
|
||||
written = await store.CreateAsync(path, text, message, ct);
|
||||
}
|
||||
catch (DocumentExistsException) when (attempt <= MaxSuffixAttempts)
|
||||
{
|
||||
path = $"raw/{datePrefix}-{slug}-{attempt}.md";
|
||||
continue;
|
||||
}
|
||||
for (var i = 0; i < names.Count; i++)
|
||||
written = await store.CreateAsync($"raw/{names[i]}", fact.Attachments[i].Bytes, $"{message} (attachment {i + 1})", ct);
|
||||
return new FactReceipt(path, written.Commit, now);
|
||||
}
|
||||
}
|
||||
|
||||
public static string BuildNote(RawSource fact, Identity by, string capturedDate)
|
||||
public static string BuildNote(RawSource fact, Identity by, string capturedDate, IReadOnlyList<string>? attachments = null)
|
||||
{
|
||||
attachments ??= [];
|
||||
var body = fact.Content;
|
||||
if (!body.EndsWith('\n')) body += "\n";
|
||||
if (attachments.Count > 0)
|
||||
{
|
||||
body += "\n";
|
||||
for (var i = 0; i < attachments.Count; i++)
|
||||
body += $"![{fact.Attachments[i].Caption ?? $"attachment {i + 1}"}]({attachments[i]})\n";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("---\n");
|
||||
@@ -56,6 +70,7 @@ public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock)
|
||||
sb.Append("captured_by: ").Append(by.Name).Append('\n');
|
||||
sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n");
|
||||
if (attachments.Count > 0) sb.Append("attachments: [").Append(string.Join(", ", attachments)).Append("]\n");
|
||||
sb.Append("---\n");
|
||||
sb.Append('\n');
|
||||
sb.Append(body);
|
||||
|
||||
@@ -13,21 +13,22 @@ public sealed record RecordedStatement(FactDraft Draft, RawSource Fact, FactRece
|
||||
/// </summary>
|
||||
public sealed class RecordStatementUseCase(DepositFactUseCase deposit, TimeProvider clock)
|
||||
{
|
||||
public async Task<RecordedStatement> RecordAsync(string statement, Identity by, IFactDrafter drafter, CancellationToken ct)
|
||||
public async Task<RecordedStatement> RecordAsync(string statement, IReadOnlyList<Attachment> attachments, Identity by, IFactDrafter drafter, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(statement);
|
||||
ArgumentNullException.ThrowIfNull(attachments);
|
||||
ArgumentNullException.ThrowIfNull(by);
|
||||
ArgumentNullException.ThrowIfNull(drafter);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var text = statement.Trim();
|
||||
var draft = await drafter.DraftAsync(new FactStatement(text, by, now), ct);
|
||||
var draft = await drafter.DraftAsync(new FactStatement(text, by, now) { Attachments = attachments }, ct);
|
||||
var fact = new RawSource(
|
||||
draft.Title,
|
||||
$"stated by {by.Name} on {now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)} via the wiki",
|
||||
Body(draft.Content, text),
|
||||
now,
|
||||
draft.Tags);
|
||||
draft.Tags) { Attachments = attachments };
|
||||
var errors = fact.Validate();
|
||||
if (errors.Count > 0)
|
||||
throw new InvalidOperationException($"the draft is not a valid raw source: {string.Join("; ", errors)}");
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Wiki.Core.Model;
|
||||
|
||||
/// <summary>A binary that belongs to a raw source and is kept next to it as evidence, a photo of a type label for instance.</summary>
|
||||
public sealed record Attachment(string MediaType, byte[] Bytes, string? Caption = null)
|
||||
{
|
||||
public const int MaxBytes = 8 * 1024 * 1024;
|
||||
|
||||
private static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["image/jpeg"] = "jpg",
|
||||
["image/png"] = "png",
|
||||
["image/webp"] = "webp",
|
||||
["image/gif"] = "gif",
|
||||
};
|
||||
|
||||
/// <summary>The file extension for a supported media type, or null.</summary>
|
||||
public static string? ExtensionFor(string mediaType) => Extensions.TryGetValue(mediaType, out var e) ? e : null;
|
||||
|
||||
public string? Extension => ExtensionFor(MediaType);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
namespace Wiki.Core.Model;
|
||||
|
||||
/// <summary>What a person said, by whom and when. The drafter sees nothing else.</summary>
|
||||
public sealed record FactStatement(string Text, Identity By, DateTimeOffset StatedAt);
|
||||
public sealed record FactStatement(string Text, Identity By, DateTimeOffset StatedAt)
|
||||
{
|
||||
/// <summary>Pictures that are part of the statement, a type label for instance.</summary>
|
||||
public IReadOnlyList<Attachment> Attachments { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A statement normalised into the fields of a raw source. Source and provenance are the application's to add.</summary>
|
||||
public sealed record FactDraft(string Title, string Content, IReadOnlyList<string> Tags);
|
||||
|
||||
@@ -13,6 +13,9 @@ public sealed record RawSource(
|
||||
{
|
||||
public const int MinimumContentLength = 40;
|
||||
|
||||
/// <summary>Binaries stored next to the note as evidence. Empty for most facts.</summary>
|
||||
public IReadOnlyList<Attachment> Attachments { get; init; } = [];
|
||||
|
||||
/// <summary>Validation errors, empty when the fact may be stored.</summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
@@ -21,6 +24,13 @@ public sealed record RawSource(
|
||||
if (string.IsNullOrWhiteSpace(Source)) errors.Add("source is required: where the fact was observed or quoted from");
|
||||
if (Content is null || Content.Trim().Length < MinimumContentLength)
|
||||
errors.Add($"content must be at least {MinimumContentLength} characters of observation");
|
||||
for (var i = 0; i < Attachments.Count; i++)
|
||||
{
|
||||
var a = Attachments[i];
|
||||
if (a.Extension is null) errors.Add($"attachment {i + 1}: unsupported media type {a.MediaType}");
|
||||
if (a.Bytes.Length == 0) errors.Add($"attachment {i + 1}: empty");
|
||||
if (a.Bytes.Length > Attachment.MaxBytes) errors.Add($"attachment {i + 1}: larger than {Attachment.MaxBytes / (1024 * 1024)} MiB");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ public interface INoteStore : IAdapterHealth
|
||||
|
||||
/// <summary>Creates a new document at the path on the current head. Throws <see cref="DocumentExistsException"/> when the path is taken.</summary>
|
||||
Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct);
|
||||
|
||||
/// <summary>Creates a new binary (an attachment) at the path. Same rules as the text overload.</summary>
|
||||
Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct);
|
||||
|
||||
/// <summary>Where a person can look at the path in the store's own UI, or null when the store has none.</summary>
|
||||
Uri? BrowseUrl(string path);
|
||||
}
|
||||
|
||||
/// <summary>Text to vector. One instance per configured model.</summary>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||
<script src="@Assets["facts.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
@inject IFactDrafter Drafter
|
||||
@inject NoteQueries Notes
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<PageTitle>Facts</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Facts</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-4" Color="Color.Secondary">
|
||||
Say what you observed, in one sentence or a few. @Drafter.ModelId turns it into a raw source under your name, with your words kept verbatim, and the nightly distill turns that into wiki notes. Observations only, never hypotheses.
|
||||
Say what you observed, in one sentence or a few, and add or paste a picture when the thing itself says it best, a type label for instance. @Drafter.ModelId turns it into a raw source under your name, with your words kept verbatim and the pictures next to the note, and the nightly distill turns that into wiki notes. Observations only, never hypotheses.
|
||||
</MudText>
|
||||
|
||||
@if (_user is null)
|
||||
@@ -22,8 +23,24 @@ else
|
||||
{
|
||||
<form @onsubmit="RecordAsync" @onsubmit:preventDefault="true">
|
||||
<MudTextField T="string" @bind-Value="_statement" Immediate="true" Lines="3" Label="What did you observe?" Variant="Variant.Outlined" Disabled="@_running" Class="mb-2" />
|
||||
<InputFile id="fact-files" OnChange="OnFilesAsync" multiple accept="image/*" style="display:none" />
|
||||
@if (_images.Count > 0)
|
||||
{
|
||||
<MudStack Row Wrap="Wrap.Wrap" Spacing="2" Class="mb-2">
|
||||
@foreach (var img in _images)
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-1" Style="position: relative">
|
||||
<img src="@img.DataUrl" alt="attachment" style="max-height: 140px; display: block" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small" Style="position: absolute; top: 0; right: 0" OnClick="@(() => _images.Remove(img))" Disabled="@_running" />
|
||||
</MudPaper>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">captured_by: @_user</MudText>
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Outlined.AddPhotoAlternate" OnClick="PickAsync" Disabled="@_running">Add picture</MudButton>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">or paste one · captured_by: @_user</MudText>
|
||||
</MudStack>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(_running || string.IsNullOrWhiteSpace(_statement))">Record</MudButton>
|
||||
</MudStack>
|
||||
</form>
|
||||
@@ -91,6 +108,9 @@ else
|
||||
private string? _error;
|
||||
private RecordedStatement? _recorded;
|
||||
private IReadOnlyList<RawQueueEntry>? _raw;
|
||||
private readonly List<PendingImage> _images = [];
|
||||
|
||||
private sealed record PendingImage(string MediaType, byte[] Bytes, string DataUrl);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -102,8 +122,43 @@ else
|
||||
await LoadRawAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && _user is not null)
|
||||
await JS.InvokeVoidAsync("wikiFacts.listenPaste", "fact-files");
|
||||
}
|
||||
|
||||
private async Task LoadRawAsync() => _raw = await Notes.RawQueueAsync(CancellationToken.None);
|
||||
|
||||
private async Task PickAsync() => await JS.InvokeVoidAsync("wikiFacts.pick", "fact-files");
|
||||
|
||||
private async Task OnFilesAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
foreach (var file in e.GetMultipleFiles(10))
|
||||
{
|
||||
if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"{file.Name}: not an image", Severity.Warning);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
// Resized in the browser: labels stay legible at 1600px and the upload stays small.
|
||||
var resized = await file.RequestImageFileAsync("image/jpeg", 1600, 1600);
|
||||
using var ms = new MemoryStream();
|
||||
await using (var stream = resized.OpenReadStream(Attachment.MaxBytes))
|
||||
await stream.CopyToAsync(ms);
|
||||
var bytes = ms.ToArray();
|
||||
_images.Add(new PendingImage("image/jpeg", bytes, "data:image/jpeg;base64," + Convert.ToBase64String(bytes)));
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Snackbar.Add($"{file.Name}: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RecordAsync()
|
||||
{
|
||||
if (_running || _user is null || string.IsNullOrWhiteSpace(_statement)) return;
|
||||
@@ -112,9 +167,11 @@ else
|
||||
_recorded = null;
|
||||
try
|
||||
{
|
||||
_recorded = await Record.RecordAsync(_statement, new Identity(_user, IdentityKind.Human), Drafter, CancellationToken.None);
|
||||
var attachments = _images.Select(i => new Attachment(i.MediaType, i.Bytes)).ToList();
|
||||
_recorded = await Record.RecordAsync(_statement, attachments, new Identity(_user, IdentityKind.Human), Drafter, CancellationToken.None);
|
||||
Snackbar.Add($"recorded {_recorded.Receipt.Path}", Severity.Success);
|
||||
_statement = null;
|
||||
_images.Clear();
|
||||
await LoadRawAsync();
|
||||
}
|
||||
catch (Exception e) when (e is HttpRequestException or InvalidOperationException)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/raw/{slug}"
|
||||
@rendermode InteractiveServer
|
||||
@inject NoteQueries Notes
|
||||
@inject INoteStore Store
|
||||
|
||||
<PageTitle>@(_doc?.Title ?? Slug)</PageTitle>
|
||||
|
||||
@@ -33,6 +34,19 @@ else
|
||||
<div class="wiki-body">
|
||||
@((MarkupString)_html!)
|
||||
</div>
|
||||
@if (Attachments(_doc) is { Count: > 0 } attachments)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">Attachments</MudText>
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var a in attachments)
|
||||
{
|
||||
var href = Store.BrowseUrl($"raw/{a}")?.ToString();
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Image">
|
||||
@if (href is null) { @a } else { <MudLink Href="@href" Target="_blank">@a</MudLink> }
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -52,4 +66,11 @@ else
|
||||
}
|
||||
|
||||
private static string? Scalar(Document d, string key) => d.Frontmatter.TryGetValue(key, out var v) ? v.Scalar : null;
|
||||
|
||||
private static IReadOnlyList<string> Attachments(Document d)
|
||||
{
|
||||
if (!d.Frontmatter.TryGetValue("attachments", out var v)) return [];
|
||||
if (v.List is { } list) return list;
|
||||
return (v.Scalar ?? "").Trim('[', ']').Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Pasted images become files on the page's hidden <input type="file">, so Blazor's InputFile
|
||||
// streams them like a picked file. One document listener; it looks the input up per paste.
|
||||
window.wikiFacts = (() => {
|
||||
let armed = false;
|
||||
return {
|
||||
listenPaste(inputId) {
|
||||
if (armed) return;
|
||||
armed = true;
|
||||
document.addEventListener('paste', (ev) => {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input || !ev.clipboardData) return;
|
||||
const files = [];
|
||||
for (const item of ev.clipboardData.items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const f = item.getAsFile();
|
||||
if (f) files.push(f);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
ev.preventDefault();
|
||||
const dt = new DataTransfer();
|
||||
for (const f of files) dt.items.add(f);
|
||||
input.files = dt.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
},
|
||||
pick(inputId) {
|
||||
document.getElementById(inputId)?.click();
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -44,6 +44,18 @@ public sealed class FakeStore : INoteStore
|
||||
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(text)))));
|
||||
}
|
||||
|
||||
public Dictionary<string, byte[]> Binaries { get; } = new();
|
||||
|
||||
public Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct)
|
||||
{
|
||||
if (Files.ContainsKey(path) || Binaries.ContainsKey(path)) throw new DocumentExistsException(path);
|
||||
Binaries[path] = content;
|
||||
Created.Add(($"{message}", path));
|
||||
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(content))));
|
||||
}
|
||||
|
||||
public Uri? BrowseUrl(string path) => null;
|
||||
|
||||
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ public class RecordStatementUseCaseTests
|
||||
var drafter = new FakeFactDrafter();
|
||||
var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
|
||||
|
||||
var recorded = await useCase.RecordAsync(" " + Statement + "\n", By, drafter, CancellationToken.None);
|
||||
var recorded = await useCase.RecordAsync(" " + Statement + "\n", [], By, drafter, CancellationToken.None);
|
||||
|
||||
var sent = Assert.Single(drafter.Statements);
|
||||
Assert.Equal(Statement, sent.Text);
|
||||
@@ -55,7 +55,7 @@ public class RecordStatementUseCaseTests
|
||||
var drafter = new FakeFactDrafter { Draft = new FactDraft(" ", "the verbatim statement always makes the body long enough; only a missing title fails", []) };
|
||||
var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => useCase.RecordAsync(Statement, By, drafter, CancellationToken.None));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => useCase.RecordAsync(Statement, [], By, drafter, CancellationToken.None));
|
||||
Assert.Empty(store.Files);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,25 @@ public class RecordStatementUseCaseTests
|
||||
{
|
||||
var drafter = new FakeFactDrafter();
|
||||
var useCase = new RecordStatementUseCase(new DepositFactUseCase(new FakeStore(), new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => useCase.RecordAsync(" \n", By, drafter, CancellationToken.None));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => useCase.RecordAsync(" \n", [], By, drafter, CancellationToken.None));
|
||||
Assert.Empty(drafter.Statements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PicturesReachTheDrafterAndLandNextToTheNote()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var drafter = new FakeFactDrafter { Draft = new FactDraft("Geschirrspüler Typenschild", "Der Geschirrspüler ist ein Bosch SMV4HCX48E, Seriennummer [unreadable].", ["dishwasher"]) };
|
||||
var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
|
||||
var photo = new Attachment("image/jpeg", [0xFF, 0xD8, 0xFF, 1, 2, 3], "type label");
|
||||
|
||||
var recorded = await useCase.RecordAsync("Das ist das Typenschild unseres Geschirrspülers.", [photo], By, drafter, CancellationToken.None);
|
||||
|
||||
Assert.Same(photo, Assert.Single(Assert.Single(drafter.Statements).Attachments));
|
||||
Assert.Equal("raw/2026-09-25-geschirrsp-ler-typenschild.md", recorded.Receipt.Path);
|
||||
Assert.Equal(photo.Bytes, store.Binaries["raw/2026-09-25-geschirrsp-ler-typenschild-1.jpg"]);
|
||||
var text = store.Files[recorded.Receipt.Path];
|
||||
Assert.Contains("attachments: [2026-09-25-geschirrsp-ler-typenschild-1.jpg]\n", text);
|
||||
Assert.EndsWith("\n", text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,25 @@ public sealed class AnthropicFactDrafterContractTests : FactDrafterContract
|
||||
return Task.FromResult(drafter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PicturesTravelAsBase64ImageBlocksBeforeTheText()
|
||||
{
|
||||
var handler = new FakeAnthropicHandler { ResponseText = DraftJson };
|
||||
var drafter = new AnthropicFactDrafter(new HttpClient(handler), new AnthropicOptions { BaseUrl = "https://anthropic.test", ApiKey = "k" }, delay: (_, _) => Task.CompletedTask);
|
||||
var statement = Statement() with { Attachments = [new Wiki.Core.Model.Attachment("image/jpeg", [1, 2, 3])] };
|
||||
|
||||
await drafter.DraftAsync(statement, CancellationToken.None);
|
||||
|
||||
using var request = System.Text.Json.JsonDocument.Parse(handler.LastRequestJson!);
|
||||
var content = request.RootElement.GetProperty("messages")[0].GetProperty("content");
|
||||
Assert.Equal(2, content.GetArrayLength());
|
||||
Assert.Equal("image", content[0].GetProperty("type").GetString());
|
||||
Assert.Equal("image/jpeg", content[0].GetProperty("source").GetProperty("media_type").GetString());
|
||||
Assert.Equal("AQID", content[0].GetProperty("source").GetProperty("data").GetString());
|
||||
Assert.Equal("text", content[1].GetProperty("type").GetString());
|
||||
Assert.Contains("1 image(s) attached", content[1].GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTakesTheObjectOutOfSurroundingTextAndNormalisesTags()
|
||||
{
|
||||
|
||||
@@ -76,6 +76,18 @@ public abstract class NoteStoreContract
|
||||
store.CreateAsync("wiki/udm-dns.md", "other", "x", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatesABinaryAndRefusesAnExistingPath()
|
||||
{
|
||||
var store = await CreateStoreAsync();
|
||||
var bytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3 };
|
||||
var written = await store.CreateAsync("raw/2026-09-24-new-1.jpg", bytes, "raw: New (attachment 1)", CancellationToken.None);
|
||||
Assert.Equal("raw/2026-09-24-new-1.jpg", written.Path);
|
||||
Assert.False(string.IsNullOrWhiteSpace(written.Commit));
|
||||
await Assert.ThrowsAsync<DocumentExistsException>(() =>
|
||||
store.CreateAsync("raw/2026-09-24-new-1.jpg", bytes, "x", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReportsOk()
|
||||
{
|
||||
|
||||
@@ -41,6 +41,17 @@ public sealed class FakeStore : INoteStore
|
||||
return Task.FromResult(new StoreWrite(path, "c"));
|
||||
}
|
||||
|
||||
public Dictionary<string, byte[]> Binaries { get; } = new();
|
||||
|
||||
public Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct)
|
||||
{
|
||||
if (Files.ContainsKey(path) || Binaries.ContainsKey(path)) throw new DocumentExistsException(path);
|
||||
Binaries[path] = content;
|
||||
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(content))));
|
||||
}
|
||||
|
||||
public Uri? BrowseUrl(string path) => null;
|
||||
|
||||
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user