using MeetingAssistant.MeetingNotes; using Microsoft.Extensions.Options; using YamlDotNet.Serialization; namespace MeetingAssistant.Summary; public interface IMeetingSummaryArtifactResolver { Task ResolveBySummaryPathAsync( string summaryPath, CancellationToken cancellationToken); Task ResolveBySummaryPathAsync( string summaryPath, MeetingAssistantOptions options, CancellationToken cancellationToken); } public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver { private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() .IgnoreUnmatchedProperties() .Build(); private readonly MeetingAssistantOptions options; private readonly IMeetingNoteStore meetingNoteStore; public MeetingSummaryArtifactResolver( IOptions options, IMeetingNoteStore meetingNoteStore) { this.options = options.Value; this.meetingNoteStore = meetingNoteStore; } public async Task ResolveBySummaryPathAsync( string summaryPath, CancellationToken cancellationToken) { return await ResolveBySummaryPathAsync(summaryPath, options, cancellationToken); } public async Task ResolveBySummaryPathAsync( string summaryPath, MeetingAssistantOptions options, CancellationToken cancellationToken) { var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath, options); if (requestedSummaryPath is null) { return null; } var summaryArtifacts = await ResolveFromSummaryFrontmatterAsync( requestedSummaryPath, cancellationToken); if (summaryArtifacts is not null) { return summaryArtifacts; } var meetingNotesFolder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder); if (!Directory.Exists(meetingNotesFolder)) { return null; } foreach (var meetingNotePath in Directory.EnumerateFiles(meetingNotesFolder, "*.md")) { var note = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken); var noteSummaryPath = ResolveNoteLink(note.Frontmatter.Summary, note.Path); if (!PathsEqual(requestedSummaryPath, noteSummaryPath)) { continue; } return TryCreateArtifactsFromLinks( note.Path, requestedSummaryPath, note.Frontmatter.Transcript, note.Frontmatter.AssistantContext); } return null; } private static async Task ResolveFromSummaryFrontmatterAsync( string requestedSummaryPath, CancellationToken cancellationToken) { if (!File.Exists(requestedSummaryPath)) { return null; } var content = await File.ReadAllTextAsync(requestedSummaryPath, cancellationToken); var (frontmatter, _) = MeetingArtifactFrontmatterRenderer.Split(content); if (string.IsNullOrWhiteSpace(frontmatter)) { return null; } SummaryArtifactLinks? links; try { links = YamlDeserializer.Deserialize(frontmatter); } catch (YamlDotNet.Core.YamlException) { return null; } if (string.IsNullOrWhiteSpace(links?.Meeting) || string.IsNullOrWhiteSpace(links.Transcript) || string.IsNullOrWhiteSpace(links.AssistantContext)) { return null; } return TryCreateArtifactsFromLinks( requestedSummaryPath, requestedSummaryPath, links.Transcript, links.AssistantContext, links.Meeting); } private static MeetingSessionArtifacts? TryCreateArtifactsFromLinks( string sourceNotePath, string requestedSummaryPath, string transcriptLink, string assistantContextLink, string? meetingLink = null) { var assistantContextPath = ResolveNoteLink(assistantContextLink, sourceNotePath); if (string.IsNullOrWhiteSpace(assistantContextPath) || !File.Exists(assistantContextPath)) { return null; } return new MeetingSessionArtifacts( meetingLink is null ? sourceNotePath : ResolveNoteLink(meetingLink, sourceNotePath), ResolveNoteLink(transcriptLink, sourceNotePath), assistantContextPath, requestedSummaryPath); } private static string? ResolveRequestedSummaryPath( string summaryPath, MeetingAssistantOptions options) { if (string.IsNullOrWhiteSpace(summaryPath)) { return null; } var summariesFolder = VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder); var requested = Environment.ExpandEnvironmentVariables(summaryPath); var fullPath = Path.IsPathRooted(requested) ? Path.GetFullPath(requested) : Path.GetFullPath(Path.Combine(summariesFolder, requested)); return IsWithinDirectory(summariesFolder, fullPath) ? fullPath : null; } private static string ResolveNoteLink(string pathOrLink, string sourceNotePath) { if (string.IsNullOrWhiteSpace(pathOrLink)) { return ""; } var sourceFolder = Path.GetDirectoryName(sourceNotePath) ?? ""; var target = ExtractWikiLinkTarget(pathOrLink) ?? pathOrLink; target = target.Replace('/', Path.DirectorySeparatorChar); if (!Path.HasExtension(target)) { target += ".md"; } return Path.GetFullPath(Path.IsPathRooted(target) ? target : Path.Combine(sourceFolder, target)); } private static string? ExtractWikiLinkTarget(string value) { if (!value.StartsWith("[[", StringComparison.Ordinal) || !value.EndsWith("]]", StringComparison.Ordinal)) { return null; } var inner = value[2..^2]; var labelSeparator = inner.IndexOf('|', StringComparison.Ordinal); return labelSeparator < 0 ? inner : inner[..labelSeparator]; } private static bool PathsEqual(string left, string right) { return string.Equals( Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); } private static bool IsWithinDirectory(string directory, string path) { var normalizedDirectory = Path.GetFullPath(directory) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var normalizedPath = Path.GetFullPath(path); return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase); } private sealed class SummaryArtifactLinks { [YamlMember(Alias = "meeting")] public string? Meeting { get; set; } [YamlMember(Alias = "transcript")] public string? Transcript { get; set; } [YamlMember(Alias = "assistant_context")] public string? AssistantContext { get; set; } } }