Files
codex e9825f5bf5
PR and Push Build/Test / build-and-test (push) Successful in 8m13s
Improve settings logs agent and renderer
2026-06-02 00:09:03 +02:00

535 lines
17 KiB
C#

namespace MeetingAssistant.Workflow;
internal enum WorkflowRulesEditorMarkdownInlineStyle
{
Normal,
Italic,
Bold,
BoldItalic,
Strikethrough,
Link,
Image,
Code
}
internal abstract record WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownParagraph(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownHeading(
int Level,
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownBlockQuote(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownImage(
string AltText,
string Target) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTaskList(
IReadOnlyList<WorkflowRulesEditorMarkdownTaskItem> Items) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTaskItem(
bool IsChecked,
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines);
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTable(
IReadOnlyList<string> Header,
IReadOnlyList<IReadOnlyList<string>> Rows) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownInline(
string Text,
WorkflowRulesEditorMarkdownInlineStyle Style,
string? LinkTarget = null);
internal static class WorkflowRulesEditorMarkdown
{
public static IReadOnlyList<WorkflowRulesEditorMarkdownBlock> Parse(string text)
{
if (string.IsNullOrEmpty(text))
{
return [];
}
var blocks = new List<WorkflowRulesEditorMarkdownBlock>();
var paragraph = new List<string>();
var code = new List<string>();
var inCodeBlock = false;
var lines = ReadLines(text).ToArray();
for (var index = 0; index < lines.Length; index++)
{
var line = lines[index];
if (line.TrimStart().StartsWith("```", StringComparison.Ordinal))
{
if (inCodeBlock)
{
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
code.Clear();
inCodeBlock = false;
}
else
{
FlushParagraph(blocks, paragraph);
inCodeBlock = true;
}
continue;
}
if (inCodeBlock)
{
code.Add(line);
continue;
}
if (TryParseStandaloneImage(line, out var image))
{
FlushParagraph(blocks, paragraph);
blocks.Add(image);
continue;
}
if (TryParseHeading(line, out var heading))
{
FlushParagraph(blocks, paragraph);
blocks.Add(heading);
continue;
}
if (IsBlockQuote(line))
{
FlushParagraph(blocks, paragraph);
var quote = new List<string>();
while (index < lines.Length && IsBlockQuote(lines[index]))
{
quote.Add(lines[index].TrimStart()[1..].TrimStart());
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownBlockQuote(ParseInline(string.Join('\n', quote))));
index--;
continue;
}
if (TryParseTaskLine(line, out _))
{
FlushParagraph(blocks, paragraph);
var tasks = new List<WorkflowRulesEditorMarkdownTaskItem>();
while (index < lines.Length && TryParseTaskLine(lines[index], out var task))
{
tasks.Add(task);
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownTaskList(tasks));
index--;
continue;
}
if (IsTableStart(lines, index))
{
FlushParagraph(blocks, paragraph);
var header = ParseTableRow(lines[index]);
index += 2;
var rows = new List<IReadOnlyList<string>>();
while (index < lines.Length && IsTableRow(lines[index]))
{
rows.Add(ParseTableRow(lines[index]));
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownTable(header, rows));
index--;
continue;
}
if (string.IsNullOrWhiteSpace(line))
{
FlushParagraph(blocks, paragraph);
}
else
{
paragraph.Add(line);
}
}
if (inCodeBlock)
{
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
}
FlushParagraph(blocks, paragraph);
return blocks;
}
public static IReadOnlyList<WorkflowRulesEditorMarkdownInline> ParseInline(string text)
{
var result = new List<WorkflowRulesEditorMarkdownInline>();
var index = 0;
while (index < text.Length)
{
var next = FindNextMarker(text, index);
if (next < 0)
{
AddInline(result, text[index..], WorkflowRulesEditorMarkdownInlineStyle.Normal);
break;
}
if (next > index)
{
AddInline(result, text[index..next], WorkflowRulesEditorMarkdownInlineStyle.Normal);
}
if (text[next] == '`')
{
var end = text.IndexOf('`', next + 1);
if (end > next)
{
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Code);
index = end + 1;
continue;
}
}
else if (text.AsSpan(next).StartsWith("![", StringComparison.Ordinal))
{
if (TryParseImage(text, next, out var altText, out var target, out var end))
{
AddInline(
result,
altText,
WorkflowRulesEditorMarkdownInlineStyle.Image,
target);
index = end + 1;
continue;
}
}
else if (text[next] == '[')
{
var endLabel = text.IndexOf(']', next + 1);
if (endLabel > next &&
endLabel + 1 < text.Length &&
text[endLabel + 1] == '(')
{
var endTarget = text.IndexOf(')', endLabel + 2);
if (endTarget > endLabel + 2)
{
AddInline(
result,
text[(next + 1)..endLabel],
WorkflowRulesEditorMarkdownInlineStyle.Link,
text[(endLabel + 2)..endTarget]);
index = endTarget + 1;
continue;
}
}
}
else if (text[next] == '<')
{
if (TryParseAutolink(text, next, out var label, out var target, out var end))
{
AddInline(
result,
label,
WorkflowRulesEditorMarkdownInlineStyle.Link,
target);
index = end + 1;
continue;
}
}
else if (text.AsSpan(next).StartsWith("***", StringComparison.Ordinal))
{
var end = text.IndexOf("***", next + 3, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 3)..end], WorkflowRulesEditorMarkdownInlineStyle.BoldItalic);
index = end + 3;
continue;
}
}
else if (text.AsSpan(next).StartsWith("~~", StringComparison.Ordinal))
{
var end = text.IndexOf("~~", next + 2, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Strikethrough);
index = end + 2;
continue;
}
}
else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal))
{
var end = text.IndexOf("**", next + 2, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Bold);
index = end + 2;
continue;
}
}
else if (text[next] == '*')
{
var end = text.IndexOf('*', next + 1);
if (end > next)
{
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Italic);
index = end + 1;
continue;
}
}
AddInline(result, text[next].ToString(), WorkflowRulesEditorMarkdownInlineStyle.Normal);
index = next + 1;
}
return result;
}
private static void FlushParagraph(
List<WorkflowRulesEditorMarkdownBlock> blocks,
List<string> paragraph)
{
if (paragraph.Count == 0)
{
return;
}
blocks.Add(new WorkflowRulesEditorMarkdownParagraph(ParseInline(string.Join('\n', paragraph))));
paragraph.Clear();
}
private static bool TryParseHeading(string line, out WorkflowRulesEditorMarkdownHeading heading)
{
heading = null!;
var trimmed = line.TrimStart();
var level = trimmed.StartsWith("# ", StringComparison.Ordinal)
? 1
: trimmed.StartsWith("## ", StringComparison.Ordinal)
? 2
: 0;
if (level == 0)
{
return false;
}
heading = new WorkflowRulesEditorMarkdownHeading(level, ParseInline(trimmed));
return true;
}
private static bool IsBlockQuote(string line)
{
return line.TrimStart().StartsWith('>');
}
private static bool TryParseTaskLine(string line, out WorkflowRulesEditorMarkdownTaskItem task)
{
task = null!;
var trimmedStart = line.TrimStart();
if (trimmedStart.StartsWith("- [x] ", StringComparison.OrdinalIgnoreCase))
{
task = new WorkflowRulesEditorMarkdownTaskItem(true, ParseInline(trimmedStart[6..]));
return true;
}
if (trimmedStart.StartsWith("- [ ] ", StringComparison.Ordinal))
{
task = new WorkflowRulesEditorMarkdownTaskItem(false, ParseInline(trimmedStart[6..]));
return true;
}
return false;
}
private static bool TryParseStandaloneImage(
string line,
out WorkflowRulesEditorMarkdownImage image)
{
image = null!;
var trimmed = line.Trim();
if (!TryParseImage(trimmed, 0, out var altText, out var target, out var end) ||
end != trimmed.Length - 1)
{
return false;
}
image = new WorkflowRulesEditorMarkdownImage(altText, target);
return true;
}
private static bool TryParseImage(
string text,
int start,
out string altText,
out string target,
out int end)
{
altText = "";
target = "";
end = -1;
if (!text.AsSpan(start).StartsWith("![", StringComparison.Ordinal))
{
return false;
}
var endLabel = text.IndexOf(']', start + 2);
if (endLabel <= start ||
endLabel + 1 >= text.Length ||
text[endLabel + 1] != '(')
{
return false;
}
var endTarget = text.IndexOf(')', endLabel + 2);
if (endTarget <= endLabel + 2)
{
return false;
}
altText = text[(start + 2)..endLabel];
target = text[(endLabel + 2)..endTarget];
end = endTarget;
return true;
}
private static bool TryParseAutolink(
string text,
int start,
out string label,
out string target,
out int end)
{
label = "";
target = "";
end = text.IndexOf('>', start + 1);
if (end <= start + 1)
{
return false;
}
label = text[(start + 1)..end];
if (label.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
label.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
label.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
{
target = label;
return true;
}
if (IsEmailAddress(label))
{
target = "mailto:" + label;
return true;
}
return false;
}
private static bool IsEmailAddress(string text)
{
var at = text.IndexOf('@');
return at > 0 &&
at < text.Length - 1 &&
text.IndexOf('.', at + 1) > at + 1 &&
!text.Any(char.IsWhiteSpace);
}
private static bool IsTableStart(IReadOnlyList<string> lines, int index)
{
return index + 1 < lines.Count &&
IsTableRow(lines[index]) &&
IsTableSeparator(lines[index + 1]);
}
private static bool IsTableRow(string line)
{
return !string.IsNullOrWhiteSpace(line) && line.Contains('|', StringComparison.Ordinal);
}
private static bool IsTableSeparator(string line)
{
var cells = ParseTableRow(line);
return cells.Count > 0 &&
cells.All(cell =>
{
var trimmed = cell.Trim();
return trimmed.Contains('-', StringComparison.Ordinal) &&
trimmed.All(character => character is '-' or ':' or ' ');
});
}
private static IReadOnlyList<string> ParseTableRow(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())
.ToArray();
}
private static void AddInline(
List<WorkflowRulesEditorMarkdownInline> result,
string text,
WorkflowRulesEditorMarkdownInlineStyle style,
string? linkTarget = null)
{
if (text.Length == 0)
{
return;
}
if (result.Count > 0 && result[^1].Style == style && result[^1].LinkTarget == linkTarget)
{
result[^1] = result[^1] with { Text = result[^1].Text + text };
return;
}
result.Add(new WorkflowRulesEditorMarkdownInline(text, style, linkTarget));
}
private static int FindNextMarker(string text, int start)
{
var code = text.IndexOf('`', start);
var image = text.IndexOf("![", start, StringComparison.Ordinal);
var link = text.IndexOf('[', start);
var autolink = text.IndexOf('<', start);
var boldItalic = text.IndexOf("***", start, StringComparison.Ordinal);
var strikethrough = text.IndexOf("~~", start, StringComparison.Ordinal);
var bold = text.IndexOf("**", start, StringComparison.Ordinal);
var italic = text.IndexOf('*', start);
while (italic >= 0 &&
italic + 1 < text.Length &&
text[italic + 1] == '*')
{
italic = text.IndexOf('*', italic + 2);
}
return new[] { code, image, link, autolink, boldItalic, strikethrough, bold, italic }
.Where(index => index >= 0)
.DefaultIfEmpty(-1)
.Min();
}
private static IEnumerable<string> ReadLines(string text)
{
using var reader = new StringReader(text);
while (reader.ReadLine() is { } line)
{
yield return line;
}
}
}