using System.Text.RegularExpressions; using MeetingAssistant.MeetingNotes; namespace MeetingAssistant.Recording; public interface IMeetingRunArtifactCleaner { Task DeleteRunArtifactsAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken); } public sealed class MeetingRunArtifactCleaner : IMeetingRunArtifactCleaner { private static readonly Regex MarkdownImageLinkPattern = new("!\\[[^\\]]*\\]\\((?[^)]+)\\)", RegexOptions.Compiled); private static readonly Regex GeneratedScreenshotFileNamePattern = new( @"^\d{8}-\d{6}-\d{3}-\d{6}(?:-cropped)?\.png$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private readonly ILogger? logger; public MeetingRunArtifactCleaner(ILogger? logger = null) { this.logger = logger; } public async Task DeleteRunArtifactsAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { var linkedFiles = await GetLinkedScreenshotAttachmentFilesAsync( artifacts.AssistantContextPath, options.Screenshots.AttachmentsFolder, cancellationToken); foreach (var linkedFile in linkedFiles) { await DeleteFileIfExistsAsync(linkedFile, cancellationToken); } await DeleteFileIfExistsAsync(artifacts.SummaryPath, cancellationToken); await DeleteFileIfExistsAsync(artifacts.AssistantContextPath, cancellationToken); await DeleteFileIfExistsAsync(artifacts.TranscriptPath, cancellationToken); await DeleteFileIfExistsAsync(artifacts.MeetingNotePath, cancellationToken); } private static async Task> GetLinkedScreenshotAttachmentFilesAsync( string assistantContextPath, string configuredAttachmentsFolder, CancellationToken cancellationToken) { if (!File.Exists(assistantContextPath)) { return []; } var contextDirectory = Path.GetDirectoryName(Path.GetFullPath(assistantContextPath)); if (string.IsNullOrWhiteSpace(contextDirectory)) { return []; } var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken); var attachmentsFolder = ResolveAttachmentsFolder(contextDirectory, configuredAttachmentsFolder); return MarkdownImageLinkPattern .Matches(content) .Select(match => match.Groups["path"].Value.Trim()) .Where(path => !string.IsNullOrWhiteSpace(path)) .Select(path => ResolveScreenshotAttachmentLink(contextDirectory, attachmentsFolder, path)) .Where(path => path is not null) .Select(path => path!) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } private static string? ResolveScreenshotAttachmentLink( string contextDirectory, string attachmentsFolder, string linkPath) { if (Uri.TryCreate(linkPath, UriKind.Absolute, out var uri) && !uri.IsFile) { return null; } var withoutFragment = linkPath.Split('#')[0].Split('?')[0]; var decoded = Uri.UnescapeDataString(withoutFragment); var fullPath = Path.IsPathRooted(decoded) ? Path.GetFullPath(decoded) : Path.GetFullPath(Path.Combine(contextDirectory, decoded)); var attachmentsRoot = Path.GetFullPath(attachmentsFolder).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var fileName = Path.GetFileName(fullPath); return fullPath.StartsWith(attachmentsRoot, StringComparison.OrdinalIgnoreCase) && GeneratedScreenshotFileNamePattern.IsMatch(fileName) ? fullPath : null; } private static string ResolveAttachmentsFolder(string contextDirectory, string configuredFolder) { var expanded = Environment.ExpandEnvironmentVariables( string.IsNullOrWhiteSpace(configuredFolder) ? "Attachments" : configuredFolder); return Path.IsPathRooted(expanded) ? Path.GetFullPath(expanded) : Path.GetFullPath(Path.Combine(contextDirectory, expanded)); } private async Task DeleteFileIfExistsAsync(string path, CancellationToken cancellationToken) { var deadline = DateTimeOffset.UtcNow.AddSeconds(2); while (true) { try { if (File.Exists(path)) { File.Delete(path); logger?.LogInformation("Deleted aborted meeting artifact {ArtifactPath}", path); } return; } catch (IOException) when (DateTimeOffset.UtcNow < deadline) { await Task.Delay(25, cancellationToken); } catch (UnauthorizedAccessException) when (DateTimeOffset.UtcNow < deadline) { await Task.Delay(25, cancellationToken); } catch (Exception exception) { logger?.LogWarning(exception, "Could not delete aborted meeting artifact {ArtifactPath}", path); return; } } } }