Public Access
Fix YAML escaping for meeting note frontmatter
PR and Push Build/Test / build-and-test (push) Successful in 10m21s
PR and Push Build/Test / build-and-test (push) Successful in 10m21s
This commit is contained in:
@@ -135,6 +135,72 @@ public sealed class MeetingNoteStoreTests
|
|||||||
Assert.Equal("User notes.", loaded.UserNotes);
|
Assert.Equal("User notes.", loaded.UserNotes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreEscapesApostrophePrefixedAttendeesBeforeWritingFrontmatter()
|
||||||
|
{
|
||||||
|
var (store, saved) = await SaveNoteAsync(
|
||||||
|
title: "Escaped Attendees",
|
||||||
|
attendees: ["'Ada Lovelace"],
|
||||||
|
projects: [],
|
||||||
|
userNotes: "Discuss attendee import.");
|
||||||
|
var content = await File.ReadAllTextAsync(saved.Path);
|
||||||
|
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains("- \"'Ada Lovelace\"", content);
|
||||||
|
Assert.Equal(["'Ada Lovelace"], loaded.Frontmatter.Attendees);
|
||||||
|
Assert.Equal("Discuss attendee import.", loaded.UserNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Ada # platform lead")]
|
||||||
|
[InlineData("- Ada Lovelace")]
|
||||||
|
[InlineData("? Ada Lovelace")]
|
||||||
|
[InlineData("{Ada: Platform}")]
|
||||||
|
[InlineData("*Ada")]
|
||||||
|
[InlineData("&Ada")]
|
||||||
|
[InlineData("!Ada")]
|
||||||
|
[InlineData("| Ada")]
|
||||||
|
[InlineData("> Ada")]
|
||||||
|
[InlineData("@Ada")]
|
||||||
|
[InlineData("`Ada")]
|
||||||
|
[InlineData("true")]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("2026-07-08")]
|
||||||
|
[InlineData("Ada \"The Architect\" Lovelace")]
|
||||||
|
[InlineData("C:\\People\\Ada")]
|
||||||
|
public async Task StoreEscapesYamlSensitiveAttendeesBeforeWritingFrontmatter(string attendee)
|
||||||
|
{
|
||||||
|
var (store, saved) = await SaveNoteAsync(
|
||||||
|
title: "Escaped Attendees",
|
||||||
|
attendees: [attendee],
|
||||||
|
projects: [],
|
||||||
|
userNotes: "Discuss attendee import.");
|
||||||
|
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal([attendee], loaded.Frontmatter.Attendees);
|
||||||
|
Assert.Equal("Discuss attendee import.", loaded.UserNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Planning # Q3")]
|
||||||
|
[InlineData("- Planning")]
|
||||||
|
[InlineData("{Planning: Q3}")]
|
||||||
|
[InlineData("true")]
|
||||||
|
[InlineData("2026-07-08")]
|
||||||
|
public async Task StoreEscapesYamlSensitiveScalarAndProjectFrontmatterValues(string value)
|
||||||
|
{
|
||||||
|
var (store, saved) = await SaveNoteAsync(
|
||||||
|
title: value,
|
||||||
|
attendees: [],
|
||||||
|
projects: [value],
|
||||||
|
userNotes: "Discuss YAML escaping.");
|
||||||
|
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(value, loaded.Frontmatter.Title);
|
||||||
|
Assert.Equal([value], loaded.Frontmatter.Projects);
|
||||||
|
Assert.Equal("Discuss YAML escaping.", loaded.UserNotes);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ActionLinkEscapesSummaryFileName()
|
public void ActionLinkEscapesSummaryFileName()
|
||||||
{
|
{
|
||||||
@@ -147,6 +213,26 @@ public sealed class MeetingNoteStoreTests
|
|||||||
link);
|
link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<(MarkdownMeetingNoteStore Store, MeetingNote Saved)> SaveNoteAsync(
|
||||||
|
string title,
|
||||||
|
IReadOnlyList<string> attendees,
|
||||||
|
IReadOnlyList<string> projects,
|
||||||
|
string userNotes)
|
||||||
|
{
|
||||||
|
var (vaultRoot, store) = CreateStore();
|
||||||
|
var note = MeetingNoteTemplate.Create(
|
||||||
|
title: title,
|
||||||
|
attendees: attendees,
|
||||||
|
projects: projects,
|
||||||
|
transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||||
|
assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"),
|
||||||
|
summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"),
|
||||||
|
userNotes: userNotes);
|
||||||
|
|
||||||
|
var saved = await store.SaveAsync(note, CancellationToken.None);
|
||||||
|
return (store, saved);
|
||||||
|
}
|
||||||
|
|
||||||
private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore()
|
private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore()
|
||||||
{
|
{
|
||||||
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using YamlDotNet.Core;
|
using YamlDotNet.Core;
|
||||||
using YamlDotNet.Serialization;
|
using YamlDotNet.Serialization;
|
||||||
|
|
||||||
@@ -113,7 +115,24 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
|||||||
|
|
||||||
private static string EscapeQuoted(string value)
|
private static string EscapeQuoted(string value)
|
||||||
{
|
{
|
||||||
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
|
var escaped = new StringBuilder(value.Length + 2);
|
||||||
|
escaped.Append('"');
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
escaped.Append(character switch
|
||||||
|
{
|
||||||
|
'\\' => "\\\\",
|
||||||
|
'"' => "\\\"",
|
||||||
|
'\r' => "\\r",
|
||||||
|
'\n' => "\\n",
|
||||||
|
'\t' => "\\t",
|
||||||
|
< ' ' => "\\x" + ((int)character).ToString("X2", CultureInfo.InvariantCulture),
|
||||||
|
_ => character.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
escaped.Append('"');
|
||||||
|
return escaped.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EscapeNullableDateTime(DateTimeOffset? value)
|
private static string EscapeNullableDateTime(DateTimeOffset? value)
|
||||||
@@ -135,7 +154,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
|||||||
return "\"\"";
|
return "\"\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal))
|
if (RequiresQuotedScalar(value))
|
||||||
{
|
{
|
||||||
return EscapeQuoted(value);
|
return EscapeQuoted(value);
|
||||||
}
|
}
|
||||||
@@ -143,6 +162,49 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool RequiresQuotedScalar(string value)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1]))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains(':', StringComparison.Ordinal) ||
|
||||||
|
value.Contains('\'', StringComparison.Ordinal) ||
|
||||||
|
value.Contains(" #", StringComparison.Ordinal) ||
|
||||||
|
value.Any(char.IsControl))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsYamlIndicator(value[0]))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsYamlCoreSchemaKeyword(value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTimeOffset.TryParse(value, out _) ||
|
||||||
|
double.TryParse(value, CultureInfo.InvariantCulture, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsYamlIndicator(char character)
|
||||||
|
{
|
||||||
|
return character is '-' or '?' or ':' or ',' or '[' or ']' or '{' or '}' or
|
||||||
|
'#' or '&' or '*' or '!' or '|' or '>' or '\'' or '"' or '%' or '@' or '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsYamlCoreSchemaKeyword(string value)
|
||||||
|
{
|
||||||
|
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(value, "null", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
value == "~";
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class MeetingNoteYaml
|
private sealed class MeetingNoteYaml
|
||||||
{
|
{
|
||||||
[YamlMember(Alias = "title")]
|
[YamlMember(Alias = "title")]
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ The meeting note frontmatter SHALL link to the transcript, assistant context, an
|
|||||||
|
|
||||||
Generated artifact notes SHALL link only to the other notes from the same run and SHALL omit the frontmatter property that would reference themselves.
|
Generated artifact notes SHALL link only to the other notes from the same run and SHALL omit the frontmatter property that would reference themselves.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL escape generated meeting-note frontmatter string values after metadata enrichment and workflow rules have been applied, immediately before writing the final markdown file.
|
||||||
|
|
||||||
#### Scenario: Meeting note links to generated artifacts
|
#### Scenario: Meeting note links to generated artifacts
|
||||||
- **WHEN** Meeting Assistant creates a meeting note
|
- **WHEN** Meeting Assistant creates a meeting note
|
||||||
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
|
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
|
||||||
@@ -59,6 +61,12 @@ Generated artifact notes SHALL link only to the other notes from the same run an
|
|||||||
- **THEN** the artifact frontmatter links to the other run notes
|
- **THEN** the artifact frontmatter links to the other run notes
|
||||||
- **AND** the artifact frontmatter omits the property for the artifact's own note type
|
- **AND** the artifact frontmatter omits the property for the artifact's own note type
|
||||||
|
|
||||||
|
#### Scenario: Generated frontmatter remains parseable after attendee transforms
|
||||||
|
- **GIVEN** meeting metadata or workflow rules produce an attendee name that contains a single quote
|
||||||
|
- **WHEN** Meeting Assistant writes the final meeting note
|
||||||
|
- **THEN** the attendee value is escaped in frontmatter
|
||||||
|
- **AND** the meeting note frontmatter remains parseable when read back
|
||||||
|
|
||||||
### Requirement: Meeting notes preserve user-authored content
|
### Requirement: Meeting notes preserve user-authored content
|
||||||
Meeting Assistant SHALL preserve user-authored meeting notes and include them as input when generating summaries, decisions, and next steps.
|
Meeting Assistant SHALL preserve user-authored meeting notes and include them as input when generating summaries, decisions, and next steps.
|
||||||
|
|
||||||
@@ -354,4 +362,3 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru
|
|||||||
- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom
|
- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom
|
||||||
- **WHEN** a new user or assistant message is appended
|
- **WHEN** a new user or assistant message is appended
|
||||||
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
|
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user