211 lines
7.4 KiB
C#
211 lines
7.4 KiB
C#
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<string, CachedEntry> _entryCache = new(StringComparer.Ordinal);
|
|
|
|
public Task<IReadOnlyList<EntrySearchResultDto>> 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<string>(currentFiles, StringComparer.Ordinal);
|
|
var results = new List<EntrySearchResultDto>();
|
|
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<IReadOnlyList<EntrySearchResultDto>>(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<string> 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<string> NormalizeSet(IReadOnlyList<string>? values)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
return [];
|
|
|
|
var set = new HashSet<string>(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);
|
|
}
|