using MeetingAssistant.Transcription; using MeetingAssistant.MeetingNotes; using Microsoft.Extensions.Options; using System.Collections.Concurrent; namespace MeetingAssistant.Recording; public sealed class VaultTranscriptStore : ITranscriptStore { private readonly MeetingAssistantOptions options; private readonly ILogger logger; private readonly ConcurrentDictionary fileGates = new(StringComparer.OrdinalIgnoreCase); public VaultTranscriptStore(IOptions options, ILogger logger) { this.options = options.Value; this.logger = logger; } public async Task CreateSessionAsync(CancellationToken cancellationToken) { return await CreateSessionAsync(options, DateTimeOffset.Now, cancellationToken); } public async Task CreateSessionAsync( MeetingAssistantOptions options, DateTimeOffset startedAt, CancellationToken cancellationToken) { var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder); Directory.CreateDirectory(folder); var fileName = MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript); var path = Path.Combine(folder, fileName); await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken); logger.LogInformation("Created meeting transcript file {TranscriptPath}", path); return new TranscriptSession(path); } public async Task AppendLineAsync( TranscriptSession session, string line, CancellationToken cancellationToken) { return await WithFileGateAsync( session.TranscriptPath, () => AppendToBodyAsync(session.TranscriptPath, line, cancellationToken), cancellationToken); } public async Task ReplaceLineAsync( TranscriptSession session, TranscriptLineReference lineReference, string replacementLine, CancellationToken cancellationToken) { await WithFileGateAsync( session.TranscriptPath, async () => { if (!File.Exists(session.TranscriptPath)) { return; } var content = await File.ReadAllTextAsync(session.TranscriptPath, cancellationToken); var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(content); var lines = SplitLines(body); if (lineReference.BodyLineIndex < 0 || lineReference.BodyLineIndex >= lines.Count || !string.Equals(lines[lineReference.BodyLineIndex], lineReference.OriginalLine, StringComparison.Ordinal)) { logger.LogWarning( "Could not replace transcript line in {TranscriptPath} at body line {BodyLineIndex} because the original line no longer matched", session.TranscriptPath, lineReference.BodyLineIndex); return; } lines[lineReference.BodyLineIndex] = replacementLine; await WriteBodyAsync(session.TranscriptPath, frontmatter, string.Join(Environment.NewLine, lines), cancellationToken); }, cancellationToken); } public Task ReplaceLinesAsync( TranscriptSession session, IReadOnlyList replacementLines, CancellationToken cancellationToken) { return WithFileGateAsync( session.TranscriptPath, async () => { var existing = File.Exists(session.TranscriptPath) ? File.ReadAllText(session.TranscriptPath) : ""; var (frontmatter, _) = MeetingArtifactFrontmatterRenderer.Split(existing); var body = "# Meeting Transcript" + Environment.NewLine + Environment.NewLine + string.Concat(replacementLines.Select(line => line + Environment.NewLine)); await WriteBodyAsync(session.TranscriptPath, frontmatter, body, cancellationToken); }, cancellationToken); } public async Task UpdateMetadataAsync( TranscriptSession session, MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { await WithFileGateAsync( session.TranscriptPath, async () => { var body = File.Exists(session.TranscriptPath) ? MeetingArtifactFrontmatterRenderer.Split(await File.ReadAllTextAsync(session.TranscriptPath, cancellationToken)).Body : "# Meeting Transcript" + Environment.NewLine + Environment.NewLine; var frontmatter = MeetingArtifactFrontmatterRenderer.Create( artifacts, meetingNote, MeetingArtifactFrontmatterRenderer.DefaultTitle(meetingNote, "Meeting Transcript"), session.TranscriptPath); await File.WriteAllTextAsync( session.TranscriptPath, MeetingArtifactFrontmatterRenderer.Render(frontmatter, body), cancellationToken); }, cancellationToken); } private static async Task AppendToBodyAsync( string path, string line, CancellationToken cancellationToken) { if (!File.Exists(path)) { await File.AppendAllTextAsync(path, line + Environment.NewLine, cancellationToken); return new TranscriptLineReference(path, 0, line); } var content = await File.ReadAllTextAsync(path, cancellationToken); var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(content); var bodyLineIndex = GetAppendBodyLineIndex(body); if (string.IsNullOrWhiteSpace(frontmatter)) { await File.AppendAllTextAsync(path, line + Environment.NewLine, cancellationToken); return new TranscriptLineReference(path, bodyLineIndex, line); } await WriteBodyAsync(path, frontmatter, body + line + Environment.NewLine, cancellationToken); return new TranscriptLineReference(path, bodyLineIndex, line); } private async Task WithFileGateAsync( string path, Func action, CancellationToken cancellationToken) { await WithFileGateAsync( path, async () => { await action(); return true; }, cancellationToken); } private async Task WithFileGateAsync( string path, Func> action, CancellationToken cancellationToken) { var gate = fileGates.GetOrAdd(path, static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken); try { return await action(); } finally { gate.Release(); } } private static async Task WriteBodyAsync( string path, string frontmatter, string body, CancellationToken cancellationToken) { var content = string.IsNullOrWhiteSpace(frontmatter) ? body : "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + body; await File.WriteAllTextAsync(path, content, cancellationToken); } private static int GetAppendBodyLineIndex(string body) { var lines = SplitLines(body); return lines.Count > 0 && lines[^1].Length == 0 ? lines.Count - 1 : lines.Count; } private static List SplitLines(string text) { return text .Replace("\r\n", "\n", StringComparison.Ordinal) .Split('\n') .ToList(); } }