Public Access
177 lines
6.2 KiB
C#
177 lines
6.2 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.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; }
|
|
}
|
|
}
|