using Journal.Core.Dtos; using Journal.Core.Repositories; using System.Globalization; using System.Security.Cryptography; using System.Text; namespace Journal.Core.Services.Entries; public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService { private readonly IEntryFileRepository _repo = repo; private readonly Lock _cacheLock = new(); private readonly Dictionary _entryCache = new(StringComparer.Ordinal); public Task> SearchEntriesAsync(EntrySearchRequestDto request) { ArgumentNullException.ThrowIfNull(request); 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 currentFiles = _repo.ListMarkdownFiles() .OrderBy(Path.GetFileName, StringComparer.Ordinal) .ToArray(); var currentFileSet = new HashSet(currentFiles, StringComparer.Ordinal); var results = new List(); foreach (var filePath in currentFiles) { var fileName = _repo.GetFileName(filePath); if (EntryFileNaming.IsTemplateFileName(fileName)) continue; var cached = GetOrBuildCachedEntry(filePath); var entry = cached.Result.Entry; 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 ? GetSection(entry, 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(cached.Result); } RemoveStaleCacheEntries(currentFileSet); return Task.FromResult>(results); } private CachedEntry GetOrBuildCachedEntry(string filePath) { var diskSignature = TryGetDiskFileSignature(filePath); if (diskSignature is not null) { lock (_cacheLock) { if (_entryCache.TryGetValue(filePath, out var cached) && cached.Signature == diskSignature.Value) { return cached; } } } var fileName = _repo.GetFileName(filePath); var fileStem = _repo.GetFileNameWithoutExtension(filePath); var rawContent = _repo.ReadFile(filePath); var signature = diskSignature ?? BuildContentSignature(rawContent); lock (_cacheLock) { if (_entryCache.TryGetValue(filePath, out var cached) && cached.Signature == signature) { return cached; } } var entry = JournalParser.ParseJournalContent(rawContent, fileStem).ToDto(); var built = new CachedEntry(signature, new EntrySearchResultDto(fileName, entry)); lock (_cacheLock) { _entryCache[filePath] = built; } return built; } private static FileSignature? TryGetDiskFileSignature(string filePath) { if (filePath.StartsWith("db://", StringComparison.OrdinalIgnoreCase)) return null; if (!File.Exists(filePath)) return null; var info = new FileInfo(filePath); return new FileSignature(info.Length, info.LastWriteTimeUtc.Ticks, null); } private static FileSignature BuildContentSignature(string content) { var bytes = Encoding.UTF8.GetBytes(content ?? ""); var hash = Convert.ToHexString(SHA256.HashData(bytes)); return new FileSignature(bytes.Length, 0, hash); } private void RemoveStaleCacheEntries(HashSet currentFileSet) { lock (_cacheLock) { if (_entryCache.Count == 0) return; var staleKeys = _entryCache.Keys .Where(path => !currentFileSet.Contains(path)) .ToArray(); foreach (var key in staleKeys) _entryCache.Remove(key); } } private static string GetSection(JournalEntryDto entry, string sectionTitle) { if (string.IsNullOrWhiteSpace(sectionTitle)) return ""; foreach (var (key, value) in entry.Sections) { if (string.Equals(key, sectionTitle, StringComparison.OrdinalIgnoreCase)) return string.Join("\n", value.Content); } return ""; } 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."); } private readonly record struct FileSignature(long Length, long LastWriteUtcTicks, string? ContentHash); private sealed record CachedEntry(FileSignature Signature, EntrySearchResultDto Result); }