Public Access
220 lines
8.1 KiB
C#
220 lines
8.1 KiB
C#
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<VaultTranscriptStore> logger;
|
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> fileGates = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public VaultTranscriptStore(IOptions<MeetingAssistantOptions> options, ILogger<VaultTranscriptStore> logger)
|
|
{
|
|
this.options = options.Value;
|
|
this.logger = logger;
|
|
}
|
|
|
|
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
|
{
|
|
return await CreateSessionAsync(options, DateTimeOffset.Now, cancellationToken);
|
|
}
|
|
|
|
public async Task<TranscriptSession> 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<TranscriptLineReference> 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<string> 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<TranscriptLineReference> 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<Task> action,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await WithFileGateAsync(
|
|
path,
|
|
async () =>
|
|
{
|
|
await action();
|
|
return true;
|
|
},
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task<T> WithFileGateAsync<T>(
|
|
string path,
|
|
Func<Task<T>> 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<string> SplitLines(string text)
|
|
{
|
|
return text
|
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
|
.Split('\n')
|
|
.ToList();
|
|
}
|
|
}
|