using Journal.Core.Dtos; using System.Globalization; namespace Journal.Core.Services.Entries; public class EntrySearchService : IEntrySearchService { public Task> SearchEntriesAsync(EntrySearchRequestDto request) { ArgumentNullException.ThrowIfNull(request); if (string.IsNullOrWhiteSpace(request.DataDirectory)) throw new ArgumentException("Data directory is required.", nameof(request.DataDirectory)); if (!Directory.Exists(request.DataDirectory)) return Task.FromResult>([]); var hasQuery = !string.IsNullOrWhiteSpace(request.Query); var query = request.Query?.Trim() ?? ""; var hasSectionFilter = !string.IsNullOrWhiteSpace(request.Section); var section = request.Section?.Trim() ?? ""; var typeSet = NormalizeSet(request.Types); var tagSet = NormalizeSet(request.Tags); var checkedSet = NormalizeSet(request.Checked); var uncheckedSet = NormalizeSet(request.Unchecked); var hasFragmentFilters = typeSet.Count > 0 || tagSet.Count > 0; var hasCheckboxFilters = checkedSet.Count > 0 || uncheckedSet.Count > 0; var startDate = ParseOptionalDate(request.StartDate, nameof(request.StartDate)); var endDate = ParseOptionalDate(request.EndDate, nameof(request.EndDate)); if (startDate.HasValue && endDate.HasValue && startDate.Value > endDate.Value) throw new ArgumentException("startDate cannot be after endDate."); var results = new List(); foreach (var filePath in Directory.GetFiles(request.DataDirectory, "*.md") .OrderBy(Path.GetFileName, StringComparer.Ordinal)) { var fileName = Path.GetFileName(filePath); if (EntryFileNaming.IsTemplateFileName(fileName)) continue; var fileStem = Path.GetFileNameWithoutExtension(filePath); var rawContent = File.ReadAllText(filePath); var entry = JournalParser.ParseJournalContent(rawContent, fileStem); if (startDate.HasValue || endDate.HasValue) { if (!DateOnly.TryParseExact(entry.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var entryDate)) continue; if (startDate.HasValue && entryDate < startDate.Value) continue; if (endDate.HasValue && entryDate > endDate.Value) continue; } var contentMatch = true; if (hasQuery) { var haystack = hasSectionFilter ? entry.GetSection(section) : entry.RawContent; contentMatch = haystack.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; } if (!contentMatch) continue; var fragmentMatch = !hasFragmentFilters || entry.Fragments.Any(fragment => (typeSet.Count == 0 || typeSet.Contains(fragment.Type)) && (tagSet.Count == 0 || fragment.Tags.Any(tagSet.Contains))); if (!fragmentMatch) continue; var checkboxMatch = !hasCheckboxFilters || entry.Sections.Values.Any(sectionValue => sectionValue.Checkboxes.Any(checkbox => (checkedSet.Count > 0 && checkbox.Value && checkedSet.Contains(checkbox.Key)) || (uncheckedSet.Count > 0 && !checkbox.Value && uncheckedSet.Contains(checkbox.Key)))); if (!checkboxMatch) continue; results.Add(new EntrySearchResultDto(fileName, entry.ToDto())); } return Task.FromResult>(results); } private static HashSet NormalizeSet(IReadOnlyList? values) { if (values is null || values.Count == 0) return []; var set = new HashSet(StringComparer.Ordinal); foreach (var value in values) { if (string.IsNullOrWhiteSpace(value)) continue; set.Add(value.Trim()); } return set; } private static DateOnly? ParseOptionalDate(string? raw, string argumentName) { if (string.IsNullOrWhiteSpace(raw)) return null; if (DateOnly.TryParseExact(raw.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return date; throw new ArgumentException($"Invalid {argumentName} value. Expected yyyy-MM-dd."); } }