Public Access
Archive completed meeting assistant changes
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
This commit is contained in:
@@ -8,4 +8,13 @@ internal static class MeetingAttendeeNames
|
||||
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
||||
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
||||
}
|
||||
|
||||
public static List<string> NormalizeDistinct(IEnumerable<string> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(attendee => attendee.Trim())
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,24 @@ public static class MeetingNoteActionLinks
|
||||
var url = $"{baseUrl}/meetings/summary/retry?summaryPath={Uri.EscapeDataString(summaryFileName)}";
|
||||
return $"[{label}]({url})";
|
||||
}
|
||||
|
||||
public static string CreateScreenshotOcrRetryLink(
|
||||
string publicBaseUrl,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
string label = "Retry screenshot OCR")
|
||||
{
|
||||
var baseUrl = string.IsNullOrWhiteSpace(publicBaseUrl)
|
||||
? "http://localhost:5090"
|
||||
: publicBaseUrl.TrimEnd('/');
|
||||
var url = $"{baseUrl}/meetings/screenshot-ocr/retry" +
|
||||
$"?meetingNotePath={Uri.EscapeDataString(artifacts.MeetingNotePath)}" +
|
||||
$"&transcriptPath={Uri.EscapeDataString(artifacts.TranscriptPath)}" +
|
||||
$"&assistantContextPath={Uri.EscapeDataString(artifacts.AssistantContextPath)}" +
|
||||
$"&summaryPath={Uri.EscapeDataString(artifacts.SummaryPath)}" +
|
||||
$"&screenshotPath={Uri.EscapeDataString(screenshotPath)}" +
|
||||
$"&screenshotId={Uri.EscapeDataString(screenshotId)}";
|
||||
return $"[{label}]({url})";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#if WINDOWS
|
||||
using MeetingAssistant.Calendar;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
internal static class OutlookClassicAppointmentMetadata
|
||||
{
|
||||
public static MeetingMetadata CreateMetadata(object appointment, DateTime end)
|
||||
{
|
||||
var title = ReadSubject(appointment);
|
||||
return new MeetingMetadata(
|
||||
title,
|
||||
ReadAttendees(appointment),
|
||||
ExtractAgenda(Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? ""),
|
||||
OutlookClassicCom.ToLocalOffset(end));
|
||||
}
|
||||
|
||||
public static string ReadSubject(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject"))?.Trim();
|
||||
return string.IsNullOrWhiteSpace(subject) ? "Teams meeting" : subject;
|
||||
}
|
||||
|
||||
public static bool IsTeamsAppointment(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
|
||||
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
|
||||
}
|
||||
|
||||
public 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();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> ReadAttendees(object appointment)
|
||||
{
|
||||
var attendees = new List<string>();
|
||||
var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer"));
|
||||
if (!string.IsNullOrWhiteSpace(organizer))
|
||||
{
|
||||
attendees.Add(organizer.Trim());
|
||||
}
|
||||
|
||||
object? recipients = null;
|
||||
try
|
||||
{
|
||||
recipients = OutlookClassicCom.GetProperty(appointment, "Recipients");
|
||||
var count = Convert.ToInt32(OutlookClassicCom.GetProperty(recipients!, "Count"));
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
object? recipient = null;
|
||||
try
|
||||
{
|
||||
recipient = OutlookClassicCom.Invoke(recipients!, "Item", index);
|
||||
var formatted = FormatRecipient(recipient!);
|
||||
if (!string.IsNullOrWhiteSpace(formatted))
|
||||
{
|
||||
attendees.Add(formatted);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipients);
|
||||
}
|
||||
|
||||
return NormalizeAttendees(attendees);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(NormalizeAttendee)
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group
|
||||
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
|
||||
.ThenBy(attendee => attendee.Length)
|
||||
.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
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 TeamsMeetingMarkerDetector.ContainsTeamsMarker(line);
|
||||
}
|
||||
|
||||
private static string NormalizeAttendee(string attendee)
|
||||
{
|
||||
var normalized = attendee.Trim();
|
||||
var mailtoPrefix = "mailto:";
|
||||
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
|
||||
while (normalized.Contains(" ", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = normalized.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
var name = normalized[..emailStart].Trim();
|
||||
var email = normalized[(emailStart + 1)..emailEnd].Trim();
|
||||
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string GetAttendeeDeduplicationKey(string attendee)
|
||||
{
|
||||
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = attendee.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
return attendee[(emailStart + 1)..emailEnd].Trim();
|
||||
}
|
||||
|
||||
return attendee.Trim();
|
||||
}
|
||||
|
||||
private static string FormatRecipient(object recipient)
|
||||
{
|
||||
var name = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? "";
|
||||
var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) ||
|
||||
email.Contains('/'))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -108,6 +108,37 @@ internal static class OutlookClassicCom
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsCanceledAppointment(object appointment)
|
||||
{
|
||||
var meetingStatus = TryGetProperty(appointment, "MeetingStatus");
|
||||
if (meetingStatus is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = Convert.ToInt32(meetingStatus);
|
||||
if (status is 5 or 7)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
// Fall back to subject prefixes when Outlook exposes a non-numeric status value.
|
||||
}
|
||||
}
|
||||
|
||||
var subject = Convert.ToString(TryGetProperty(appointment, "Subject")) ?? "";
|
||||
return SubjectIndicatesCancellation(subject);
|
||||
}
|
||||
|
||||
internal static bool SubjectIndicatesCancellation(string subject)
|
||||
{
|
||||
var normalized = subject.TrimStart();
|
||||
return normalized.StartsWith("Canceled:", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("Cancelled:", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("Abgesagt:", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static DateTimeOffset ToLocalOffset(DateTime value)
|
||||
{
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
|
||||
|
||||
@@ -121,7 +121,9 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
}
|
||||
|
||||
var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End"));
|
||||
if (end < windowStart || !IsTeamsAppointment(item))
|
||||
if (end < windowStart ||
|
||||
OutlookClassicCom.IsCanceledAppointment(item) ||
|
||||
!OutlookClassicAppointmentMetadata.IsTeamsAppointment(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -150,15 +152,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
return null;
|
||||
}
|
||||
|
||||
var appointment = selected.Appointment;
|
||||
var title = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||
var attendees = ReadAttendees(appointment);
|
||||
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||
return new MeetingMetadata(
|
||||
title.Trim(),
|
||||
attendees,
|
||||
ExtractAgenda(body),
|
||||
OutlookClassicCom.ToLocalOffset(selected.End));
|
||||
return OutlookClassicAppointmentMetadata.CreateMetadata(selected.Appointment, selected.End);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -169,158 +163,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
}
|
||||
}
|
||||
|
||||
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 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 TeamsMeetingMarkerDetector.ContainsTeamsMarker(line);
|
||||
}
|
||||
|
||||
private static bool IsTeamsAppointment(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
|
||||
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadAttendees(object appointment)
|
||||
{
|
||||
var attendees = new List<string>();
|
||||
var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer"));
|
||||
if (!string.IsNullOrWhiteSpace(organizer))
|
||||
{
|
||||
attendees.Add(organizer.Trim());
|
||||
}
|
||||
|
||||
object? recipients = null;
|
||||
try
|
||||
{
|
||||
recipients = OutlookClassicCom.GetProperty(appointment, "Recipients");
|
||||
var count = Convert.ToInt32(OutlookClassicCom.GetProperty(recipients!, "Count"));
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
object? recipient = null;
|
||||
try
|
||||
{
|
||||
recipient = OutlookClassicCom.Invoke(recipients!, "Item", index);
|
||||
var formatted = FormatRecipient(recipient!);
|
||||
if (!string.IsNullOrWhiteSpace(formatted))
|
||||
{
|
||||
attendees.Add(formatted);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipients);
|
||||
}
|
||||
|
||||
return NormalizeAttendees(attendees);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(NormalizeAttendee)
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group
|
||||
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
|
||||
.ThenBy(attendee => attendee.Length)
|
||||
.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeAttendee(string attendee)
|
||||
{
|
||||
var normalized = attendee.Trim();
|
||||
var mailtoPrefix = "mailto:";
|
||||
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
|
||||
while (normalized.Contains(" ", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = normalized.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
var name = normalized[..emailStart].Trim();
|
||||
var email = normalized[(emailStart + 1)..emailEnd].Trim();
|
||||
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string GetAttendeeDeduplicationKey(string attendee)
|
||||
{
|
||||
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = attendee.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
return attendee[(emailStart + 1)..emailEnd].Trim();
|
||||
}
|
||||
|
||||
return attendee.Trim();
|
||||
}
|
||||
|
||||
private static string FormatRecipient(object recipient)
|
||||
{
|
||||
var name = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? "";
|
||||
var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) ||
|
||||
email.Contains('/'))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
private sealed record OutlookAppointmentCandidate(
|
||||
object Appointment,
|
||||
DateTime Start,
|
||||
|
||||
Reference in New Issue
Block a user