Implement meeting assistant v1

This commit is contained in:
2026-05-20 02:06:16 +02:00
parent 90df1edc03
commit 0297bcc0f6
120 changed files with 11883 additions and 180 deletions
@@ -0,0 +1,24 @@
namespace MeetingAssistant.MeetingNotes;
public interface IMeetingArtifactStore
{
Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken);
Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
CancellationToken cancellationToken);
}
public enum AssistantContextState
{
Transcribing,
SpeakerRecognition,
Summarizing,
Finished,
Error
}
@@ -0,0 +1,24 @@
namespace MeetingAssistant.MeetingNotes;
public interface IMeetingMetadataProvider
{
Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken);
}
public sealed record MeetingMetadata(
string Title,
IReadOnlyList<string> Attendees,
string Agenda,
DateTimeOffset? ScheduledEnd = null);
public sealed class NoopMeetingMetadataProvider : IMeetingMetadataProvider
{
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return Task.FromResult<MeetingMetadata?>(null);
}
}
@@ -0,0 +1,6 @@
namespace MeetingAssistant.MeetingNotes;
public interface IMeetingNoteOpener
{
Task OpenAsync(string notePath, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace MeetingAssistant.MeetingNotes;
public interface IMeetingNoteStore
{
Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken);
Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken);
}
@@ -0,0 +1,76 @@
namespace MeetingAssistant.MeetingNotes;
internal readonly record struct MarkdownDocument(
bool HasFrontmatter,
string Frontmatter,
string Body);
internal static class MarkdownDocumentParser
{
public static MarkdownDocument SplitOptional(string content)
{
using var reader = new StringReader(content);
if (reader.ReadLine() != "---")
{
return new MarkdownDocument(false, "", content);
}
var yaml = new List<string>();
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line == "---")
{
return new MarkdownDocument(
true,
string.Join(Environment.NewLine, yaml),
RemoveBodySeparator(reader.ReadToEnd()));
}
yaml.Add(line);
}
return new MarkdownDocument(false, "", content);
}
public static MarkdownDocument SplitRequired(
string content,
string missingFrontmatterMessage,
string unclosedFrontmatterMessage)
{
using var reader = new StringReader(content);
if (reader.ReadLine() != "---")
{
throw new InvalidDataException(missingFrontmatterMessage);
}
var yaml = new List<string>();
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line == "---")
{
return new MarkdownDocument(
true,
string.Join(Environment.NewLine, yaml),
RemoveBodySeparator(reader.ReadToEnd()));
}
yaml.Add(line);
}
throw new InvalidDataException(unclosedFrontmatterMessage);
}
private static string RemoveBodySeparator(string body)
{
if (body.StartsWith("\r\n", StringComparison.Ordinal))
{
return body[2..];
}
return body.StartsWith('\n')
? body[1..]
: body;
}
}
@@ -0,0 +1,123 @@
namespace MeetingAssistant.MeetingNotes;
using YamlDotNet.Serialization;
public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly ILogger<MarkdownMeetingArtifactStore> logger;
public MarkdownMeetingArtifactStore(ILogger<MarkdownMeetingArtifactStore> logger)
{
this.logger = logger;
}
public async Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var content = Render(
artifacts,
AssistantContextState.Transcribing,
agenda,
scheduledEnd,
"# Assistant Context" + Environment.NewLine + Environment.NewLine + "## Live Context" + Environment.NewLine);
await File.WriteAllTextAsync(artifacts.AssistantContextPath, content, cancellationToken);
logger.LogInformation("Created assistant context note {AssistantContextPath}", artifacts.AssistantContextPath);
}
public async Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var existing = File.Exists(artifacts.AssistantContextPath)
? MarkdownDocumentParser.SplitOptional(await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken))
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
var body = existing.Body;
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
var agenda = existingFrontmatter.Agenda ?? "";
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
Render(artifacts, state, agenda, scheduledEnd, body),
cancellationToken);
logger.LogInformation(
"Updated assistant context note {AssistantContextPath} state to {State}",
artifacts.AssistantContextPath,
ToYamlState(state));
}
private static string Render(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
string? agenda,
DateTimeOffset? scheduledEnd,
string body)
{
var frontmatter = new MeetingArtifactFrontmatter
{
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, artifacts.AssistantContextPath, "Meeting Note"),
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, artifacts.AssistantContextPath, "Transcript"),
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, artifacts.AssistantContextPath, "Summary"),
State = ToYamlState(state),
Agenda = agenda ?? "",
ScheduledEnd = scheduledEnd
};
return MeetingArtifactFrontmatterRenderer.Render(
frontmatter,
body);
}
private static AssistantContextFrontmatterYaml ReadFrontmatter(string frontmatter)
{
if (string.IsNullOrWhiteSpace(frontmatter))
{
return new AssistantContextFrontmatterYaml();
}
return YamlDeserializer.Deserialize<AssistantContextFrontmatterYaml>(frontmatter)
?? new AssistantContextFrontmatterYaml();
}
private static DateTimeOffset? ParseDateTime(string? value)
{
return DateTimeOffset.TryParse(value, out var parsed)
? parsed
: null;
}
private sealed class AssistantContextFrontmatterYaml
{
[YamlMember(Alias = "agenda")]
public string? Agenda { get; set; }
[YamlMember(Alias = "scheduled_end")]
public string? ScheduledEnd { get; set; }
}
private static string ToYamlState(AssistantContextState state)
{
return state switch
{
AssistantContextState.Transcribing => "transcribing",
AssistantContextState.SpeakerRecognition => "speaker recognition",
AssistantContextState.Summarizing => "summarizing",
AssistantContextState.Finished => "finished",
AssistantContextState.Error => "error",
_ => "error"
};
}
}
@@ -0,0 +1,176 @@
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; }
}
}
@@ -0,0 +1,148 @@
using System.Text;
namespace MeetingAssistant.MeetingNotes;
public sealed class MeetingArtifactFrontmatter
{
public string Title { get; set; } = "";
public DateTimeOffset? StartTime { get; set; }
public DateTimeOffset? EndTime { get; set; }
public string Meeting { get; set; } = "";
public string Transcript { get; set; } = "";
public string AssistantContext { get; set; } = "";
public string Summary { get; set; } = "";
public string? State { get; set; }
public string? Agenda { get; set; }
public DateTimeOffset? ScheduledEnd { get; set; }
}
public static class MeetingArtifactFrontmatterRenderer
{
public static string Render(
MeetingArtifactFrontmatter frontmatter,
string body)
{
var builder = new StringBuilder();
builder.AppendLine("---");
AppendScalar(builder, "title", frontmatter.Title);
AppendDateTime(builder, "start_time", frontmatter.StartTime);
AppendDateTime(builder, "end_time", frontmatter.EndTime);
AppendQuoted(builder, "meeting", frontmatter.Meeting);
AppendQuoted(builder, "transcript", frontmatter.Transcript);
AppendQuoted(builder, "assistant_context", frontmatter.AssistantContext);
AppendQuoted(builder, "summary", frontmatter.Summary);
if (!string.IsNullOrWhiteSpace(frontmatter.State))
{
AppendScalar(builder, "state", frontmatter.State);
}
if (frontmatter.Agenda is not null)
{
AppendBlockScalar(builder, "agenda", frontmatter.Agenda);
}
if (frontmatter.ScheduledEnd is not null)
{
AppendDateTime(builder, "scheduled_end", frontmatter.ScheduledEnd);
}
builder.AppendLine("---");
builder.AppendLine();
builder.Append(body.TrimStart('\r', '\n'));
return builder.ToString();
}
public static (string Frontmatter, string Body) Split(string content)
{
var document = MarkdownDocumentParser.SplitOptional(content);
return (document.Frontmatter, document.Body);
}
public static MeetingArtifactFrontmatter Create(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string title,
string sourceNotePath,
string? state = null)
{
return new MeetingArtifactFrontmatter
{
Title = title,
StartTime = meetingNote.Frontmatter.StartTime,
EndTime = meetingNote.Frontmatter.EndTime,
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, sourceNotePath, "Meeting Note"),
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, sourceNotePath, "Transcript"),
AssistantContext = ObsidianLink.ToWikiLink(artifacts.AssistantContextPath, sourceNotePath, "Assistant Context"),
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, sourceNotePath, "Summary"),
State = state
};
}
public static string DefaultTitle(MeetingNote meetingNote, string fallback)
{
return string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
? fallback
: meetingNote.Frontmatter.Title;
}
private static void AppendScalar(StringBuilder builder, string key, string? value)
{
builder.Append(key);
builder.Append(": ");
builder.AppendLine(string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value));
}
private static void AppendQuoted(StringBuilder builder, string key, string value)
{
builder.Append(key);
builder.Append(": ");
builder.AppendLine(EscapeQuoted(value));
}
private static void AppendDateTime(StringBuilder builder, string key, DateTimeOffset? value)
{
builder.Append(key);
builder.Append(": ");
builder.AppendLine(value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O")));
}
private static void AppendBlockScalar(StringBuilder builder, string key, string value)
{
if (string.IsNullOrEmpty(value))
{
builder.Append(key);
builder.AppendLine(": \"\"");
return;
}
builder.Append(key);
builder.AppendLine(": |-");
foreach (var line in value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'))
{
builder.Append(" ");
builder.AppendLine(line);
}
}
private static string EscapeQuoted(string value)
{
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
}
private static string EscapeListItem(string value)
{
return value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal)
? EscapeQuoted(value)
: value;
}
}
@@ -0,0 +1,25 @@
namespace MeetingAssistant.MeetingNotes;
public sealed record MeetingNote(
string Path,
MeetingNoteFrontmatter Frontmatter,
string UserNotes);
public sealed class MeetingNoteFrontmatter
{
public string Title { get; set; } = "";
public DateTimeOffset? StartTime { get; set; }
public DateTimeOffset? EndTime { get; set; }
public List<string> Attendees { get; set; } = [];
public List<string> Projects { get; set; } = [];
public string Transcript { get; set; } = "";
public string AssistantContext { get; set; } = "";
public string Summary { get; set; } = "";
}
@@ -0,0 +1,17 @@
namespace MeetingAssistant.MeetingNotes;
public static class MeetingNoteActionLinks
{
public static string CreateSummaryRetryLink(
string publicBaseUrl,
string summaryPath,
string label = "Retry summary generation")
{
var baseUrl = string.IsNullOrWhiteSpace(publicBaseUrl)
? "http://localhost:5090"
: publicBaseUrl.TrimEnd('/');
var summaryFileName = Path.GetFileName(summaryPath);
var url = $"{baseUrl}/meetings/summary/retry?summaryPath={Uri.EscapeDataString(summaryFileName)}";
return $"[{label}]({url})";
}
}
@@ -0,0 +1,31 @@
namespace MeetingAssistant.MeetingNotes;
public static class MeetingNoteTemplate
{
public static MeetingNote Create(
string title,
DateTimeOffset? startTime = null,
DateTimeOffset? endTime = null,
IEnumerable<string>? attendees = null,
IEnumerable<string>? projects = null,
string transcriptPath = "",
string assistantContextPath = "",
string summaryPath = "",
string userNotes = "")
{
var notePath = "";
var frontmatter = new MeetingNoteFrontmatter
{
Title = title,
StartTime = startTime,
EndTime = endTime,
Attendees = attendees?.Where(value => !string.IsNullOrWhiteSpace(value)).ToList() ?? [],
Projects = projects?.Where(value => !string.IsNullOrWhiteSpace(value)).ToList() ?? [],
Transcript = transcriptPath,
AssistantContext = assistantContextPath,
Summary = summaryPath
};
return new MeetingNote(notePath, frontmatter, userNotes);
}
}
@@ -0,0 +1,7 @@
namespace MeetingAssistant.MeetingNotes;
public sealed record MeetingSessionArtifacts(
string MeetingNotePath,
string TranscriptPath,
string AssistantContextPath,
string SummaryPath);
@@ -0,0 +1,22 @@
namespace MeetingAssistant.MeetingNotes;
public static class ObsidianLink
{
public static string ToWikiLink(string pathOrLink, string sourceNotePath, string label)
{
if (string.IsNullOrWhiteSpace(pathOrLink) || pathOrLink.StartsWith("[[", StringComparison.Ordinal))
{
return pathOrLink;
}
var sourceFolder = Path.GetDirectoryName(sourceNotePath) ?? "";
var relativePath = Path.GetRelativePath(sourceFolder, pathOrLink);
var withoutExtension = Path.Combine(
Path.GetDirectoryName(relativePath) ?? "",
Path.GetFileNameWithoutExtension(relativePath))
.Replace(Path.DirectorySeparatorChar, '/')
.Replace(Path.AltDirectorySeparatorChar, '/');
return $"[[{withoutExtension}|{label}]]";
}
}
@@ -0,0 +1,26 @@
using System.Diagnostics;
namespace MeetingAssistant.MeetingNotes;
public sealed class ObsidianMeetingNoteOpener : IMeetingNoteOpener
{
private readonly ILogger<ObsidianMeetingNoteOpener> logger;
public ObsidianMeetingNoteOpener(ILogger<ObsidianMeetingNoteOpener> logger)
{
this.logger = logger;
}
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
{
var uri = $"obsidian://open?path={Uri.EscapeDataString(Path.GetFullPath(notePath))}";
Process.Start(new ProcessStartInfo
{
FileName = uri,
UseShellExecute = true
});
logger.LogInformation("Opened meeting note in Obsidian via {ObsidianUri}", uri);
return Task.CompletedTask;
}
}
@@ -0,0 +1,289 @@
using System.Runtime.InteropServices;
namespace MeetingAssistant.MeetingNotes;
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider
{
private const int OutlookCalendarFolder = 9;
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
public OutlookClassicMeetingMetadataProvider(ILogger<OutlookClassicMeetingMetadataProvider> logger)
{
this.logger = logger;
}
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return Task.FromResult(GetCurrentMeeting(startedAt));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogDebug(exception, "Outlook Classic meeting metadata was not available");
return Task.FromResult<MeetingMetadata?>(null);
}
}
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
{
object? application = null;
object? session = null;
object? calendar = null;
object? items = null;
object? restrictedItems = null;
try
{
var applicationType = Type.GetTypeFromProgID("Outlook.Application");
if (applicationType is null)
{
return null;
}
application = Activator.CreateInstance(applicationType);
if (application is null)
{
return null;
}
session = GetProperty(application, "Session");
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
items = GetProperty(calendar!, "Items");
SetProperty(items!, "IncludeRecurrences", true);
Invoke(items!, "Sort", "[Start]");
var startedLocal = startedAt.LocalDateTime;
var windowStart = startedLocal.AddMinutes(-1);
var windowEnd = startedLocal.AddMinutes(5);
var filter =
$"[Start] <= '{windowEnd:g}' AND [End] >= '{windowStart:g}'";
restrictedItems = Invoke(items!, "Restrict", filter);
var candidates = new List<OutlookAppointmentCandidate>();
var count = Convert.ToInt32(GetProperty(restrictedItems!, "Count"));
for (var index = 1; index <= count; index++)
{
var appointment = Invoke(restrictedItems!, "Item", index);
if (appointment is not null && IsTeamsAppointment(appointment))
{
candidates.Add(new OutlookAppointmentCandidate(
appointment,
Convert.ToDateTime(GetProperty(appointment, "Start")),
Convert.ToDateTime(GetProperty(appointment, "End"))));
}
else
{
ReleaseComObject(appointment);
}
}
var selected = OutlookMeetingCandidateSelector.Select(
candidates,
startedLocal,
candidate => candidate.Start,
candidate => candidate.End);
if (selected is null)
{
foreach (var candidate in candidates)
{
ReleaseComObject(candidate.Appointment);
}
return null;
}
try
{
var appointment = selected.Appointment;
var title = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
var attendees = ReadAttendees(appointment);
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
return new MeetingMetadata(
title.Trim(),
attendees,
ExtractAgenda(body),
ToLocalOffset(selected.End));
}
finally
{
foreach (var candidate in candidates)
{
ReleaseComObject(candidate.Appointment);
}
}
}
finally
{
ReleaseComObject(restrictedItems);
ReleaseComObject(items);
ReleaseComObject(calendar);
ReleaseComObject(session);
ReleaseComObject(application);
}
}
internal static string ExtractAgenda(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return "";
}
var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
var agendaLines = new List<string>();
foreach (var line in lines)
{
if (IsTeamsSeparator(line) || IsTeamsJoinLine(line))
{
break;
}
agendaLines.Add(line);
}
return string.Join(Environment.NewLine, agendaLines).Trim();
}
private static bool IsTeamsAppointment(object appointment)
{
var subject = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
var location = Convert.ToString(GetProperty(appointment, "Location")) ?? "";
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
return ContainsTeamsMarker(subject) ||
ContainsTeamsMarker(location) ||
ContainsTeamsMarker(body);
}
private static bool ContainsTeamsMarker(string value)
{
return value.Contains("teams.microsoft.com", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Microsoft Teams", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join the meeting now", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join Microsoft Teams Meeting", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTeamsSeparator(string line)
{
var trimmed = line.Trim();
return trimmed.Length >= 8 &&
trimmed.All(character => character is '_' or '-' or '*' or ' ');
}
private static bool IsTeamsJoinLine(string line)
{
return ContainsTeamsMarker(line);
}
private static IReadOnlyList<string> ReadAttendees(object appointment)
{
var attendees = new List<string>();
var organizer = Convert.ToString(GetProperty(appointment, "Organizer"));
if (!string.IsNullOrWhiteSpace(organizer))
{
attendees.Add(organizer.Trim());
}
object? recipients = null;
try
{
recipients = GetProperty(appointment, "Recipients");
var count = Convert.ToInt32(GetProperty(recipients!, "Count"));
for (var index = 1; index <= count; index++)
{
object? recipient = null;
try
{
recipient = Invoke(recipients!, "Item", index);
var formatted = FormatRecipient(recipient!);
if (!string.IsNullOrWhiteSpace(formatted))
{
attendees.Add(formatted);
}
}
finally
{
ReleaseComObject(recipient);
}
}
}
finally
{
ReleaseComObject(recipients);
}
return attendees
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static DateTimeOffset ToLocalOffset(DateTime value)
{
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
}
private static string FormatRecipient(object recipient)
{
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";
var email = Convert.ToString(GetProperty(recipient, "Address"))?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(name))
{
return email;
}
if (string.IsNullOrWhiteSpace(email) ||
email.Contains('/'))
{
return name;
}
return $"{name} <{email}>";
}
private static object? GetProperty(object target, string name)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.GetProperty,
null,
target,
null);
}
private static void SetProperty(object target, string name, object value)
{
target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.SetProperty,
null,
target,
[value]);
}
private static object? Invoke(object target, string name, params object[] arguments)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
arguments);
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
private sealed record OutlookAppointmentCandidate(
object Appointment,
DateTime Start,
DateTime End);
}
@@ -0,0 +1,38 @@
namespace MeetingAssistant.MeetingNotes;
internal static class OutlookMeetingCandidateSelector
{
private static readonly TimeSpan MinimumRemainingOverlap = TimeSpan.FromMinutes(5);
private static readonly TimeSpan UpcomingStartWindow = TimeSpan.FromMinutes(5);
public static T? Select<T>(
IReadOnlyList<T> candidates,
DateTime startedAt,
Func<T, DateTime> getStart,
Func<T, DateTime> getEnd)
where T : class
{
var goodOverlaps = candidates
.Where(candidate =>
getStart(candidate) <= startedAt &&
getEnd(candidate) >= startedAt.Add(MinimumRemainingOverlap))
.ToList();
if (goodOverlaps.Count == 1)
{
return goodOverlaps[0];
}
if (goodOverlaps.Count > 1)
{
return null;
}
var upcoming = candidates
.Where(candidate =>
getStart(candidate) > startedAt &&
getStart(candidate) <= startedAt.Add(UpcomingStartWindow))
.OrderBy(getStart)
.ToList();
return upcoming.Count == 1 ? upcoming[0] : null;
}
}