Files
meeting-assistant/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs
T
codex b22754ce5d
PR and Push Build/Test / build-and-test (push) Successful in 8m49s
Normalize scalar frontmatter lists
2026-06-11 16:00:05 +02:00

173 lines
6.1 KiB
C#

using Microsoft.Extensions.Options;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace MeetingAssistant.MeetingNotes;
public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
{
private readonly MeetingAssistantOptions options;
private readonly ILogger<MarkdownMeetingNoteStore> logger;
private readonly IDeserializer yamlDeserializer;
public MarkdownMeetingNoteStore(
IOptions<MeetingAssistantOptions> options,
ILogger<MarkdownMeetingNoteStore> logger)
{
this.options = options.Value;
this.logger = logger;
yamlDeserializer = FrontmatterYamlDeserializer.Create();
}
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
return await SaveAsync(note, options, cancellationToken);
}
public async Task<MeetingNote> SaveAsync(
MeetingNote note,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
Directory.CreateDirectory(folder);
var path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(folder, MeetingArtifactFileNames.Create(note.Frontmatter.StartTime ?? DateTimeOffset.Now, MeetingArtifactFileNames.Note))
: note.Path;
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
var content = Render(frontmatter, note.UserNotes);
await File.WriteAllTextAsync(path, content, cancellationToken);
logger.LogInformation("Saved meeting note {MeetingNotePath}", path);
return new MeetingNote(path, frontmatter, note.UserNotes);
}
public async Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
{
var content = await File.ReadAllTextAsync(path, cancellationToken);
var document = MarkdownDocumentParser.SplitRequired(
content,
"Meeting note does not start with YAML frontmatter.",
"Meeting note frontmatter is not closed.");
var yaml = yamlDeserializer.Deserialize<MeetingNoteYaml>(document.Frontmatter) ?? new MeetingNoteYaml();
var frontmatter = new MeetingNoteFrontmatter
{
Title = yaml.Title ?? "",
StartTime = ParseDateTime(yaml.StartTime),
EndTime = ParseDateTime(yaml.EndTime),
Attendees = yaml.Attendees ?? [],
Projects = yaml.Projects ?? [],
Transcript = yaml.Transcript ?? "",
AssistantContext = yaml.AssistantContext ?? "",
Summary = yaml.Summary ?? ""
};
return new MeetingNote(path, frontmatter, document.Body);
}
private MeetingNoteFrontmatter PrepareFrontmatter(MeetingNoteFrontmatter frontmatter, string notePath)
{
return new MeetingNoteFrontmatter
{
Title = frontmatter.Title,
StartTime = frontmatter.StartTime,
EndTime = frontmatter.EndTime,
Attendees = frontmatter.Attendees,
Projects = frontmatter.Projects,
Transcript = ObsidianLink.ToWikiLink(frontmatter.Transcript, notePath, "Transcript"),
AssistantContext = ObsidianLink.ToWikiLink(frontmatter.AssistantContext, notePath, "Assistant Context"),
Summary = ObsidianLink.ToWikiLink(frontmatter.Summary, notePath, "Summary")
};
}
private static string Render(MeetingNoteFrontmatter frontmatter, string userNotes)
{
var lines = new List<string>
{
"---",
$"title: {EscapeScalar(frontmatter.Title)}",
$"start_time: {EscapeNullableDateTime(frontmatter.StartTime)}",
$"end_time: {EscapeNullableDateTime(frontmatter.EndTime)}",
"attendees:"
};
lines.AddRange(frontmatter.Attendees.Select(attendee => $"- {EscapeListItem(attendee)}"));
lines.Add("projects:");
lines.AddRange(frontmatter.Projects.Select(project => $"- {EscapeListItem(project)}"));
lines.Add($"transcript: {EscapeQuoted(frontmatter.Transcript)}");
lines.Add($"assistant_context: {EscapeQuoted(frontmatter.AssistantContext)}");
lines.Add($"summary: {EscapeQuoted(frontmatter.Summary)}");
lines.Add("---");
lines.Add("");
lines.Add(userNotes);
return string.Join(Environment.NewLine, lines);
}
private static string EscapeScalar(string value)
{
return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value);
}
private static string EscapeQuoted(string value)
{
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
}
private static string EscapeNullableDateTime(DateTimeOffset? value)
{
return value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O"));
}
private static DateTimeOffset? ParseDateTime(string? value)
{
return DateTimeOffset.TryParse(value, out var parsed)
? parsed
: null;
}
private static string EscapeListItem(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "\"\"";
}
if (value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal))
{
return EscapeQuoted(value);
}
return value;
}
private sealed class MeetingNoteYaml
{
[YamlMember(Alias = "title")]
public string? Title { get; set; }
[YamlMember(Alias = "start_time")]
public string? StartTime { get; set; }
[YamlMember(Alias = "end_time")]
public string? EndTime { get; set; }
[YamlMember(Alias = "attendees")]
public List<string>? Attendees { get; set; }
[YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
[YamlMember(Alias = "transcript")]
public string? Transcript { get; set; }
[YamlMember(Alias = "assistant_context")]
public string? AssistantContext { get; set; }
[YamlMember(Alias = "summary")]
public string? Summary { get; set; }
}
}