Normalize scalar frontmatter lists
PR and Push Build/Test / build-and-test (push) Successful in 8m49s

This commit is contained in:
2026-06-11 16:00:05 +02:00
parent bd786426bf
commit b22754ce5d
10 changed files with 131 additions and 17 deletions
@@ -105,6 +105,36 @@ public sealed class MeetingNoteStoreTests
Assert.Equal(userNotes, reloaded.UserNotes);
}
[Fact]
public async Task ReadConvertsScalarListFrontmatterToSingleItemLists()
{
var (vaultRoot, store) = CreateStore();
var notePath = Path.Combine(vaultRoot, "Meetings", "Notes", "manual.md");
Directory.CreateDirectory(Path.GetDirectoryName(notePath)!);
await File.WriteAllTextAsync(
notePath,
"""
---
title: Manual frontmatter
start_time: "2026-05-19T10:00:00.0000000+02:00"
end_time: ""
attendees: Ada
projects: Meeting Assistant
transcript: "[[../Transcripts/manual-transcript|Transcript]]"
assistant_context: "[[../Assistant Context/manual-context|Assistant Context]]"
summary: "[[../Summaries/manual-summary|Summary]]"
---
User notes.
""");
var loaded = await store.ReadAsync(notePath, CancellationToken.None);
Assert.Equal(["Ada"], loaded.Frontmatter.Attendees);
Assert.Equal(["Meeting Assistant"], loaded.Frontmatter.Projects);
Assert.Equal("User notes.", loaded.UserNotes);
}
[Fact]
public void ActionLinkEscapesSummaryFileName()
{
@@ -63,6 +63,34 @@ public sealed class ProjectKnowledgeToolTests
await tools.Search("alpha"));
}
[Fact]
public async Task ListProjectsAcceptsScalarProjectFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
projects: MeetingAssistant
---
""");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
}
[Fact]
public async Task WriteProjectFileSupportsAppendReplaceInsertOverwriteAndCreate()
{
@@ -0,0 +1,24 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.MeetingNotes;
internal static class FrontmatterYamlDeserializer
{
public static IDeserializer Create()
{
return new DeserializerBuilder()
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
public static IDeserializer CreateWithUnderscoredNaming()
{
return new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
}
@@ -16,9 +16,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
{
this.options = options.Value;
this.logger = logger;
yamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
yamlDeserializer = FrontmatterYamlDeserializer.Create();
}
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
@@ -0,0 +1,38 @@
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace MeetingAssistant.MeetingNotes;
internal sealed class ScalarStringListYamlTypeConverter : IYamlTypeConverter
{
public bool Accepts(Type type)
{
return type == typeof(List<string>);
}
public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
{
if (parser.TryConsume<Scalar>(out var scalar))
{
return string.IsNullOrWhiteSpace(scalar.Value)
? []
: new List<string> { scalar.Value };
}
var values = new List<string>();
parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
{
var item = parser.Consume<Scalar>();
values.Add(item.Value);
}
return values;
}
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
{
serializer(value, typeof(IEnumerable<string>));
}
}
@@ -7,9 +7,7 @@ public sealed record BoundMeetingProject(string Name, string Path);
public sealed class BoundMeetingProjectResolver
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create();
private readonly MeetingAssistantOptions options;
@@ -5,9 +5,7 @@ namespace MeetingAssistant.Summary;
internal static class MeetingSummaryFrontmatterFactory
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create();
public static async Task<MeetingArtifactFrontmatter> CreateAsync(
MeetingSessionArtifacts artifacts,
@@ -7,9 +7,7 @@ namespace MeetingAssistant.Summary;
public sealed class MeetingSummaryTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create();
private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options;
@@ -12,16 +12,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.Workflow;
public sealed class WorkflowRulesEditorTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.CreateWithUnderscoredNaming();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
+6
View File
@@ -48,6 +48,12 @@ Generated artifact notes SHALL link only to the other notes from the same run an
- **WHEN** Meeting Assistant creates a meeting note
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
#### Scenario: Scalar list frontmatter is normalized
- **GIVEN** a meeting note frontmatter field that Meeting Assistant expects as a list is stored as a scalar string
- **WHEN** Meeting Assistant reads the meeting note
- **THEN** it treats the scalar string as a one-item list
- **AND** continues processing the meeting note
#### Scenario: Generated artifacts do not self-reference
- **WHEN** Meeting Assistant writes transcript, assistant context, or summary artifact frontmatter
- **THEN** the artifact frontmatter links to the other run notes