using System.Text; namespace Core.Notes; /// /// Parses and upserts a generic Field/Value markdown table under a stable heading. /// public static class ImportedFieldsTable { public const string Heading = "## Additional fields"; public static string NormalizeValue(string? raw) { var value = (raw ?? string.Empty).Trim(); if (value.Equals("x", StringComparison.OrdinalIgnoreCase)) return "Yes"; return value; } /// /// Reads Field/Value rows from the Additional fields section. /// public static List ParseFields(string? markdown) { var section = ExtractSection(markdown); if (string.IsNullOrWhiteSpace(section)) return []; List fields = []; foreach (var rawLine in section.Split('\n')) { var line = rawLine.Trim(); if (!line.StartsWith('|') || line.Contains("---", StringComparison.Ordinal)) continue; var cells = SplitTableCells(line); if (cells.Length < 2) continue; var field = UnescapeCell(cells[0]); var value = UnescapeCell(cells[1]); if (field.Equals("Field", StringComparison.OrdinalIgnoreCase) && value.Equals("Value", StringComparison.OrdinalIgnoreCase)) continue; fields.Add(new ImportedField(field, value)); } return fields; } /// /// Upserts incoming fields into the Additional fields section. Incoming values win. /// Fields not in are kept. Identical values are not changes. /// public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList incoming) { var existing = ParseFields(existingMarkdown); var merged = existing.ToList(); List changes = []; foreach (var incomingField in incoming) { var name = incomingField.Name.Trim(); if (string.IsNullOrEmpty(name)) continue; var value = NormalizeValue(incomingField.Value); var index = merged.FindIndex(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); if (index < 0) { merged.Add(new ImportedField(name, value)); changes.Add(new ImportedFieldChange { Field = name, PreviousValue = null, NewValue = value, IsNew = true }); continue; } var current = merged[index]; if (string.Equals(current.Value, value, StringComparison.Ordinal)) continue; merged[index] = new ImportedField(current.Name, value); changes.Add(new ImportedFieldChange { Field = current.Name, PreviousValue = current.Value, NewValue = value, IsNew = false }); } var section = FormatSection(merged); var markdown = ReplaceSection(existingMarkdown, section); return new ImportedFieldsMergeResult { Markdown = markdown, Changed = changes.Count > 0, Changes = changes }; } public static string FormatSection(IReadOnlyList fields) { var builder = new StringBuilder(); builder.AppendLine(Heading); builder.AppendLine(); builder.AppendLine("| Field | Value |"); builder.AppendLine("| --- | --- |"); foreach (var field in fields) builder.AppendLine($"| {EscapeCell(field.Name)} | {EscapeCell(field.Value)} |"); return builder.ToString().TrimEnd() + Environment.NewLine; } public static string? GetFieldValue(string? markdown, string fieldName) { var field = ParseFields(markdown) .FirstOrDefault(f => f.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase)); return field?.Value; } /// /// Unique additional-field names across notes, first-seen casing, sorted A–Z. /// public static List DistinctFieldNames(IEnumerable markdowns) { Dictionary names = new(StringComparer.OrdinalIgnoreCase); foreach (var markdown in markdowns) { foreach (var field in ParseFields(markdown)) { var name = field.Name.Trim(); if (name.Length == 0) continue; names.TryAdd(name, name); } } return [.. names.Values.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)]; } private static string? ExtractSection(string? markdown) { if (string.IsNullOrEmpty(markdown)) return null; if (!TryFindHeading(markdown, out var start, out var headingLength)) return null; var afterHeading = start + headingLength; var nextHeading = FindNextHeading(markdown, afterHeading); return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading]; } private static string ReplaceSection(string? existingMarkdown, string section) { if (string.IsNullOrWhiteSpace(existingMarkdown)) return section.TrimEnd() + Environment.NewLine; if (!TryFindHeading(existingMarkdown, out var start, out var headingLength)) { var prefix = existingMarkdown.TrimEnd(); return string.IsNullOrEmpty(prefix) ? section : prefix + Environment.NewLine + Environment.NewLine + section; } var afterHeading = start + headingLength; var nextHeading = FindNextHeading(existingMarkdown, afterHeading); var before = existingMarkdown[..start].TrimEnd(); var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart(); var builder = new StringBuilder(); if (!string.IsNullOrEmpty(before)) { builder.Append(before); builder.AppendLine(); builder.AppendLine(); } builder.Append(section.TrimEnd()); builder.AppendLine(); if (!string.IsNullOrEmpty(after)) { builder.AppendLine(); builder.Append(after.TrimEnd()); builder.AppendLine(); } return builder.ToString(); } /// /// First ## … fields heading (any prefix). New sections are written as . /// private static bool TryFindHeading(string markdown, out int start, out int headingLength) { var index = 0; while (index < markdown.Length) { var lineEnd = markdown.IndexOf('\n', index); var end = lineEnd < 0 ? markdown.Length : lineEnd; var line = markdown[index..end].TrimEnd('\r'); if (IsFieldsHeading(line)) { start = index; headingLength = line.Length; return true; } if (lineEnd < 0) break; index = lineEnd + 1; } start = -1; headingLength = 0; return false; } private static bool IsFieldsHeading(string line) { var trimmed = line.Trim(); if (!trimmed.StartsWith("## ", StringComparison.Ordinal) || trimmed.StartsWith("###", StringComparison.Ordinal)) return false; var title = trimmed[3..].Trim(); return title.EndsWith(" fields", StringComparison.OrdinalIgnoreCase) || title.Equals("fields", StringComparison.OrdinalIgnoreCase); } private static int FindNextHeading(string markdown, int startIndex) { var index = startIndex; while (index < markdown.Length) { var lineStart = markdown.IndexOf('\n', index); if (lineStart < 0) return -1; lineStart++; if (lineStart < markdown.Length && markdown[lineStart] == '#' && lineStart + 2 < markdown.Length && markdown[lineStart + 1] == '#' && markdown[lineStart + 2] == ' ') return lineStart; index = lineStart; } return -1; } /// /// Splits a markdown table row into cells. Leading/trailing pipes are ignored so a blank /// value (e.g. | Teacher Rec 3 | |) is kept instead of dropped. /// private static string[] SplitTableCells(string line) { var parts = line.Split('|', StringSplitOptions.TrimEntries); var start = 0; var length = parts.Length; if (length > 0 && parts[0].Length == 0) { start = 1; length--; } if (length > 0 && parts[start + length - 1].Length == 0) length--; return length <= 0 ? [] : parts[start..(start + length)]; } private static string EscapeCell(string value) => value.Replace("|", "\\|"); private static string UnescapeCell(string value) => value.Replace("\\|", "|"); } public record ImportedField(string Name, string Value); public class ImportedFieldsMergeResult { public required string Markdown { get; init; } public bool Changed { get; init; } public List Changes { get; init; } = []; } public class ImportedFieldChange { public required string Field { get; init; } public string? PreviousValue { get; init; } public required string NewValue { get; init; } public bool IsNew { get; init; } }