Files
meeting-assistant/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs
T

177 lines
6.3 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 = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
}
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
Directory.CreateDirectory(folder);
var path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Slugify(note.Frontmatter.Title)}.md")
: 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 Slugify(string value)
{
var slug = new string(value
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray());
slug = string.Join("-", slug.Split('-', StringSplitOptions.RemoveEmptyEntries));
return string.IsNullOrWhiteSpace(slug) ? "meeting" : slug;
}
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; }
}
}