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 logger; private readonly IDeserializer yamlDeserializer; public MarkdownMeetingNoteStore( IOptions options, ILogger logger) { this.options = options.Value; this.logger = logger; yamlDeserializer = FrontmatterYamlDeserializer.Create(); } public async Task SaveAsync(MeetingNote note, CancellationToken cancellationToken) { return await SaveAsync(note, options, cancellationToken); } public async Task 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 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(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 { "---", $"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? Attendees { get; set; } [YamlMember(Alias = "projects")] public List? 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; } } }