Public Access
228 lines
7.3 KiB
C#
228 lines
7.3 KiB
C#
using MeetingAssistant.MeetingNotes;
|
|
using Microsoft.Extensions.Options;
|
|
using YamlDotNet.Serialization;
|
|
|
|
namespace MeetingAssistant.Summary;
|
|
|
|
public interface IMeetingSummaryArtifactResolver
|
|
{
|
|
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
|
string summaryPath,
|
|
CancellationToken cancellationToken);
|
|
|
|
Task<MeetingSessionArtifacts?> 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<MeetingAssistantOptions> options,
|
|
IMeetingNoteStore meetingNoteStore)
|
|
{
|
|
this.options = options.Value;
|
|
this.meetingNoteStore = meetingNoteStore;
|
|
}
|
|
|
|
public async Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
|
string summaryPath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await ResolveBySummaryPathAsync(summaryPath, options, cancellationToken);
|
|
}
|
|
|
|
public async Task<MeetingSessionArtifacts?> 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<MeetingSessionArtifacts?> 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<SummaryArtifactLinks>(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; }
|
|
}
|
|
}
|