journal/Journal.Core/Services/Entries/EntrySearchService.cs
Jacob Schmidt 0d77300c22 feat: Project Journal backend monorepo
Monorepo with centralized build props, npm workspaces, LlamaSharp AI,
SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests.

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-02 20:56:26 -06:00

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);
}