Public Access
664 lines
22 KiB
C#
664 lines
22 KiB
C#
using MeetingAssistant.MeetingNotes;
|
|
using MeetingAssistant.Transcription;
|
|
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using YamlDotNet.Serialization;
|
|
|
|
namespace MeetingAssistant.Summary;
|
|
|
|
public sealed class MeetingSummaryTools
|
|
{
|
|
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
|
.IgnoreUnmatchedProperties()
|
|
.Build();
|
|
|
|
private readonly MeetingSessionArtifacts artifacts;
|
|
private readonly MeetingAssistantOptions options;
|
|
private readonly IDictationWordStore? dictationWordStore;
|
|
|
|
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
|
: this(artifacts, new MeetingAssistantOptions(), null)
|
|
{
|
|
}
|
|
|
|
public MeetingSummaryTools(
|
|
MeetingSessionArtifacts artifacts,
|
|
MeetingAssistantOptions options,
|
|
IDictationWordStore? dictationWordStore = null)
|
|
{
|
|
this.artifacts = artifacts;
|
|
this.options = options;
|
|
this.dictationWordStore = dictationWordStore;
|
|
}
|
|
|
|
public Task<string> ReadTranscript(int? @from = null, int? to = null)
|
|
{
|
|
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to);
|
|
}
|
|
|
|
public Task<string> ReadMeetingNote(int? @from = null, int? to = null)
|
|
{
|
|
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to);
|
|
}
|
|
|
|
public Task<string> ReadContext(int? @from = null, int? to = null)
|
|
{
|
|
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to);
|
|
}
|
|
|
|
public async Task<string> ReadUserNotes(int? @from = null, int? to = null)
|
|
{
|
|
if (!File.Exists(artifacts.MeetingNotePath))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
var body = document.HasFrontmatter ? document.Body.Trim() : content;
|
|
return ReadLines(body, @from, to);
|
|
}
|
|
|
|
public Task<string> ReadGlossary(int? @from = null, int? to = null)
|
|
{
|
|
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to);
|
|
}
|
|
|
|
public async Task<string> AddDictationWord(string word)
|
|
{
|
|
if (dictationWordStore is null)
|
|
{
|
|
return "Dictation word store is not available.";
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(word))
|
|
{
|
|
return "Refused: word must not be empty.";
|
|
}
|
|
|
|
var words = await dictationWordStore.AddWordAsync(word, CancellationToken.None);
|
|
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
|
|
}
|
|
|
|
public async Task<string> WriteSummary(string markdown, string? title = null)
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
|
var meetingNote = await ReadMeetingNoteAsync();
|
|
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
|
? title
|
|
: meetingNote.Frontmatter.Title;
|
|
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
|
|
artifacts,
|
|
meetingNote,
|
|
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
|
|
artifacts.SummaryPath);
|
|
await File.WriteAllTextAsync(
|
|
artifacts.SummaryPath,
|
|
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
|
|
return artifacts.SummaryPath;
|
|
}
|
|
|
|
public async Task<string> WriteContext(
|
|
string content,
|
|
int? @from = null,
|
|
int? to = null,
|
|
int? insert = null)
|
|
{
|
|
var editMode = FileLineEditMode.Create(@from, to, insert);
|
|
if (editMode is null)
|
|
{
|
|
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
|
var existing = File.Exists(artifacts.AssistantContextPath)
|
|
? await File.ReadAllTextAsync(artifacts.AssistantContextPath)
|
|
: "";
|
|
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
|
|
var updatedBody = ApplyLineEdit(body, content, editMode);
|
|
var updated = string.IsNullOrWhiteSpace(frontmatter)
|
|
? updatedBody
|
|
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
|
|
|
|
await File.WriteAllTextAsync(artifacts.AssistantContextPath, updated);
|
|
return artifacts.AssistantContextPath;
|
|
}
|
|
|
|
public async Task<string> ListProjects()
|
|
{
|
|
var projects = await GetBoundProjectsAsync();
|
|
return string.Join('\n', projects.Select(project => project.Name));
|
|
}
|
|
|
|
public async Task<string> ListProjectFiles(string project)
|
|
{
|
|
var projectRoot = await ResolveBoundProjectRootAsync(project);
|
|
if (projectRoot is null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
|
|
.Select(path => ToToolPath(Path.GetRelativePath(projectRoot, path)))
|
|
.Order(StringComparer.Ordinal);
|
|
return string.Join('\n', files);
|
|
}
|
|
|
|
public async Task<string> ReadProjectFile(string project, string path, int? @from = null, int? to = null)
|
|
{
|
|
var filePath = await ResolveBoundProjectFilePathAsync(project, path);
|
|
if (filePath is null || !File.Exists(filePath))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return ReadLines(await File.ReadAllTextAsync(filePath), @from, to);
|
|
}
|
|
|
|
public async Task<string> WriteProjectFile(
|
|
string project,
|
|
string path,
|
|
string content,
|
|
int? @from = null,
|
|
int? to = null,
|
|
int? insert = null)
|
|
{
|
|
var editMode = FileLineEditMode.Create(@from, to, insert);
|
|
if (editMode is null)
|
|
{
|
|
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
|
|
}
|
|
|
|
var target = ResolveExistingProjectFilePath(project, path);
|
|
if (target is null)
|
|
{
|
|
return "Refused: project does not exist or the path escapes the project folder.";
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
|
await WriteProjectFileContentAsync(target.Path, content, editMode);
|
|
return $"{target.Project.Name}/{ToToolPath(path)}";
|
|
}
|
|
|
|
public async Task<string> Search(string keywords, string[]? projects = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(keywords))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var selectedProjects = await GetSearchProjectsAsync(projects);
|
|
if (selectedProjects.Count == 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var projectsRoot = GetProjectsRoot();
|
|
var result = await RunRipgrepAsync(projectsRoot, selectedProjects, keywords);
|
|
return result is not null
|
|
? FormatRipgrepJson(result, selectedProjects.Count == 1)
|
|
: SearchWithRegexFallback(selectedProjects, keywords, selectedProjects.Count == 1);
|
|
}
|
|
|
|
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null)
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(path);
|
|
return ReadLines(content, from, to);
|
|
}
|
|
|
|
private async Task<MeetingNote> ReadMeetingNoteAsync()
|
|
{
|
|
if (!File.Exists(artifacts.MeetingNotePath))
|
|
{
|
|
return new MeetingNote("", new MeetingNoteFrontmatter(), "");
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
var frontmatter = new MeetingNoteFrontmatter();
|
|
if (document.HasFrontmatter)
|
|
{
|
|
var note = YamlDeserializer.Deserialize<MeetingNoteFrontmatterYaml>(document.Frontmatter) ?? new MeetingNoteFrontmatterYaml();
|
|
frontmatter.Title = note.Title ?? "";
|
|
frontmatter.StartTime = ParseDateTime(note.StartTime);
|
|
frontmatter.EndTime = ParseDateTime(note.EndTime);
|
|
frontmatter.Attendees = note.Attendees ?? [];
|
|
frontmatter.Projects = note.Projects ?? [];
|
|
frontmatter.Transcript = note.Transcript ?? "";
|
|
frontmatter.AssistantContext = note.AssistantContext ?? "";
|
|
frontmatter.Summary = note.Summary ?? "";
|
|
}
|
|
|
|
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
|
|
}
|
|
|
|
private async Task<List<ProjectFolder>> GetSearchProjectsAsync(string[]? projects)
|
|
{
|
|
var boundProjects = await GetBoundProjectsAsync();
|
|
if (projects is null || projects.Length == 0)
|
|
{
|
|
return boundProjects;
|
|
}
|
|
|
|
var requested = projects
|
|
.Where(project => !string.IsNullOrWhiteSpace(project))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
return boundProjects
|
|
.Where(project => requested.Contains(project.Name))
|
|
.ToList();
|
|
}
|
|
|
|
private async Task<ProjectFolder?> ResolveBoundProjectAsync(string project)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(project))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return (await GetBoundProjectsAsync())
|
|
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private async Task<string?> ResolveBoundProjectRootAsync(string project)
|
|
{
|
|
return (await ResolveBoundProjectAsync(project))?.Path;
|
|
}
|
|
|
|
private async Task<string?> ResolveBoundProjectFilePathAsync(string project, string path)
|
|
{
|
|
var projectRoot = await ResolveBoundProjectRootAsync(project);
|
|
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
|
|
return IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
|
|
}
|
|
|
|
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(project) ||
|
|
string.IsNullOrWhiteSpace(path) ||
|
|
Path.IsPathRooted(path))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var projectsRoot = GetProjectsRoot();
|
|
if (!Directory.Exists(projectsRoot))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
|
|
.Select(candidate => new ProjectFolder(Path.GetFileName(candidate), candidate))
|
|
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
|
if (projectFolder is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path));
|
|
return IsWithinDirectory(projectFolder.Path, fullPath)
|
|
? new ProjectFileTarget(projectFolder, fullPath)
|
|
: null;
|
|
}
|
|
|
|
private static async Task WriteProjectFileContentAsync(
|
|
string filePath,
|
|
string content,
|
|
FileLineEditMode editMode)
|
|
{
|
|
if (editMode.Kind == FileLineEditKind.Overwrite)
|
|
{
|
|
await File.WriteAllTextAsync(filePath, content);
|
|
return;
|
|
}
|
|
|
|
var lines = File.Exists(filePath)
|
|
? (await File.ReadAllLinesAsync(filePath)).ToList()
|
|
: [];
|
|
var replacementLines = SplitLines(content);
|
|
|
|
if (editMode.Kind == FileLineEditKind.Insert)
|
|
{
|
|
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
|
|
lines.InsertRange(insertIndex, replacementLines);
|
|
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
|
|
return;
|
|
}
|
|
|
|
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
|
|
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
|
|
if (endIndexExclusive < startIndex)
|
|
{
|
|
endIndexExclusive = startIndex;
|
|
}
|
|
|
|
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
|
|
lines.InsertRange(startIndex, replacementLines);
|
|
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
|
|
}
|
|
|
|
private static List<string> SplitLines(string content)
|
|
{
|
|
if (content.Length == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return content
|
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
|
.Replace('\r', '\n')
|
|
.Split('\n')
|
|
.ToList();
|
|
}
|
|
|
|
private static string ReadLines(string content, int? from = null, int? to = null)
|
|
{
|
|
if (!from.HasValue && !to.HasValue)
|
|
{
|
|
return content;
|
|
}
|
|
|
|
var lines = SplitLines(content);
|
|
if (lines.Count == 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
|
|
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
|
|
if (end < start)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return string.Join('\n', lines.GetRange(start, end - start + 1));
|
|
}
|
|
|
|
private static string ApplyLineEdit(
|
|
string existingContent,
|
|
string replacementContent,
|
|
FileLineEditMode editMode)
|
|
{
|
|
if (editMode.Kind == FileLineEditKind.Overwrite)
|
|
{
|
|
return replacementContent;
|
|
}
|
|
|
|
var lines = SplitLines(existingContent);
|
|
var replacementLines = SplitLines(replacementContent);
|
|
if (editMode.Kind == FileLineEditKind.Insert)
|
|
{
|
|
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
|
|
lines.InsertRange(insertIndex, replacementLines);
|
|
return string.Join('\n', lines);
|
|
}
|
|
|
|
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
|
|
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
|
|
if (endIndexExclusive < startIndex)
|
|
{
|
|
endIndexExclusive = startIndex;
|
|
}
|
|
|
|
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
|
|
lines.InsertRange(startIndex, replacementLines);
|
|
return string.Join('\n', lines);
|
|
}
|
|
|
|
private async Task<List<ProjectFolder>> GetBoundProjectsAsync()
|
|
{
|
|
var projectsRoot = GetProjectsRoot();
|
|
if (!Directory.Exists(projectsRoot))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var projectNames = await ReadMeetingProjectNamesAsync();
|
|
if (projectNames.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return Directory.EnumerateDirectories(projectsRoot)
|
|
.Select(path => new ProjectFolder(Path.GetFileName(path), path))
|
|
.Where(project => projectNames.Contains(project.Name))
|
|
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
private async Task<HashSet<string>> ReadMeetingProjectNamesAsync()
|
|
{
|
|
if (!File.Exists(artifacts.MeetingNotePath))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
if (!document.HasFrontmatter)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var note = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter) ?? new ProjectFrontmatter();
|
|
return (note.Projects ?? [])
|
|
.Where(project => !string.IsNullOrWhiteSpace(project))
|
|
.Select(project => project.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private string GetProjectsRoot()
|
|
{
|
|
return VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
|
}
|
|
|
|
private static async Task<string?> RunRipgrepAsync(
|
|
string projectsRoot,
|
|
IReadOnlyList<ProjectFolder> projects,
|
|
string keywords)
|
|
{
|
|
try
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "rg",
|
|
WorkingDirectory = projectsRoot,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false
|
|
};
|
|
startInfo.ArgumentList.Add("--json");
|
|
startInfo.ArgumentList.Add("--line-number");
|
|
startInfo.ArgumentList.Add("--color");
|
|
startInfo.ArgumentList.Add("never");
|
|
startInfo.ArgumentList.Add("--");
|
|
startInfo.ArgumentList.Add(keywords);
|
|
foreach (var project in projects)
|
|
{
|
|
startInfo.ArgumentList.Add(project.Name);
|
|
}
|
|
|
|
using var process = Process.Start(startInfo);
|
|
if (process is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var output = await process.StandardOutput.ReadToEndAsync();
|
|
await process.WaitForExitAsync();
|
|
return process.ExitCode is 0 or 1 ? output : null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static string FormatRipgrepJson(string output, bool singleProject)
|
|
{
|
|
var matches = new List<string>();
|
|
using var reader = new StringReader(output);
|
|
string? line;
|
|
while ((line = reader.ReadLine()) is not null)
|
|
{
|
|
using var document = JsonDocument.Parse(line);
|
|
var root = document.RootElement;
|
|
if (!root.TryGetProperty("type", out var type) ||
|
|
type.GetString() != "match" ||
|
|
!root.TryGetProperty("data", out var data))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
|
|
var lineNumber = data.GetProperty("line_number").GetInt32();
|
|
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
|
|
matches.Add($"{FormatSearchPath(path, singleProject)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
|
|
}
|
|
|
|
return string.Join('\n', matches);
|
|
}
|
|
|
|
private static string SearchWithRegexFallback(IReadOnlyList<ProjectFolder> projects, string keywords, bool singleProject)
|
|
{
|
|
try
|
|
{
|
|
var regex = new System.Text.RegularExpressions.Regex(keywords);
|
|
var matches = new List<string>();
|
|
foreach (var project in projects)
|
|
{
|
|
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
|
|
{
|
|
var lines = File.ReadAllLines(file);
|
|
for (var index = 0; index < lines.Length; index++)
|
|
{
|
|
if (regex.IsMatch(lines[index]))
|
|
{
|
|
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
|
|
matches.Add($"{FormatSearchPath(relative, singleProject)}:{index + 1} {lines[index]}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return string.Join('\n', matches);
|
|
}
|
|
catch
|
|
{
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private static string FormatSearchPath(string path, bool singleProject)
|
|
{
|
|
var formatted = ToToolPath(path);
|
|
if (!singleProject)
|
|
{
|
|
return formatted;
|
|
}
|
|
|
|
var slashIndex = formatted.IndexOf('/', StringComparison.Ordinal);
|
|
return slashIndex < 0 ? formatted : formatted[(slashIndex + 1)..];
|
|
}
|
|
|
|
private static bool IsWithinDirectory(string directory, string path)
|
|
{
|
|
var normalizedDirectory = Path.GetFullPath(directory)
|
|
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
var normalizedPath = Path.GetFullPath(path);
|
|
return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string ToToolPath(string path)
|
|
{
|
|
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
|
|
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
|
}
|
|
|
|
private sealed record ProjectFolder(string Name, string Path);
|
|
|
|
private sealed record ProjectFileTarget(ProjectFolder Project, string Path);
|
|
|
|
private sealed record FileLineEditMode(
|
|
FileLineEditKind Kind,
|
|
int? From = null,
|
|
int? To = null,
|
|
int? Insert = null)
|
|
{
|
|
public static FileLineEditMode? Create(int? from, int? to, int? insert)
|
|
{
|
|
if (insert.HasValue)
|
|
{
|
|
return from.HasValue || to.HasValue
|
|
? null
|
|
: new FileLineEditMode(FileLineEditKind.Insert, Insert: insert);
|
|
}
|
|
|
|
if (from.HasValue || to.HasValue)
|
|
{
|
|
return from.HasValue && to.HasValue && from.Value <= to.Value
|
|
? new FileLineEditMode(FileLineEditKind.Replace, From: from, To: to)
|
|
: null;
|
|
}
|
|
|
|
return new FileLineEditMode(FileLineEditKind.Overwrite);
|
|
}
|
|
}
|
|
|
|
private enum FileLineEditKind
|
|
{
|
|
Overwrite,
|
|
Replace,
|
|
Insert
|
|
}
|
|
|
|
private sealed class ProjectFrontmatter
|
|
{
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
|
|
public List<string>? Projects { get; set; }
|
|
}
|
|
|
|
private sealed class MeetingNoteFrontmatterYaml
|
|
{
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
|
|
public string? Title { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "start_time")]
|
|
public string? StartTime { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "end_time")]
|
|
public string? EndTime { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "attendees")]
|
|
public List<string>? Attendees { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
|
|
public List<string>? Projects { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "transcript")]
|
|
public string? Transcript { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "assistant_context")]
|
|
public string? AssistantContext { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "summary")]
|
|
public string? Summary { get; set; }
|
|
}
|
|
|
|
private static DateTimeOffset? ParseDateTime(string? value)
|
|
{
|
|
return DateTimeOffset.TryParse(value, out var parsed)
|
|
? parsed
|
|
: null;
|
|
}
|
|
}
|