From ed6f500bef0617dad5c70375cbc84ec37fedcf81 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 15:14:11 -0700 Subject: [PATCH] =?UTF-8?q?AI:=20Powered=20Document=20=E2=80=94=20all=20fo?= =?UTF-8?q?ur=20modes,=20with=20a=20real=20rendered=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft a full markdown document in any of the four documented context.mode variants: article (default), from_list, from_article, research_url. Hosted in DocumentsView.xaml with one line and no code-behind edit — #139 is also in that file, so this uses the #55/#56 self-contained-control pattern like #14 does. ## A reference, not source text from_list sends a listId, from_article a documentId, research_url a URL, and the server loads or fetches the source itself. All three are IDOR-guarded. Verified live, and all four probes were free — input validation costs no quota (usedToday was unchanged across them): from_list non-owned id -> {"error":"List not found.","code":"invalid_input"} from_list no id -> {"error":"A list must be selected.","code":"invalid_input"} from_article non-owned id -> {"error":"Document not found.","code":"invalid_input"} research_url ftp:// -> {"error":"Only http(s) URLs are supported.","code":"invalid_input"} Those all land on SourceError — the amber bar directly beneath the offending picker — rather than the generic notice slot, which is #15's last acceptance criterion. The attribution branches on mode + error code, never on the server's prose: every client-knowable input problem (the 500-word cap, an empty topic, a missing reference, a non-http scheme) is already rejected locally before the call leaves, so an invalid_input that still comes back in a derived mode can only be the one thing the client cannot check — the reference doesn't exist or isn't this user's. The server's own wording is what gets displayed, since it's the specific and useful part. The source pickers list the user's own lists plus root documents AND documents filed in folders — a document someone tidied away is still a legitimate source. They load lazily, only once a mode that needs them is selected, rather than spending two GETs on panel construction that most sessions won't use. ## One live probe changed the mode picker's design An unrecognized context.mode is NOT cheap-rejected: {"mode":"not_a_mode"} fell through to the model and came back 422 invalid_ai_output — billed a quota unit. So mode is never a free string here; it's the four-value AiDocumentMode enum throughout, and the picker can only ever produce one of them. ## The rendered preview is actually rendered "Rendered markdown preview" needed a renderer, and the app has no markdown library. MarkdownRenderer builds a FlowDocument covering what the server actually emits — ATX headings, fenced code, bullet and ordered lists, GFM pipe tables, blockquotes, rules, and inline bold/italic/code/links — using only existing Strata theme brushes, so it follows light/dark and introduces no colour, font or radius of its own. Everything unrecognized degrades to a plain paragraph, which is the right failure mode for a preview. Pipe tables are in there because the live probe's draft contained one (| Date | Platform | Topic / Asset | Owner |) in the middle of a briefing. Without table support those three lines would have collapsed into a single run-together paragraph of pipes and dashes — exactly the mangling a preview exists to prevent. The line-classification predicates live in their own MarkdownSyntax file with no WPF dependency, specifically so they can be tested off-Windows: a harness replays the real /suggest markdown through the renderer's actual index-advancing control flow and asserts 25 properties, including that the table becomes one 4-column block rather than three stray paragraphs, that the |---| separator is not mistaken for a horizontal rule, that "* * *" is a rule while "- item" is not, that "#hashtag" is not a heading, and that a paragraph with a stray pipe is not a table. All 25 pass. The rendered view and the editable markdown box are two views of one string: the markdown is the single point of truth and the rendered pane projects it, so toggling can't lose an edit. ## Quota discipline Confirm pre-flights a non-empty title, non-empty body, and the 40,000-character server ceiling before spending the /generate unit, and treats the returned {documentId} as a hint — it re-fetches with GET /api/documents/{id} before handing the host anything, and when no id comes back it says plainly that a retry would duplicate the document and spend another credit. /suggest calls made: 1 (powered_document, mode from_article, against the account's own "Social Media Campaign" document). Artifact shape confirmed exactly as modelled: {kind, title, markdown, outline[], isPublic}, with a 7-entry outline. POST /api/ai/generate was NOT called — it persists to a shared account — so the confirm path is reasoned, not observed. Closes #15 Co-Authored-By: Claude Opus 5 --- .../PoweredDocumentPanelViewModel.cs | 526 ++++++++++++++++++ InterlinedList/Views/DocumentsView.xaml | 3 + InterlinedList/Views/MarkdownRenderer.cs | 414 ++++++++++++++ InterlinedList/Views/MarkdownSyntax.cs | 126 +++++ InterlinedList/Views/MarkdownViewer.cs | 47 ++ .../Views/PoweredDocumentPanel.xaml | 407 ++++++++++++++ .../Views/PoweredDocumentPanel.xaml.cs | 74 +++ 7 files changed, 1597 insertions(+) create mode 100644 InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs create mode 100644 InterlinedList/Views/MarkdownRenderer.cs create mode 100644 InterlinedList/Views/MarkdownSyntax.cs create mode 100644 InterlinedList/Views/MarkdownViewer.cs create mode 100644 InterlinedList/Views/PoweredDocumentPanel.xaml create mode 100644 InterlinedList/Views/PoweredDocumentPanel.xaml.cs diff --git a/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs b/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs new file mode 100644 index 0000000..00ce02f --- /dev/null +++ b/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs @@ -0,0 +1,526 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// One source a derived Powered Document mode can point at. +public sealed record AiDocumentSource(string Id, string Title) +{ + public override string ToString() => Title; +} + +/// One entry in the mode picker. +public sealed record AiDocumentModeOption(AiDocumentMode Mode, string Label, string Hint); + +/// +/// Powered Document: draft a full markdown document, in one of four modes. +/// +/// You pass a reference, not source text. from_list sends a +/// listId, from_article a documentId, research_url a +/// URL — and the server loads (or fetches) and truncates the source itself. +/// Those three are IDOR-guarded: a reference that's missing or isn't yours comes +/// back 422 invalid_input. Verified live, all four for free (input +/// validation costs no quota): +/// +/// from_list with a non-owned id → {"error":"List not found.","code":"invalid_input"} +/// from_list with no id → {"error":"A list must be selected.","code":"invalid_input"} +/// from_article with a non-owned id → {"error":"Document not found.","code":"invalid_input"} +/// research_url with ftp:// → {"error":"Only http(s) URLs are supported.","code":"invalid_input"} +/// +/// Those all land on — next to the offending picker — +/// rather than in the generic notice slot, which is #15's last acceptance +/// criterion. +/// +/// One live finding shapes the mode picker. An unrecognized +/// context.mode is not cheap-rejected: {"mode":"not_a_mode"} fell +/// through to the model and came back 422 invalid_ai_output — billed +/// a quota unit. So the mode is never a free string here; it's the +/// four-value enum, always, and the picker can only +/// ever produce one of them. +/// +public sealed partial class PoweredDocumentPanelViewModel : AiPanelViewModelBase +{ + private bool _sourcesLoaded; + + public PoweredDocumentPanelViewModel(SessionService session, AiAvailabilityService? availability = null) + : base(session, availability) + { + Modes = new[] + { + new AiDocumentModeOption(AiDocumentMode.Article, "From a topic", "Drafts from your description alone."), + new AiDocumentModeOption(AiDocumentMode.FromList, "From one of my lists", "Reads the list's schema and up to 50 rows."), + new AiDocumentModeOption(AiDocumentMode.FromArticle, "From one of my documents", "Reads that document's title and body."), + new AiDocumentModeOption(AiDocumentMode.ResearchUrl, "From a URL", "Fetches the page and cites it.") + }; + + selectedMode = Modes[0]; + } + + /// Set by the hosting control from its OpenDocumentCommand dependency property. + public ICommand? OpenDocumentCommand { get; set; } + + /// Set by the hosting control from its RefreshCommand dependency property. + public ICommand? RefreshCommand { get; set; } + + // ── Mode ──────────────────────────────────────────────────────────────────── + + public IReadOnlyList Modes { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsListMode))] + [NotifyPropertyChangedFor(nameof(IsArticleSourceMode))] + [NotifyPropertyChangedFor(nameof(IsUrlMode))] + [NotifyPropertyChangedFor(nameof(NeedsSource))] + [NotifyPropertyChangedFor(nameof(ModeHint))] + private AiDocumentModeOption selectedMode; + + public bool IsListMode => SelectedMode.Mode == AiDocumentMode.FromList; + public bool IsArticleSourceMode => SelectedMode.Mode == AiDocumentMode.FromArticle; + public bool IsUrlMode => SelectedMode.Mode == AiDocumentMode.ResearchUrl; + + /// True for the three derived modes, which all need a reference. + public bool NeedsSource => SelectedMode.Mode != AiDocumentMode.Article; + + public string ModeHint => SelectedMode.Hint; + + partial void OnSelectedModeChanged(AiDocumentModeOption value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + + // Load the pickers' contents only once a mode actually needs them — + // two GETs nobody asked for is the wrong default on panel construction. + if (NeedsSource) _ = LoadSourcesAsync(); + } + + // ── Sources ───────────────────────────────────────────────────────────────── + + public ObservableCollection MyLists { get; } = new(); + + public ObservableCollection MyDocuments { get; } = new(); + + [ObservableProperty] + private AiDocumentSource? selectedList; + + [ObservableProperty] + private AiDocumentSource? selectedDocument; + + [ObservableProperty] + private string sourceUrl = ""; + + /// + /// The message shown against the source picker, not in the generic + /// notice slot. Both the client's own pre-flight and a server-side + /// reference rejection land here. + /// + [ObservableProperty] + private string? sourceError; + + [ObservableProperty] + private bool isLoadingSources; + + partial void OnSelectedListChanged(AiDocumentSource? value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + } + + partial void OnSelectedDocumentChanged(AiDocumentSource? value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + } + + partial void OnSourceUrlChanged(string value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + } + + private async Task LoadSourcesAsync() + { + if (_sourcesLoaded) return; + + IsLoadingSources = true; + try + { + var lists = await Session.Api.GetListsAsync(limit: 100, offset: 0); + MyLists.Clear(); + foreach (var list in lists.Lists) + MyLists.Add(new AiDocumentSource(list.Id, list.Title)); + + // Root documents plus everything filed in a folder — a document a + // user tidied away is still a legitimate source, and the folder + // entries carry their own ids. + var documents = await Session.Api.GetRootDocumentsAsync(); + var folders = await Session.Api.GetDocumentFoldersAsync(); + + MyDocuments.Clear(); + foreach (var doc in documents.Documents) + MyDocuments.Add(new AiDocumentSource(doc.Id, doc.Title)); + + foreach (var folder in folders.Folders) + foreach (var entry in folder.Documents) + MyDocuments.Add(new AiDocumentSource(entry.Id, $"{folder.Name} / {entry.Title}")); + + _sourcesLoaded = true; + } + catch (InterlinedApiException ex) + { + SourceError = ex.Message; + } + catch (Exception ex) + { + SourceError = "Couldn't load your lists and documents."; + AppLog.Warn($"Powered Document source load failed: {ex.Message}"); + } + finally + { + IsLoadingSources = false; + } + } + + [RelayCommand] + private async Task ReloadSourcesAsync() + { + _sourcesLoaded = false; + await LoadSourcesAsync(); + } + + // ── Input ─────────────────────────────────────────────────────────────────── + + /// Collapsed by default — an option in the Documents flow, not the default path. + [ObservableProperty] + private bool isOpen; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WordCount))] + [NotifyPropertyChangedFor(nameof(WordCountLabel))] + [NotifyPropertyChangedFor(nameof(IsOverWordCap))] + private string topic = ""; + + public int WordCount => AiFeatureLimits.CountWords(Topic); + + public int MaxWords => AiFeatureLimits.For(AiFeature.PoweredDocument).MaxInputWords; + + public string WordCountLabel => $"{WordCount} / {MaxWords} words"; + + public bool IsOverWordCap => WordCount > MaxWords; + + partial void OnTopicChanged(string value) => SuggestCommand.NotifyCanExecuteChanged(); + + // ── Preview ───────────────────────────────────────────────────────────────── + + [ObservableProperty] + private bool hasPreview; + + [ObservableProperty] + private string draftTitle = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CharacterCountLabel))] + [NotifyPropertyChangedFor(nameof(IsOverCharacterCap))] + private string draftMarkdown = ""; + + /// Server-side ceiling on a generated document. + public int MaxCharacters => AiFeatureLimits.MaxDocumentCharacters; + + public string CharacterCountLabel => $"{DraftMarkdown.Length:N0} / {MaxCharacters:N0} characters"; + + public bool IsOverCharacterCap => DraftMarkdown.Length > MaxCharacters; + + /// The model's section plan, shown as read-only context for the draft. + public ObservableCollection DraftOutline { get; } = new(); + + public bool HasOutline => DraftOutline.Count > 0; + + /// Whether the created document should be publicly readable. The artifact carries this. + [ObservableProperty] + private bool draftIsPublic; + + /// + /// Toggles the preview between the rendered document and the editable + /// markdown source. The source is the single point of truth — the rendered + /// view is a projection of it, so edits can't be lost to the toggle. + /// + [ObservableProperty] + private bool showMarkdownSource; + + [ObservableProperty] + private string? usageLabel; + + // ── Commands ──────────────────────────────────────────────────────────────── + + protected override void NotifyAiCommandsChanged() + { + SuggestCommand.NotifyCanExecuteChanged(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void ToggleOpen() + { + IsOpen = !IsOpen; + if (IsOpen) _ = EnsureStatusLoadedAsync(); + } + + private bool CanSuggest() + { + if (!CanRunAi || string.IsNullOrWhiteSpace(Topic)) return false; + + return SelectedMode.Mode switch + { + AiDocumentMode.FromList => SelectedList is not null, + AiDocumentMode.FromArticle => SelectedDocument is not null, + AiDocumentMode.ResearchUrl => !string.IsNullOrWhiteSpace(SourceUrl), + _ => true + }; + } + + [RelayCommand(CanExecute = nameof(CanSuggest))] + private async Task SuggestAsync(CancellationToken ct) + { + SourceError = null; + + if (!TryBuildContext(out var context, out var sourceError)) + { + // Against the field, not in the generic slot. + SourceError = sourceError; + return; + } + + if (!AiFeatureLimits.TryValidateInput(AiFeature.PoweredDocument, Topic, out var inputError)) + { + RejectLocally(inputError!); + return; + } + + var mode = SelectedMode.Mode; + + var result = await RunAiAsync( + "Drafting your document…", + token => Session.Api.AiSuggestAsync(AiFeature.PoweredDocument, Topic.Trim(), context, ct: token), + r => r.Quota, + ct); + + if (result is null) + { + AttributeFailureToSource(mode); + return; + } + + if (result.Artifact.AsDocument() is not { } document) + { + Notice = new AiNotice(AiNoticeKind.ModelDeclined, + $"Expected a document but the AI returned a \"{result.Artifact.Kind}\". " + + "That attempt still counted against today's allowance — try rewording the topic.", + Spent: true); + return; + } + + DraftTitle = document.Title; + DraftMarkdown = document.Markdown; + DraftIsPublic = document.IsPublic; + + DraftOutline.Clear(); + foreach (var section in document.Outline) + DraftOutline.Add(section); + OnPropertyChanged(nameof(HasOutline)); + + ShowMarkdownSource = false; + HasPreview = true; + UsageLabel = result.Usage?.Display; + Notice = AiNotice.Info("Draft ready. Edit it below, then save it as a document."); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + /// + /// Move a reference rejection out of the generic notice and onto the source + /// field, per #15. + /// + /// Why mode + code is enough, without reading the server's prose. + /// Every client-knowable input problem — the 500-word cap, an empty topic, + /// a missing reference, a non-http scheme — is already rejected locally + /// before the call goes out. So an invalid_input that comes back from + /// the server in a derived mode can only be the one thing the client + /// cannot check: the reference doesn't exist, or it isn't this user's. That + /// inference is what lets this branch on the code alone (the repo's rule) + /// while still showing the server's own wording, which is the specific and + /// useful part. + /// + private void AttributeFailureToSource(AiDocumentMode mode) + { + if (mode == AiDocumentMode.Article) return; + if (Notice is not { Kind: AiNoticeKind.Input } notice) return; + + SourceError = mode switch + { + AiDocumentMode.FromList => $"{notice.Text} Pick a different list.", + AiDocumentMode.FromArticle => $"{notice.Text} Pick a different document.", + AiDocumentMode.ResearchUrl => $"{notice.Text} Check the URL.", + _ => notice.Text + }; + + ClearNotice(); + } + + /// + /// Build context for the selected mode, running the checks the + /// server would answer 422 for. These cost no quota either way (verified), + /// but catching them locally also keeps the message on the right field. + /// + private bool TryBuildContext(out AiContext? context, out string? error) + { + context = null; + error = null; + + switch (SelectedMode.Mode) + { + case AiDocumentMode.Article: + context = AiContext.DocumentFromTopic(); + return true; + + case AiDocumentMode.FromList: + if (SelectedList is null) + { + error = "Pick one of your lists to draft from."; + return false; + } + context = AiContext.DocumentFromList(SelectedList.Id); + break; + + case AiDocumentMode.FromArticle: + if (SelectedDocument is null) + { + error = "Pick one of your documents to draft from."; + return false; + } + context = AiContext.DocumentFromArticle(SelectedDocument.Id); + break; + + case AiDocumentMode.ResearchUrl: + var url = SourceUrl.Trim(); + if (url.Length == 0) + { + error = "Enter the URL to research."; + return false; + } + + // Client-side http/https check. The server enforces it too + // ("Only http(s) URLs are supported.", verified), but catching + // it here puts the message on the field and skips a round trip. + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + error = "Only http:// and https:// URLs are supported."; + return false; + } + + context = AiContext.DocumentFromResearchUrl(uri.ToString()); + break; + + default: + error = "Pick how the document should be drafted."; + return false; + } + + // Second belt: the service's own pre-flight for the same rules. + if (context is not null && !context.TryValidate(AiFeature.PoweredDocument, out var contextError)) + { + error = contextError; + return false; + } + + return true; + } + + [RelayCommand] + private void Discard() + { + HasPreview = false; + DraftTitle = ""; + DraftMarkdown = ""; + DraftIsPublic = false; + DraftOutline.Clear(); + OnPropertyChanged(nameof(HasOutline)); + ShowMarkdownSource = false; + UsageLabel = null; + ClearNotice(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void ToggleSourceView() => ShowMarkdownSource = !ShowMarkdownSource; + + private bool CanConfirm() => CanRunAi && HasPreview; + + [RelayCommand(CanExecute = nameof(CanConfirm))] + private async Task ConfirmAsync(CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(DraftTitle)) + { + RejectLocally("Give the document a title before saving it."); + return; + } + + if (string.IsNullOrWhiteSpace(DraftMarkdown)) + { + RejectLocally("The document is empty — there's nothing to save."); + return; + } + + // Mirrored server ceiling. /generate costs a unit, so an over-long + // document is rejected here rather than there. + if (DraftMarkdown.Length > MaxCharacters) + { + RejectLocally($"A document is capped at {MaxCharacters:N0} characters (this one is {DraftMarkdown.Length:N0}). Trim it before saving."); + return; + } + + var artifact = AiArtifact.FromPayload(new AiDocumentArtifact + { + Title = DraftTitle.Trim(), + Markdown = DraftMarkdown, + Outline = DraftOutline.ToList(), + IsPublic = DraftIsPublic + }); + + var result = await RunAiAsync( + "Saving your document…", + token => Session.Api.AiGenerateAsync(AiFeature.PoweredDocument, artifact, ct: token), + r => r.Quota, + ct); + + if (result is null) return; + + // /generate's envelope is contract-transcribed, not observed, so the id + // is a hint — prove the document exists before handing it to the host. + if (result.Created.DocumentId is { Length: > 0 } documentId) + { + var created = await RunApiAsync("Loading the new document…", token => Session.Api.GetDocumentAsync(documentId, token), ct); + + RefreshCommand?.Execute(null); + + if (created is not null) + { + Notice = AiNotice.Info($"Saved \"{created.Title}\"."); + Discard(); + IsOpen = false; + OpenDocumentCommand?.Execute(created); + return; + } + + Notice = AiNotice.Info("The document was saved, but loading it back failed. It's in the document list."); + Discard(); + return; + } + + RefreshCommand?.Execute(null); + Notice = new AiNotice(AiNoticeKind.Error, + "The server accepted the document but didn't return its id. Check the document list before trying again — a second attempt would create a duplicate and spend another AI credit."); + } +} diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml index 593ec5c..e86eb47 100644 --- a/InterlinedList/Views/DocumentsView.xaml +++ b/InterlinedList/Views/DocumentsView.xaml @@ -394,6 +394,9 @@ + + + +/// A small markdown → renderer, so #15's "rendered +/// markdown preview" is actually rendered rather than a wall of hashes and +/// asterisks. +/// +/// Why hand-rolled. The app has two NuGet references and no markdown +/// library, and the artifact it has to display is a narrow, known subset: +/// powered_document returns model-written prose with ATX headings, lists +/// and light inline emphasis (verified live — see +/// ). Pulling in a +/// CommonMark implementation to render six constructs would add a dependency to +/// a csproj several other open PRs are also touching. This covers what the +/// server actually emits and degrades to plain paragraphs for anything else, +/// which is the right failure mode for a preview. +/// +/// Everything it draws uses the existing Strata theme brushes looked up +/// dynamically, so it follows light/dark like the rest of the app and +/// introduces no colour of its own. +/// +/// Supported: ATX headings (#…######), fenced code blocks, +/// unordered (-/*/+) and ordered lists, GFM pipe tables, +/// blockquotes, horizontal rules, paragraphs, and inline **bold**, +/// *italic*, `code` and [text](url). Anything else renders +/// as literal text. +/// +/// Pipe tables are here because the server emits them. The live +/// powered_document probe came back with a GFM table +/// (| Date | Platform | Topic / Asset | Owner |) in the middle of the +/// draft — without table support those three lines would have collapsed into +/// one run-together paragraph of pipes and dashes, which is exactly the kind of +/// mangling a preview exists to prevent. Constructs the real payload used: +/// h1, h2, bullet lists, paragraphs, *italic*, and that table. +/// +internal static class MarkdownRenderer +{ + private const string MonoFonts = "JetBrains Mono, Consolas"; + + public static FlowDocument Render(string? markdown) + { + var document = new FlowDocument + { + PagePadding = new Thickness(0), + FontSize = 12.5, + LineHeight = 19, + Foreground = Brush("TextBodyBrush", Colors.Black) + }; + + if (string.IsNullOrWhiteSpace(markdown)) + return document; + + var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + var paragraph = new List(); + var index = 0; + + void FlushParagraph() + { + if (paragraph.Count == 0) return; + document.Blocks.Add(Body(string.Join(" ", paragraph))); + paragraph.Clear(); + } + + while (index < lines.Length) + { + var line = lines[index]; + var trimmed = line.TrimStart(); + + // ── Fenced code block ─────────────────────────────────────────── + if (MarkdownSyntax.IsCodeFence(trimmed)) + { + FlushParagraph(); + index++; + var code = new StringBuilder(); + while (index < lines.Length && !MarkdownSyntax.IsCodeFence(lines[index].TrimStart())) + { + code.AppendLine(lines[index]); + index++; + } + index++; // closing fence (or end of input) + document.Blocks.Add(CodeBlock(code.ToString().TrimEnd())); + continue; + } + + // ── Blank line ────────────────────────────────────────────────── + if (trimmed.Length == 0) + { + FlushParagraph(); + index++; + continue; + } + + // ── Horizontal rule ───────────────────────────────────────────── + if (MarkdownSyntax.IsHorizontalRule(trimmed)) + { + FlushParagraph(); + document.Blocks.Add(HorizontalRule()); + index++; + continue; + } + + // ── Heading ───────────────────────────────────────────────────── + if (MarkdownSyntax.TryReadHeading(trimmed, out var headingLevel, out var headingText)) + { + FlushParagraph(); + document.Blocks.Add(Heading(headingText, headingLevel)); + index++; + continue; + } + + // ── GFM pipe table ────────────────────────────────────────────── + // Needs a header row AND a |---|---| separator underneath, so a + // paragraph that merely contains a pipe isn't mistaken for one. + if (MarkdownSyntax.IsTableHeader(trimmed, index + 1 < lines.Length ? lines[index + 1] : null)) + { + FlushParagraph(); + + var header = MarkdownSyntax.SplitTableRow(trimmed); + index += 2; // header + separator + + var body = new List>(); + while (index < lines.Length) + { + var rowLine = lines[index].TrimStart(); + if (rowLine.Length == 0 || !rowLine.Contains('|')) break; + body.Add(MarkdownSyntax.SplitTableRow(rowLine)); + index++; + } + + document.Blocks.Add(TableBlock(header, body)); + continue; + } + + // ── Blockquote ────────────────────────────────────────────────── + if (trimmed[0] == '>') + { + FlushParagraph(); + var quote = new List(); + while (index < lines.Length && lines[index].TrimStart().StartsWith('>')) + { + quote.Add(lines[index].TrimStart().TrimStart('>').Trim()); + index++; + } + document.Blocks.Add(BlockQuote(string.Join(" ", quote))); + continue; + } + + // ── List ──────────────────────────────────────────────────────── + if (MarkdownSyntax.TryReadListMarker(trimmed, out var ordered, out _)) + { + FlushParagraph(); + var list = new List + { + MarkerStyle = ordered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc, + Margin = new Thickness(16, 4, 0, 8), + Padding = new Thickness(0) + }; + + while (index < lines.Length) + { + var itemLine = lines[index].TrimStart(); + if (itemLine.Length == 0) break; + if (!MarkdownSyntax.TryReadListMarker(itemLine, out var itemOrdered, out var content)) break; + if (itemOrdered != ordered) break; + + list.ListItems.Add(new ListItem(Body(content, bottomMargin: 2))); + index++; + } + + document.Blocks.Add(list); + continue; + } + + // ── Ordinary paragraph line ───────────────────────────────────── + paragraph.Add(trimmed); + index++; + } + + FlushParagraph(); + return document; + } + + // ── Block builders ────────────────────────────────────────────────────────── + + private static Paragraph Heading(string text, int level) + { + // Sizes stay on the app's existing type scale; no new tokens. + var size = level switch { 1 => 19.0, 2 => 16.0, 3 => 14.0, 4 => 13.0, _ => 12.5 }; + + var paragraph = new Paragraph + { + FontSize = size, + FontWeight = FontWeights.SemiBold, + Foreground = Brush("TextBrush", Colors.Black), + Margin = new Thickness(0, level == 1 ? 0 : 12, 0, 6) + }; + + AppendInline(paragraph.Inlines, text); + return paragraph; + } + + private static Paragraph Body(string text, double bottomMargin = 8) + { + var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, bottomMargin) }; + AppendInline(paragraph.Inlines, text); + return paragraph; + } + + private static Section BlockQuote(string text) + { + var body = Body(text, bottomMargin: 0); + body.Foreground = Brush("TextMutedBrush", Colors.Gray); + + // 3px amber edge: the brand's live/AI accent, same as the notice bar. + return new Section(body) + { + BorderBrush = Brush("AmberBrush", Color.FromRgb(0xF0, 0xA8, 0x30)), + BorderThickness = new Thickness(3, 0, 0, 0), + Padding = new Thickness(10, 4, 0, 4), + Margin = new Thickness(0, 4, 0, 8) + }; + } + + private static Section CodeBlock(string code) + { + var paragraph = new Paragraph(new Run(code)) + { + FontFamily = new FontFamily(MonoFonts), + FontSize = 11, + Margin = new Thickness(0) + }; + + return new Section(paragraph) + { + Background = Brush("Surface3Brush", Color.FromRgb(0xF1, 0xEA, 0xDD)), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 4, 0, 8) + }; + } + + /// + /// A GFM pipe table. Columns are sized off the widest row rather than the + /// header alone, so a header with fewer cells than a body row doesn't drop + /// data — malformed tables are common in generated markdown. + /// + private static Table TableBlock(List header, List> body) + { + var columns = Math.Max(header.Count, body.Count == 0 ? 0 : body.Max(row => row.Count)); + + var table = new Table + { + CellSpacing = 0, + Margin = new Thickness(0, 4, 0, 10), + BorderBrush = Brush("BorderBrush", Colors.Gray), + BorderThickness = new Thickness(1, 1, 0, 0) + }; + + for (var i = 0; i < columns; i++) + table.Columns.Add(new TableColumn()); + + var group = new TableRowGroup(); + table.RowGroups.Add(group); + + group.Rows.Add(BuildTableRow(header, columns, isHeader: true)); + foreach (var row in body) + group.Rows.Add(BuildTableRow(row, columns, isHeader: false)); + + return table; + } + + private static TableRow BuildTableRow(List cells, int columns, bool isHeader) + { + var row = new TableRow(); + if (isHeader) row.Background = Brush("Surface3Brush", Color.FromRgb(0xF1, 0xEA, 0xDD)); + + for (var i = 0; i < columns; i++) + { + var paragraph = new Paragraph { Margin = new Thickness(0), FontSize = 11 }; + if (isHeader) paragraph.FontWeight = FontWeights.SemiBold; + AppendInline(paragraph.Inlines, i < cells.Count ? cells[i] : ""); + + row.Cells.Add(new TableCell(paragraph) + { + Padding = new Thickness(7, 4, 7, 4), + BorderBrush = Brush("BorderBrush", Colors.Gray), + BorderThickness = new Thickness(0, 0, 1, 1) + }); + } + + return row; + } + + private static Paragraph HorizontalRule() => new() + { + BorderBrush = Brush("BorderStrongBrush", Colors.Gray), + BorderThickness = new Thickness(0, 0, 0, 1), + Margin = new Thickness(0, 8, 0, 12), + FontSize = 1 + }; + + // ── Inline parsing ────────────────────────────────────────────────────────── + + /// + /// Single left-to-right pass over **bold**, *italic*, + /// `code` and [text](url). An unclosed marker is emitted as + /// literal text rather than swallowing the rest of the line — a preview + /// should never lose the user's content to a parse failure. + /// + private static void AppendInline(InlineCollection inlines, string text) + { + var literal = new StringBuilder(); + var i = 0; + + void FlushLiteral() + { + if (literal.Length == 0) return; + inlines.Add(new Run(literal.ToString())); + literal.Clear(); + } + + while (i < text.Length) + { + // `code` + if (text[i] == '`') + { + var close = text.IndexOf('`', i + 1); + if (close > i) + { + FlushLiteral(); + inlines.Add(new Run(text[(i + 1)..close]) + { + FontFamily = new FontFamily(MonoFonts), + FontSize = 11.5 + }); + i = close + 1; + continue; + } + } + + // **bold** + if (text[i] == '*' && i + 1 < text.Length && text[i + 1] == '*') + { + var close = text.IndexOf("**", i + 2, StringComparison.Ordinal); + if (close > i + 1) + { + FlushLiteral(); + var bold = new Bold(); + AppendInline(bold.Inlines, text[(i + 2)..close]); + inlines.Add(bold); + i = close + 2; + continue; + } + } + + // *italic* / _italic_ + if (text[i] is '*' or '_') + { + var marker = text[i]; + var close = text.IndexOf(marker, i + 1); + if (close > i + 1) + { + FlushLiteral(); + var italic = new Italic(); + AppendInline(italic.Inlines, text[(i + 1)..close]); + inlines.Add(italic); + i = close + 1; + continue; + } + } + + // [text](url) — styled, not navigable. A preview shouldn't be able + // to launch a browser from model-authored text on a stray click. + if (text[i] == '[') + { + var closeBracket = text.IndexOf(']', i + 1); + if (closeBracket > i + && closeBracket + 1 < text.Length + && text[closeBracket + 1] == '(' + && text.IndexOf(')', closeBracket + 2) is var closeParen and > 0) + { + FlushLiteral(); + var label = text[(i + 1)..closeBracket]; + var url = text[(closeBracket + 2)..closeParen]; + + inlines.Add(new Run(label.Length > 0 ? label : url) + { + Foreground = Brush("LinkBrush", Colors.SteelBlue), + ToolTip = url + }); + i = closeParen + 1; + continue; + } + } + + literal.Append(text[i]); + i++; + } + + FlushLiteral(); + } + + /// + /// Resolve a theme brush by resource key, with a literal fallback so the + /// renderer still works in a designer or a unit-test context where no + /// Application exists. + /// + private static Brush Brush(string key, Color fallback) + => Application.Current?.TryFindResource(key) as Brush ?? new SolidColorBrush(fallback); +} diff --git a/InterlinedList/Views/MarkdownSyntax.cs b/InterlinedList/Views/MarkdownSyntax.cs new file mode 100644 index 0000000..aba17ea --- /dev/null +++ b/InterlinedList/Views/MarkdownSyntax.cs @@ -0,0 +1,126 @@ +namespace InterlinedList.Views; + +/// +/// The line-level markdown predicates classifies +/// with, split out from it deliberately: these are pure string functions with no +/// dependency on WPF, so they can be exercised on any platform, whereas +/// anything touching FlowDocument can only run on Windows. The +/// classification rules are the part most likely to get a construct wrong, so +/// they're the part worth being able to test. +/// +internal static class MarkdownSyntax +{ + /// + /// An ATX heading: 1–6 # followed by a space. Returns false for + /// #hashtag (no space) and for 7+ hashes, matching CommonMark. + /// + public static bool TryReadHeading(string line, out int level, out string text) + { + level = 0; + text = ""; + + var hashes = 0; + while (hashes < line.Length && line[hashes] == '#') hashes++; + + if (hashes is 0 or > 6) return false; + if (hashes >= line.Length || line[hashes] != ' ') return false; + + level = hashes; + text = line[(hashes + 1)..].Trim(); + return true; + } + + /// + /// A list item marker. distinguishes + /// 1./1) from -/*/+ so a renderer can + /// pick the right marker style and not merge the two kinds into one list. + /// + public static bool TryReadListMarker(string line, out bool ordered, out string content) + { + ordered = false; + content = ""; + + if (line.Length < 2) return false; + + if (line[0] is '-' or '*' or '+' && line[1] == ' ') + { + content = line[2..].Trim(); + return true; + } + + var digits = 0; + while (digits < line.Length && char.IsAsciiDigit(line[digits])) digits++; + + if (digits > 0 + && digits + 1 < line.Length + && line[digits] is '.' or ')' + && line[digits + 1] == ' ') + { + ordered = true; + content = line[(digits + 2)..].Trim(); + return true; + } + + return false; + } + + /// + /// A thematic break: three or more of the same -, * or + /// _, spaces allowed. Checked before the list rule, since + /// --- would otherwise never match and * * * would look like + /// a bullet. + /// + public static bool IsHorizontalRule(string line) + { + if (line.Length < 3) return false; + + var marker = line[0]; + if (marker is not ('-' or '*' or '_')) return false; + + var count = 0; + foreach (var c in line) + { + if (c == marker) count++; + else if (c != ' ') return false; + } + + return count >= 3; + } + + /// + /// The |---|:---:|---:| row under a GFM table header. Requires a + /// pipe and a dash and nothing but pipes, dashes, colons and spaces — which + /// is what tells a real table apart from a paragraph containing a dash. + /// + public static bool IsTableSeparator(string line) + { + var trimmed = line.Trim(); + if (!trimmed.Contains('|') || !trimmed.Contains('-')) return false; + + foreach (var c in trimmed) + if (c is not ('|' or '-' or ':' or ' ')) return false; + + return true; + } + + /// + /// A table row is a header candidate only when it contains a pipe and the + /// next line is a separator. Taking both lines is what stops a paragraph + /// with a stray | becoming a one-column table. + /// + public static bool IsTableHeader(string line, string? nextLine) + => line.Contains('|') && nextLine is not null && IsTableSeparator(nextLine); + + /// Split | a | b | c | into cells, dropping the outer pipes. + public static List SplitTableRow(string line) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith('|')) trimmed = trimmed[1..]; + if (trimmed.EndsWith('|')) trimmed = trimmed[..^1]; + + return trimmed.Split('|').Select(cell => cell.Trim()).ToList(); + } + + /// A fenced code block delimiter (``` or longer). + public static bool IsCodeFence(string line) => line.StartsWith("```", StringComparison.Ordinal); +} diff --git a/InterlinedList/Views/MarkdownViewer.cs b/InterlinedList/Views/MarkdownViewer.cs new file mode 100644 index 0000000..9e183ed --- /dev/null +++ b/InterlinedList/Views/MarkdownViewer.cs @@ -0,0 +1,47 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; + +namespace InterlinedList.Views; + +/// +/// A read-only rendered view of a markdown string. Bind +/// and it re-renders on every change: +/// +/// +/// <local:MarkdownViewer Markdown="{Binding DraftMarkdown}" MaxHeight="360"/> +/// +/// +/// Code-only (no XAML partner file) because it's a +/// with one dependency property and a +/// re-render callback — a markup file would only add indirection. +/// +/// stays off: the stock +/// toolbar brings page/zoom controls styled nothing like Strata. +/// +public sealed class MarkdownViewer : FlowDocumentScrollViewer +{ + public static readonly DependencyProperty MarkdownProperty = + DependencyProperty.Register( + nameof(Markdown), typeof(string), typeof(MarkdownViewer), + new PropertyMetadata(null, OnMarkdownChanged)); + + public MarkdownViewer() + { + IsToolBarVisible = false; + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + Padding = new Thickness(0); + Document = MarkdownRenderer.Render(null); + } + + /// The markdown source to render. + public string? Markdown + { + get => (string?)GetValue(MarkdownProperty); + set => SetValue(MarkdownProperty, value); + } + + private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + => ((MarkdownViewer)d).Document = MarkdownRenderer.Render(e.NewValue as string); +} diff --git a/InterlinedList/Views/PoweredDocumentPanel.xaml b/InterlinedList/Views/PoweredDocumentPanel.xaml new file mode 100644 index 0000000..2f2c448 --- /dev/null +++ b/InterlinedList/Views/PoweredDocumentPanel.xaml @@ -0,0 +1,407 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +