diff --git a/Journal.Core/Dtos/CommandDtos.cs b/Journal.Core/Dtos/CommandDtos.cs new file mode 100644 index 0000000..0ed5dba --- /dev/null +++ b/Journal.Core/Dtos/CommandDtos.cs @@ -0,0 +1,35 @@ +namespace Journal.Core.Dtos; + +internal sealed record VaultInitializePayload(string Password, string VaultDirectory); +internal sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null); +internal sealed record ClearDataPayload(string DataDirectory); +internal sealed record EntryListPayload(string? DataDirectory = null); +internal sealed record EntryLoadPayload(string FilePath); +public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null); +public sealed record EntryListItem(string FileName, string FilePath); +public sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent); +public sealed record EntrySaveResult(string FilePath); +internal sealed record DatabasePayload(string Password, string? DataDirectory = null); +internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null); +internal sealed record AiSummarizeAllPayload(List? Entries); +internal sealed record AiChatPayload(string Prompt); +internal sealed record AiEmbedPayload(string Content); +internal sealed record SpeechTranscribePayload( + string? AudioBase64 = null, + string? Audio_Base64 = null, + string? Engine = null, + string? WhisperModel = null, + string? Whisper_Model = null, + string? Text = null, + int? SimulateDelayMs = null, + int? Simulate_Delay_Ms = null); +internal sealed record SearchEntriesPayload( + string DataDirectory, + string? Query = null, + string? Section = null, + string? StartDate = null, + string? EndDate = null, + List? Tags = null, + List? Types = null, + List? Checked = null, + List? Unchecked = null); diff --git a/Journal.Core/Dtos/DatabaseDtos.cs b/Journal.Core/Dtos/DatabaseDtos.cs new file mode 100644 index 0000000..3c587e7 --- /dev/null +++ b/Journal.Core/Dtos/DatabaseDtos.cs @@ -0,0 +1,18 @@ +namespace Journal.Core.Dtos; + +public sealed record JournalDatabaseStatus( + string DatabasePath, + int KeyLengthBytes, + int Iterations, + string KeyDerivation, + IReadOnlyList SchemaTables, + string SchemaBootstrapPath, + bool RuntimeReady, + string RuntimeMessage); + +public sealed record JournalDatabaseHydrationResult( + string DatabasePath, + string SchemaBootstrapPath, + int EntryFilesProcessed, + bool RuntimeReady, + string Message); diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs index 39627f5..860a0ae 100644 --- a/Journal.Core/Entry.cs +++ b/Journal.Core/Entry.cs @@ -1,46 +1,46 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; -using System.Net; -using System.Text.RegularExpressions; using System.Text.Json; using Journal.Core.Dtos; using Journal.Core.Models; -using Journal.Core.Services; +using Journal.Core.Services.Ai; +using Journal.Core.Services.Config; +using Journal.Core.Services.Database; +using Journal.Core.Services.Entries; +using Journal.Core.Services.Fragments; +using Journal.Core.Services.Logging; +using Journal.Core.Services.Speech; +using Journal.Core.Services.Vault; namespace Journal.Core; -public class Entry +public class Entry( + IFragmentService fragments, + IEntrySearchService entrySearch, + IVaultStorageService vaultStorage, + IJournalDatabaseService database, + IDatabaseSessionService databaseSession, + IJournalConfigService config, + IAiService ai, + ISpeechBridgeService speech, + IEntryFileService entryFiles, + CommandLogger logger) { - private readonly IFragmentService _fragments; - private readonly IEntrySearchService _entrySearch; - private readonly IVaultStorageService _vaultStorage; - private readonly IJournalDatabaseService _database; - private readonly IJournalConfigService _config; - private readonly IAiService _ai; - private readonly ISpeechBridgeService _speech; + private readonly IFragmentService _fragments = fragments; + private readonly IEntrySearchService _entrySearch = entrySearch; + private readonly IVaultStorageService _vaultStorage = vaultStorage; + private readonly IJournalDatabaseService _database = database; + private readonly IDatabaseSessionService _databaseSession = databaseSession; + private readonly IJournalConfigService _config = config; + private readonly IAiService _ai = ai; + private readonly ISpeechBridgeService _speech = speech; + private readonly IEntryFileService _entryFiles = entryFiles; + private readonly CommandLogger _logger = logger; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public Entry( - IFragmentService fragments, - IEntrySearchService entrySearch, - IVaultStorageService vaultStorage, - IJournalDatabaseService database, - IJournalConfigService config, - IAiService ai, - ISpeechBridgeService speech) - { - _fragments = fragments; - _entrySearch = entrySearch; - _vaultStorage = vaultStorage; - _database = database; - _config = config; - _ai = ai; - _speech = speech; - } - public async Task RunAsync() { string? line; @@ -73,7 +73,7 @@ public class Entry var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId) ? Guid.NewGuid().ToString("N") : cmd.CorrelationId.Trim(); - LogStart(action, correlationId, cmd.Payload); + CommandLogger.LogStart(action, correlationId, cmd.Payload); object? result; try @@ -81,18 +81,18 @@ public class Entry switch (action) { case "fragments.list": - result = await _fragments.GetAllAsync(); + result = _fragments.GetAll(); break; case "fragments.get": if (!Guid.TryParse(cmd.Id, out var getId)) return Error("Invalid or missing id"); - result = await _fragments.GetByIdAsync(getId); + result = _fragments.GetById(getId); break; case "fragments.create": var createDto = DeserializePayload(cmd.Payload); if (createDto is null) return Error("Missing or invalid payload"); - result = await _fragments.CreateAsync(createDto); + result = _fragments.Create(createDto); break; case "fragments.update": if (!Guid.TryParse(cmd.Id, out var updateId)) @@ -100,15 +100,15 @@ public class Entry var updateDto = DeserializePayload(cmd.Payload); if (updateDto is null) return Error("Missing or invalid payload"); - result = await _fragments.UpdateAsync(updateId, updateDto); + result = _fragments.Update(updateId, updateDto); break; case "fragments.delete": if (!Guid.TryParse(cmd.Id, out var deleteId)) return Error("Invalid or missing id"); - result = await _fragments.RemoveAsync(deleteId); + result = _fragments.Remove(deleteId); break; case "fragments.search": - result = await _fragments.SearchAsync(cmd.Type, cmd.Tag); + result = _fragments.Search(cmd.Type, cmd.Tag); break; case "search.entries": var searchPayload = DeserializePayload(cmd.Payload); @@ -131,19 +131,19 @@ public class Entry var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory) ? listPayload.DataDirectory : _config.Current.DataDirectory; - result = ListEntries(listDataDirectory); + result = _entryFiles.ListEntries(listDataDirectory); break; case "entries.load": var loadEntryPayload = DeserializePayload(cmd.Payload); if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath)) return Error("Missing or invalid payload"); - result = LoadEntry(loadEntryPayload.FilePath); + result = _entryFiles.LoadEntry(loadEntryPayload.FilePath); break; case "entries.save": var saveEntryPayload = DeserializePayload(cmd.Payload); if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content)) return Error("Missing or invalid payload"); - result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory); + result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory); break; case "config.get": result = _config.Current; @@ -211,6 +211,7 @@ public class Entry if (loadPayload is null) return Error("Missing or invalid payload"); result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory); + _databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory); break; case "vault.save_current_month": var saveCurrentPayload = DeserializePayload(cmd.Payload); @@ -254,124 +255,56 @@ public class Entry if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password)) return Error("Missing or invalid payload"); result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory); + _databaseSession.SetPassword(dbHydratePayload.Password, dbHydratePayload.DataDirectory); break; default: - LogFailure(action, correlationId, "unknown_action"); + CommandLogger.LogFailure(action, correlationId, "unknown_action"); return Error($"Unknown action: {action}"); } } catch (JsonException) { - LogFailure(action, correlationId, "invalid_payload_json"); + CommandLogger.LogFailure(action, correlationId, "invalid_payload_json"); return Error("Missing or invalid payload"); } catch (ValidationException ex) { - LogFailure(action, correlationId, "validation", ex.Message); + CommandLogger.LogFailure(action, correlationId, "validation", ex.Message); return Error(ex.Message); } catch (ArgumentException ex) { - LogFailure(action, correlationId, "argument", ex.Message); + CommandLogger.LogFailure(action, correlationId, "argument", ex.Message); return Error(ex.Message); } catch (TimeoutException ex) { - LogFailure(action, correlationId, "timeout", ex.Message); + CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message); return Error(ex.Message); } catch (InvalidOperationException ex) { - LogFailure(action, correlationId, "invalid_operation", ex.Message); + CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message); return Error(ex.Message); } catch (FileNotFoundException ex) { - LogFailure(action, correlationId, "not_found", ex.Message); + CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message); return Error(ex.Message); } catch { - LogFailure(action, correlationId, "internal_error"); + CommandLogger.LogFailure(action, correlationId, "internal_error"); return Error("Internal error"); } - LogSuccess(action, correlationId); + CommandLogger.LogSuccess(action, correlationId); return JsonSerializer.Serialize(new { ok = true, data = result }); } private static string Error(string message) => JsonSerializer.Serialize(new { ok = false, error = message }); - private void LogStart(string action, string correlationId, JsonElement? payload) - { - var redactedPayload = LogRedactor.RedactPayload(payload); - EmitLog("information", action, correlationId, "start", redactedPayload); - } - - private void LogSuccess(string action, string correlationId) - { - EmitLog("information", action, correlationId, "success"); - } - - private void LogFailure(string action, string correlationId, string errorType, string? message = null) - { - var details = string.IsNullOrWhiteSpace(message) - ? "" - : (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)"); - EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details); - } - - private static void EmitLog( - string level, - string action, - string correlationId, - string outcome, - object? payload = null, - string? errorType = null, - string? details = null) - { - if (!ShouldLog(level)) - return; - - var envelope = new - { - timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), - level, - component = nameof(Entry), - action, - correlation_id = correlationId, - outcome, - error_type = errorType, - details, - payload - }; - Console.Error.WriteLine(JsonSerializer.Serialize(envelope)); - } - - private static bool ShouldLog(string level) - { - var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning") - .Trim() - .ToLowerInvariant(); - var configuredRank = LogLevelRank(configured); - var incomingRank = LogLevelRank(level); - return incomingRank >= configuredRank; - } - - private static int LogLevelRank(string level) => level switch - { - "trace" => 0, - "debug" => 1, - "information" => 2, - "info" => 2, - "warning" => 3, - "warn" => 3, - "error" => 4, - "critical" => 5, - _ => 3 - }; - private static T? DeserializePayload(JsonElement? payload) { if (payload is null) @@ -395,153 +328,4 @@ public class Entry throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time."); } - - private static IReadOnlyList ListEntries(string dataDirectory) - { - if (!Directory.Exists(dataDirectory)) - return []; - - return Directory.GetFiles(dataDirectory, "*.md") - .OrderBy(Path.GetFileName, StringComparer.Ordinal) - .Select(path => new EntryListItem( - FileName: Path.GetFileName(path), - FilePath: Path.GetFullPath(path))) - .ToArray(); - } - - private static EntryLoadResult LoadEntry(string filePath) - { - var normalizedPath = Path.GetFullPath(filePath); - if (!File.Exists(normalizedPath)) - throw new FileNotFoundException($"Entry file not found: {normalizedPath}"); - - var rawContent = StripRichHtml(File.ReadAllText(normalizedPath)); - var fileStem = Path.GetFileNameWithoutExtension(normalizedPath); - var entry = JournalParser.ParseJournalContent(rawContent, fileStem); - - return new EntryLoadResult( - Date: entry.Date, - FileName: Path.GetFileName(normalizedPath), - FilePath: normalizedPath, - RawContent: entry.RawContent); - } - - private static EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory) - { - var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory); - var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim(); - var sanitizedContent = StripRichHtml(payload.Content ?? ""); - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - - if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase)) - { - File.WriteAllText(targetPath, sanitizedContent); - return new EntrySaveResult(targetPath); - } - - if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase)) - { - File.AppendAllText(targetPath, "\n\n" + sanitizedContent.Trim()); - return new EntrySaveResult(targetPath); - } - - string finalContent; - if (File.Exists(targetPath)) - { - var existingContent = File.ReadAllText(targetPath); - var fileStem = Path.GetFileNameWithoutExtension(targetPath); - var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem); - var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem); - existingEntry.MergeWith(newEntryData); - finalContent = existingEntry.ToMarkdown(); - } - else - { - finalContent = sanitizedContent; - } - - File.WriteAllText(targetPath, finalContent); - return new EntrySaveResult(targetPath); - } - - private static string ResolveTargetPath(string? filePath, string defaultDataDirectory) - { - if (!string.IsNullOrWhiteSpace(filePath)) - return Path.GetFullPath(filePath); - - return Path.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md")); - } - - private static bool LooksLikeRichHtml(string content) - { - var lowered = content.ToLowerInvariant(); - string[] markers = - [ - "", " lowered.Contains(marker, StringComparison.Ordinal))) - return true; - return Regex.Matches(lowered, "]*>").Count >= 8; - } - - private static string StripRichHtml(string content) - { - if (string.IsNullOrWhiteSpace(content)) - return content; - if (!LooksLikeRichHtml(content)) - return content; - - var text = content.Replace("\r\n", "\n").Replace("\r", "\n"); - text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); - text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); - text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); - text = Regex.Replace(text, "]*>", "\n- ", RegexOptions.IgnoreCase); - text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); - text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase); - text = Regex.Replace(text, "", " ", RegexOptions.IgnoreCase); - text = Regex.Replace(text, "]*>", "\n---\n", RegexOptions.IgnoreCase); - text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline); - text = WebUtility.HtmlDecode(text) - .Replace('\u00a0', ' ') - .Replace("\u200b", "", StringComparison.Ordinal); - text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd())); - text = Regex.Replace(text, "[ \\t]{2,}", " "); - text = Regex.Replace(text, "\n{3,}", "\n\n").Trim(); - return string.IsNullOrEmpty(text) ? content : text; - } - - private sealed record VaultInitializePayload(string Password, string VaultDirectory); - private sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null); - private sealed record ClearDataPayload(string DataDirectory); - private sealed record EntryListPayload(string? DataDirectory = null); - private sealed record EntryLoadPayload(string FilePath); - private sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null); - private sealed record EntryListItem(string FileName, string FilePath); - private sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent); - private sealed record EntrySaveResult(string FilePath); - private sealed record DatabasePayload(string Password, string? DataDirectory = null); - private sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null); - private sealed record AiSummarizeAllPayload(List? Entries); - private sealed record AiChatPayload(string Prompt); - private sealed record AiEmbedPayload(string Content); - private sealed record SpeechTranscribePayload( - string? AudioBase64 = null, - string? Audio_Base64 = null, - string? Engine = null, - string? WhisperModel = null, - string? Whisper_Model = null, - string? Text = null, - int? SimulateDelayMs = null, - int? Simulate_Delay_Ms = null); - private sealed record SearchEntriesPayload( - string DataDirectory, - string? Query = null, - string? Section = null, - string? StartDate = null, - string? EndDate = null, - List? Tags = null, - List? Types = null, - List? Checked = null, - List? Unchecked = null); } diff --git a/Journal.Core/Repositories/DiskEntryFileRepository.cs b/Journal.Core/Repositories/DiskEntryFileRepository.cs new file mode 100644 index 0000000..43df2a9 --- /dev/null +++ b/Journal.Core/Repositories/DiskEntryFileRepository.cs @@ -0,0 +1,33 @@ +namespace Journal.Core.Repositories; + +public sealed class DiskEntryFileRepository : IEntryFileRepository +{ + public IReadOnlyList ListMarkdownFiles(string dataDirectory) + { + if (!Directory.Exists(dataDirectory)) + return []; + + return [.. Directory.GetFiles(dataDirectory, "*.md").OrderBy(Path.GetFileName, StringComparer.Ordinal)]; + } + + public string ReadFile(string filePath) => File.ReadAllText(filePath); + + public void WriteFile(string filePath, string content) => File.WriteAllText(filePath, content); + + public void AppendFile(string filePath, string content) => File.AppendAllText(filePath, content); + + public bool FileExists(string filePath) => File.Exists(filePath); + + public string GetFullPath(string filePath) => Path.GetFullPath(filePath); + + public string GetFileName(string filePath) => Path.GetFileName(filePath); + + public string GetFileNameWithoutExtension(string filePath) => Path.GetFileNameWithoutExtension(filePath); + + public void EnsureDirectory(string path) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(dir)) + Directory.CreateDirectory(dir); + } +} diff --git a/Journal.Core/Repositories/FileFragmentRepository.cs b/Journal.Core/Repositories/FileFragmentRepository.cs deleted file mode 100644 index 71e107d..0000000 --- a/Journal.Core/Repositories/FileFragmentRepository.cs +++ /dev/null @@ -1,228 +0,0 @@ -using System.Text.Json; -using Journal.Core.Models; - -namespace Journal.Core.Repositories; - -public class FileFragmentRepository : IFragmentRepository -{ - private readonly Lock _lock = new(); - private readonly string _storagePath; - private readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true - }; - private readonly List _store; - - public FileFragmentRepository() : this(storagePath: null) - { - } - - public FileFragmentRepository(string? storagePath) - { - _storagePath = ResolveStoragePath(storagePath); - _store = LoadStore(_storagePath); - } - - public Task> GetAllAsync() - { - lock (_lock) - { - return Task.FromResult(_store.ToList()); - } - } - - public Task GetByIdAsync(Guid id) - { - lock (_lock) - { - return Task.FromResult(_store.FirstOrDefault(f => f.Id == id)); - } - } - - public Task AddAsync(Fragment fragment) - { - ArgumentNullException.ThrowIfNull(fragment); - lock (_lock) - { - Normalize(fragment); - _store.Add(fragment); - SaveStoreLocked(); - } - return Task.CompletedTask; - } - - public Task RemoveAsync(Guid id) - { - lock (_lock) - { - var item = _store.FirstOrDefault(f => f.Id == id); - if (item is null) - return Task.FromResult(false); - - var removed = _store.Remove(item); - if (removed) - SaveStoreLocked(); - return Task.FromResult(removed); - } - } - - public Task UpdateAsync( - Guid id, - string? type = null, - string? description = null, - IEnumerable? tags = null, - DateTimeOffset? time = null) - { - lock (_lock) - { - var item = _store.FirstOrDefault(f => f.Id == id); - if (item is null) - return Task.FromResult(false); - - if (type != null) - { - if (string.IsNullOrWhiteSpace(type)) - throw new ArgumentException("Type cannot be empty", nameof(type)); - item.Type = type.Trim(); - } - - if (description != null) - { - if (string.IsNullOrWhiteSpace(description)) - throw new ArgumentException("Description cannot be empty", nameof(description)); - item.Description = description.Trim(); - } - - if (tags != null) - { - item.Tags = [.. - tags.Where(t => !string.IsNullOrWhiteSpace(t)) - .Select(t => t.Trim())]; - } - - if (time.HasValue) - item.Time = time.Value; - - SaveStoreLocked(); - return Task.FromResult(true); - } - } - - public Task> GetByTagAsync(string tag) - { - var q = tag?.Trim(); - if (string.IsNullOrWhiteSpace(q)) - return Task.FromResult(new List()); - - lock (_lock) - { - var items = _store - .Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))) - .ToList(); - return Task.FromResult(items); - } - } - - public Task> GetByTypeAsync(string type) - { - var q = type?.Trim(); - if (string.IsNullOrWhiteSpace(q)) - return Task.FromResult(new List()); - - lock (_lock) - { - var items = _store - .Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase)) - .ToList(); - return Task.FromResult(items); - } - } - - public Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) - { - var qType = type?.Trim(); - var qTag = tag?.Trim(); - - lock (_lock) - { - IEnumerable results = _store; - - if (!string.IsNullOrWhiteSpace(qType)) - results = results.Where(f => f.Type.Contains(qType, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(qTag)) - results = results.Where(f => f.Tags.Any(t => t.Contains(qTag, StringComparison.OrdinalIgnoreCase))); - if (timeAfter.HasValue) - results = results.Where(f => f.Time > timeAfter.Value); - - return Task.FromResult(results.ToList()); - } - } - - private static string ResolveStoragePath(string? storagePath) - { - var configured = storagePath; - if (string.IsNullOrWhiteSpace(configured)) - configured = Environment.GetEnvironmentVariable("JOURNAL_FRAGMENT_STORE_PATH"); - if (string.IsNullOrWhiteSpace(configured)) - configured = Path.Combine(Environment.CurrentDirectory, ".journal-sidecar", "fragments.json"); - - return Path.GetFullPath(configured); - } - - private List LoadStore(string path) - { - var directory = Path.GetDirectoryName(path); - if (!string.IsNullOrWhiteSpace(directory)) - Directory.CreateDirectory(directory); - - if (!File.Exists(path)) - return []; - - var json = File.ReadAllText(path); - if (string.IsNullOrWhiteSpace(json)) - return []; - - var docs = JsonSerializer.Deserialize>(json, _jsonOptions) ?? []; - return docs.Select(d => new Fragment(d.Id, d.Type, d.Description, d.Time, d.Tags)).ToList(); - } - - private void SaveStoreLocked() - { - var directory = Path.GetDirectoryName(_storagePath); - if (!string.IsNullOrWhiteSpace(directory)) - Directory.CreateDirectory(directory); - - var docs = _store.Select(f => new FragmentDocument - { - Id = f.Id, - Type = f.Type, - Description = f.Description, - Time = f.Time, - Tags = [.. f.Tags] - }).ToList(); - var json = JsonSerializer.Serialize(docs, _jsonOptions); - - var tempPath = _storagePath + ".tmp"; - File.WriteAllText(tempPath, json); - File.Copy(tempPath, _storagePath, overwrite: true); - File.Delete(tempPath); - } - - private static void Normalize(Fragment fragment) - { - fragment.Type = fragment.Type.Trim(); - fragment.Description = fragment.Description.Trim(); - fragment.Tags = [.. - fragment.Tags.Where(t => !string.IsNullOrWhiteSpace(t)) - .Select(t => t.Trim())]; - } - - private sealed class FragmentDocument - { - public Guid Id { get; init; } - public string Type { get; init; } = ""; - public string Description { get; init; } = ""; - public DateTimeOffset Time { get; init; } - public List Tags { get; init; } = []; - } -} diff --git a/Journal.Core/Repositories/IEntryFileRepository.cs b/Journal.Core/Repositories/IEntryFileRepository.cs new file mode 100644 index 0000000..8242bec --- /dev/null +++ b/Journal.Core/Repositories/IEntryFileRepository.cs @@ -0,0 +1,14 @@ +namespace Journal.Core.Repositories; + +public interface IEntryFileRepository +{ + IReadOnlyList ListMarkdownFiles(string dataDirectory); + string ReadFile(string filePath); + void WriteFile(string filePath, string content); + void AppendFile(string filePath, string content); + bool FileExists(string filePath); + string GetFullPath(string filePath); + string GetFileName(string filePath); + string GetFileNameWithoutExtension(string filePath); + void EnsureDirectory(string path); +} diff --git a/Journal.Core/Repositories/IFragmentRepository.cs b/Journal.Core/Repositories/IFragmentRepository.cs index 54011c1..bb05f0d 100644 --- a/Journal.Core/Repositories/IFragmentRepository.cs +++ b/Journal.Core/Repositories/IFragmentRepository.cs @@ -4,12 +4,12 @@ namespace Journal.Core.Repositories; public interface IFragmentRepository { - Task> GetAllAsync(); - Task GetByIdAsync(Guid id); - Task AddAsync(Fragment fragment); - Task RemoveAsync(Guid id); - Task UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable? tags = null, DateTimeOffset? time = null); - Task> GetByTagAsync(string tag); - Task> GetByTypeAsync(string type); - Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); + List GetAll(); + Fragment? GetById(Guid id); + void Add(Fragment fragment); + bool Remove(Guid id); + bool Update(Guid id, string? type = null, string? description = null, IEnumerable? tags = null, DateTimeOffset? time = null); + List GetByTag(string tag); + List GetByType(string type); + List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); } diff --git a/Journal.Core/Repositories/InMemoryFragmentRepository.cs b/Journal.Core/Repositories/InMemoryFragmentRepository.cs index d283645..b9f6cf5 100644 --- a/Journal.Core/Repositories/InMemoryFragmentRepository.cs +++ b/Journal.Core/Repositories/InMemoryFragmentRepository.cs @@ -7,23 +7,23 @@ public class InMemoryFragmentRepository : IFragmentRepository private readonly List _store = []; private readonly Lock _lock = new(); - public Task> GetAllAsync() + public List GetAll() { lock (_lock) { - return Task.FromResult(_store.ToList()); + return _store.ToList(); } } - public Task GetByIdAsync(Guid id) + public Fragment? GetById(Guid id) { lock (_lock) { - return Task.FromResult(_store.FirstOrDefault(f => f.Id == id)); + return _store.FirstOrDefault(f => f.Id == id); } } - public Task AddAsync(Fragment fragment) + public void Add(Fragment fragment) { if (fragment is null) throw new ArgumentNullException(nameof(fragment)); lock (_lock) @@ -39,25 +39,24 @@ public class InMemoryFragmentRepository : IFragmentRepository _store.Add(fragment); } - return Task.CompletedTask; } - public Task RemoveAsync(Guid id) + public bool Remove(Guid id) { lock (_lock) { var item = _store.FirstOrDefault(f => f.Id == id); - if (item is null) return Task.FromResult(false); - return Task.FromResult(_store.Remove(item)); + if (item is null) return false; + return _store.Remove(item); } } - public Task UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable? tags = null, DateTimeOffset? time = null) + public bool Update(Guid id, string? type = null, string? description = null, IEnumerable? tags = null, DateTimeOffset? time = null) { lock (_lock) { var item = _store.FirstOrDefault(f => f.Id == id); - if (item is null) return Task.FromResult(false); + if (item is null) return false; if (type != null) { @@ -81,31 +80,31 @@ public class InMemoryFragmentRepository : IFragmentRepository if (time.HasValue) item.Time = time.Value; - return Task.FromResult(true); + return true; } } - public Task> GetByTagAsync(string tag) + public List GetByTag(string tag) { var q = tag?.Trim(); - if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List()); + if (string.IsNullOrWhiteSpace(q)) return []; lock (_lock) { - return Task.FromResult(_store.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(q, StringComparison.OrdinalIgnoreCase)) == true).ToList()); + return _store.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(q, StringComparison.OrdinalIgnoreCase)) == true).ToList(); } } - public Task> GetByTypeAsync(string type) + public List GetByType(string type) { var q = type?.Trim(); - if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List()); + if (string.IsNullOrWhiteSpace(q)) return []; lock (_lock) { - return Task.FromResult(_store.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(q, StringComparison.OrdinalIgnoreCase)).ToList()); + return _store.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); } } - public Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + public List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) { var results = _store.AsEnumerable(); var qType = type?.Trim(); @@ -120,7 +119,7 @@ public class InMemoryFragmentRepository : IFragmentRepository if (timeAfter.HasValue) results = results.Where(f => f.Time > timeAfter.Value); - return Task.FromResult(results.ToList()); + return results.ToList(); } } } diff --git a/Journal.Core/Repositories/SqliteFragmentRepository.cs b/Journal.Core/Repositories/SqliteFragmentRepository.cs new file mode 100644 index 0000000..50c24a7 --- /dev/null +++ b/Journal.Core/Repositories/SqliteFragmentRepository.cs @@ -0,0 +1,314 @@ +using Journal.Core.Models; +using Journal.Core.Services.Database; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Repositories; + +public sealed class SqliteFragmentRepository(IDatabaseSessionService session) : IFragmentRepository +{ + private readonly IDatabaseSessionService _session = session; + + public List GetAll() + { + var conn = _session.GetConnection(); + return ReadAllFragments(conn); + } + + public Fragment? GetById(Guid id) + { + var conn = _session.GetConnection(); + return ReadFragment(conn, id); + } + + public void Add(Fragment fragment) + { + ArgumentNullException.ThrowIfNull(fragment); + Normalize(fragment); + var conn = _session.GetConnection(); + InsertFragment(conn, fragment); + } + + public bool Remove(Guid id) + { + var conn = _session.GetConnection(); + return DeleteFragment(conn, id); + } + + public bool Update( + Guid id, + string? type = null, + string? description = null, + IEnumerable? tags = null, + DateTimeOffset? time = null) + { + var conn = _session.GetConnection(); + var existing = ReadFragment(conn, id); + if (existing is null) + return false; + + if (type != null) + { + if (string.IsNullOrWhiteSpace(type)) + throw new ArgumentException("Type cannot be empty", nameof(type)); + existing.Type = type.Trim(); + } + + if (description != null) + { + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description cannot be empty", nameof(description)); + existing.Description = description.Trim(); + } + + if (tags != null) + { + existing.Tags = [.. + tags.Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim())]; + } + + if (time.HasValue) + existing.Time = time.Value; + + UpdateFragmentRow(conn, existing); + return true; + } + + public List GetByTag(string tag) + { + var q = tag?.Trim(); + if (string.IsNullOrWhiteSpace(q)) + return []; + + var conn = _session.GetConnection(); + var all = ReadAllFragments(conn); + return all + .Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + public List GetByType(string type) + { + var q = type?.Trim(); + if (string.IsNullOrWhiteSpace(q)) + return []; + + var conn = _session.GetConnection(); + var all = ReadAllFragments(conn); + return all + .Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + { + var conn = _session.GetConnection(); + IEnumerable results = ReadAllFragments(conn); + + var qType = type?.Trim(); + var qTag = tag?.Trim(); + + if (!string.IsNullOrWhiteSpace(qType)) + results = results.Where(f => f.Type.Contains(qType, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(qTag)) + results = results.Where(f => f.Tags.Any(t => t.Contains(qTag, StringComparison.OrdinalIgnoreCase))); + if (timeAfter.HasValue) + results = results.Where(f => f.Time > timeAfter.Value); + + return results.ToList(); + } + + // ── Private helpers ────────────────────────────────────────────── + + private static void InsertFragment(SqliteConnection conn, Fragment f) + { + using var tx = conn.BeginTransaction(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO fragments (guid, entry_id, type, description, time) + VALUES (@guid, NULL, @type, @description, @time); + """; + cmd.Parameters.AddWithValue("@guid", f.Id.ToString("D")); + cmd.Parameters.AddWithValue("@type", f.Type); + cmd.Parameters.AddWithValue("@description", f.Description); + cmd.Parameters.AddWithValue("@time", f.Time.ToString("O")); + cmd.ExecuteNonQuery(); + + var fragmentRowId = GetFragmentRowId(conn, f.Id); + if (fragmentRowId.HasValue) + InsertTags(conn, fragmentRowId.Value, f.Tags); + + tx.Commit(); + } + + private static void UpdateFragmentRow(SqliteConnection conn, Fragment f) + { + using var tx = conn.BeginTransaction(); + + using var upd = conn.CreateCommand(); + upd.CommandText = """ + UPDATE fragments SET type = @type, description = @description, time = @time + WHERE guid = @guid AND entry_id IS NULL; + """; + upd.Parameters.AddWithValue("@guid", f.Id.ToString("D")); + upd.Parameters.AddWithValue("@type", f.Type); + upd.Parameters.AddWithValue("@description", f.Description); + upd.Parameters.AddWithValue("@time", f.Time.ToString("O")); + upd.ExecuteNonQuery(); + + var fragmentRowId = GetFragmentRowId(conn, f.Id); + if (fragmentRowId.HasValue) + { + using var del = conn.CreateCommand(); + del.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;"; + del.Parameters.AddWithValue("@id", fragmentRowId.Value); + del.ExecuteNonQuery(); + + InsertTags(conn, fragmentRowId.Value, f.Tags); + } + + tx.Commit(); + } + + private static bool DeleteFragment(SqliteConnection conn, Guid id) + { + using var tx = conn.BeginTransaction(); + + var fragmentRowId = GetFragmentRowId(conn, id); + if (fragmentRowId.HasValue) + { + using var delTags = conn.CreateCommand(); + delTags.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;"; + delTags.Parameters.AddWithValue("@id", fragmentRowId.Value); + delTags.ExecuteNonQuery(); + } + + using var delFrag = conn.CreateCommand(); + delFrag.CommandText = "DELETE FROM fragments WHERE guid = @guid AND entry_id IS NULL;"; + delFrag.Parameters.AddWithValue("@guid", id.ToString("D")); + var rows = delFrag.ExecuteNonQuery(); + + tx.Commit(); + return rows > 0; + } + + private static Fragment? ReadFragment(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, guid, type, description, time + FROM fragments WHERE guid = @guid AND entry_id IS NULL; + """; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + return null; + + var fragment = MapRow(reader); + fragment.Tags = ReadTags(conn, reader.GetInt64(0)); + return fragment; + } + + private static List ReadAllFragments(SqliteConnection conn) + { + var fragments = new List(); + var rowIds = new List(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, guid, type, description, time + FROM fragments WHERE guid IS NOT NULL AND entry_id IS NULL + ORDER BY time; + """; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + fragments.Add(MapRow(reader)); + rowIds.Add(reader.GetInt64(0)); + } + + for (var i = 0; i < fragments.Count; i++) + fragments[i].Tags = ReadTags(conn, rowIds[i]); + + return fragments; + } + + private static List ReadTags(SqliteConnection conn, long fragmentRowId) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT t.name FROM tags t + INNER JOIN fragment_tags ft ON ft.tag_id = t.id + WHERE ft.fragment_id = @id + ORDER BY t.name; + """; + cmd.Parameters.AddWithValue("@id", fragmentRowId); + + var tags = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + tags.Add(reader.GetString(0)); + + return tags; + } + + private static long? GetFragmentRowId(SqliteConnection conn, Guid guid) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id FROM fragments WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", guid.ToString("D")); + var result = cmd.ExecuteScalar(); + return result is long id ? id : null; + } + + private static void InsertTags(SqliteConnection conn, long fragmentRowId, List tags) + { + if (tags.Count == 0) return; + + foreach (var tag in tags) + { + // Upsert into tags table + using var upsert = conn.CreateCommand(); + upsert.CommandText = "INSERT OR IGNORE INTO tags (name) VALUES (@name);"; + upsert.Parameters.AddWithValue("@name", tag); + upsert.ExecuteNonQuery(); + + // Get tag id + using var getTagId = conn.CreateCommand(); + getTagId.CommandText = "SELECT id FROM tags WHERE name = @name;"; + getTagId.Parameters.AddWithValue("@name", tag); + var tagId = (long)getTagId.ExecuteScalar()!; + + // Link fragment to tag + using var link = conn.CreateCommand(); + link.CommandText = "INSERT OR IGNORE INTO fragment_tags (fragment_id, tag_id) VALUES (@fid, @tid);"; + link.Parameters.AddWithValue("@fid", fragmentRowId); + link.Parameters.AddWithValue("@tid", tagId); + link.ExecuteNonQuery(); + } + } + + private static Fragment MapRow(SqliteDataReader reader) + { + // columns: id (int), guid (text), type (text), description (text), time (text) + var guid = Guid.Parse(reader.GetString(1)); + var type = reader.GetString(2); + var description = reader.IsDBNull(3) ? "" : reader.GetString(3); + var time = reader.IsDBNull(4) + ? DateTimeOffset.MinValue + : DateTimeOffset.Parse(reader.GetString(4)); + return new Fragment(guid, type, description, time); + } + + private static void Normalize(Fragment fragment) + { + fragment.Type = fragment.Type.Trim(); + fragment.Description = fragment.Description.Trim(); + fragment.Tags = [.. + fragment.Tags.Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim())]; + } +} diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs index 3bb6fc4..e617b41 100644 --- a/Journal.Core/ServiceCollectionExtensions.cs +++ b/Journal.Core/ServiceCollectionExtensions.cs @@ -1,6 +1,14 @@ using Microsoft.Extensions.DependencyInjection; using Journal.Core.Repositories; -using Journal.Core.Services; +using Journal.Core.Services.Ai; +using Journal.Core.Services.Config; +using Journal.Core.Services.Database; +using Journal.Core.Services.Entries; +using Journal.Core.Services.Fragments; +using Journal.Core.Services.Logging; +using Journal.Core.Services.Sidecar; +using Journal.Core.Services.Speech; +using Journal.Core.Services.Vault; namespace Journal.Core; @@ -8,7 +16,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddFragmentServices(this IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddTransient(); @@ -47,6 +56,9 @@ public static class ServiceCollectionExtensions message: $"Python speech sidecar unavailable: {ex.Message}"); } }); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; } diff --git a/Journal.Core/Services/DisabledAiService.cs b/Journal.Core/Services/Ai/DisabledAiService.cs similarity index 62% rename from Journal.Core/Services/DisabledAiService.cs rename to Journal.Core/Services/Ai/DisabledAiService.cs index cc5b8ac..d5a7ad6 100644 --- a/Journal.Core/Services/DisabledAiService.cs +++ b/Journal.Core/Services/Ai/DisabledAiService.cs @@ -1,19 +1,12 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Ai; -public sealed class DisabledAiService : IAiService +public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService { - private readonly string _provider; - private readonly string _message; - private readonly bool _healthy; - - public DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) - { - _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); - _message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim(); - _healthy = healthy; - } + private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); + private readonly string _message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim(); + private readonly bool _healthy = healthy; public Task HealthAsync(CancellationToken cancellationToken = default) => Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message)); diff --git a/Journal.Core/Services/IAiService.cs b/Journal.Core/Services/Ai/IAiService.cs similarity index 94% rename from Journal.Core/Services/IAiService.cs rename to Journal.Core/Services/Ai/IAiService.cs index 791873b..5300641 100644 --- a/Journal.Core/Services/IAiService.cs +++ b/Journal.Core/Services/Ai/IAiService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Ai; public interface IAiService { diff --git a/Journal.Core/Services/Ai/PythonSidecarAiService.cs b/Journal.Core/Services/Ai/PythonSidecarAiService.cs new file mode 100644 index 0000000..daba179 --- /dev/null +++ b/Journal.Core/Services/Ai/PythonSidecarAiService.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Services.Sidecar; + +namespace Journal.Core.Services.Ai; + +public sealed class PythonSidecarAiService : IAiService +{ + private readonly PythonSidecarClient _client; + + public PythonSidecarAiService(JournalConfig config) + { + if (string.IsNullOrWhiteSpace(config.PythonAiSidecarPath)) + throw new ArgumentException("Python AI sidecar path is required."); + if (!File.Exists(config.PythonAiSidecarPath)) + throw new FileNotFoundException($"Python AI sidecar not found: {config.PythonAiSidecarPath}"); + _client = new PythonSidecarClient(config); + } + + public async Task HealthAsync(CancellationToken cancellationToken = default) + { + var data = await _client.SendAsync("health", payload: new { }, cancellationToken); + if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object) + return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok"); + + var provider = payload.TryGetProperty("provider", out var providerNode) + ? providerNode.GetString() ?? "python-sidecar" + : "python-sidecar"; + var message = payload.TryGetProperty("message", out var messageNode) + ? messageNode.GetString() ?? "ok" + : "ok"; + var healthy = !payload.TryGetProperty("healthy", out var healthyNode) || + healthyNode.ValueKind is JsonValueKind.True || + (healthyNode.ValueKind is not JsonValueKind.False); + return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message); + } + + public async Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Entry content is required.", nameof(content)); + + var data = await _client.SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken); + return data?.GetString() ?? ""; + } + + public async Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default) + { + entries ??= []; + var data = await _client.SendAsync("summarize_all", new { entries }, cancellationToken); + return data?.GetString() ?? ""; + } + + public async Task ChatAsync(string prompt, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required.", nameof(prompt)); + + var data = await _client.SendAsync("chat", new { prompt }, cancellationToken); + return data?.GetString() ?? ""; + } + + public async Task> EmbedAsync(string content, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Content is required.", nameof(content)); + + var data = await _client.SendAsync("embed", new { content }, cancellationToken); + if (data is null || data.Value.ValueKind == JsonValueKind.Null) + return []; + + if (data.Value.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array."); + + var values = new List(); + foreach (var item in data.Value.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Number) + throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value."); + values.Add(item.GetDouble()); + } + + return values; + } +} diff --git a/Journal.Core/Services/IJournalConfigService.cs b/Journal.Core/Services/Config/IJournalConfigService.cs similarity index 72% rename from Journal.Core/Services/IJournalConfigService.cs rename to Journal.Core/Services/Config/IJournalConfigService.cs index 3e0a7b5..5da1f33 100644 --- a/Journal.Core/Services/IJournalConfigService.cs +++ b/Journal.Core/Services/Config/IJournalConfigService.cs @@ -1,6 +1,6 @@ using Journal.Core.Models; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Config; public interface IJournalConfigService { diff --git a/Journal.Core/Services/JournalConfigService.cs b/Journal.Core/Services/Config/JournalConfigService.cs similarity index 99% rename from Journal.Core/Services/JournalConfigService.cs rename to Journal.Core/Services/Config/JournalConfigService.cs index bce9602..826ec43 100644 --- a/Journal.Core/Services/JournalConfigService.cs +++ b/Journal.Core/Services/Config/JournalConfigService.cs @@ -1,6 +1,6 @@ using Journal.Core.Models; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Config; public sealed class JournalConfigService : IJournalConfigService { diff --git a/Journal.Core/Services/Database/DatabaseSessionService.cs b/Journal.Core/Services/Database/DatabaseSessionService.cs new file mode 100644 index 0000000..f16f7c6 --- /dev/null +++ b/Journal.Core/Services/Database/DatabaseSessionService.cs @@ -0,0 +1,66 @@ +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services.Database; + +public sealed class DatabaseSessionService(IJournalDatabaseService database) : IDatabaseSessionService, IDisposable +{ + private readonly IJournalDatabaseService _database = database; + private readonly Lock _lock = new(); + private string? _password; + private string? _dataDirectory; + private SqliteConnection? _connection; + + public bool IsUnlocked + { + get + { + lock (_lock) { return _password is not null; } + } + } + + public void SetPassword(string password, string? dataDirectory = null) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + + lock (_lock) + { + // If password or directory changed, close the old connection + if (_connection is not null && + (_password != password || _dataDirectory != dataDirectory)) + { + _connection.Dispose(); + _connection = null; + } + + _password = password; + _dataDirectory = dataDirectory; + } + } + + public SqliteConnection GetConnection() + { + lock (_lock) + { + if (_password is null) + throw new InvalidOperationException( + "Database is locked. Authenticate first (e.g. vault.load_all or db.hydrate_workspace)."); + + if (_connection is not null) + return _connection; + + _connection = _database.OpenEncryptedConnection(_password, _dataDirectory); + _database.EnsureSchema(_connection); + return _connection; + } + } + + public void Dispose() + { + lock (_lock) + { + _connection?.Dispose(); + _connection = null; + } + } +} diff --git a/Journal.Core/Services/Database/IDatabaseSessionService.cs b/Journal.Core/Services/Database/IDatabaseSessionService.cs new file mode 100644 index 0000000..264334d --- /dev/null +++ b/Journal.Core/Services/Database/IDatabaseSessionService.cs @@ -0,0 +1,10 @@ +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services.Database; + +public interface IDatabaseSessionService +{ + bool IsUnlocked { get; } + void SetPassword(string password, string? dataDirectory = null); + SqliteConnection GetConnection(); +} diff --git a/Journal.Core/Services/IJournalDatabaseService.cs b/Journal.Core/Services/Database/IJournalDatabaseService.cs similarity index 51% rename from Journal.Core/Services/IJournalDatabaseService.cs rename to Journal.Core/Services/Database/IJournalDatabaseService.cs index 54b86bc..8d240fd 100644 --- a/Journal.Core/Services/IJournalDatabaseService.cs +++ b/Journal.Core/Services/Database/IJournalDatabaseService.cs @@ -1,4 +1,7 @@ -namespace Journal.Core.Services; +using Journal.Core.Dtos; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services.Database; public interface IJournalDatabaseService { @@ -6,24 +9,9 @@ public interface IJournalDatabaseService byte[] DeriveDatabaseKey(string password); string BuildPragmaKeyStatement(string password); IReadOnlyDictionary GetSchemaStatements(); + SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null); + void EnsureSchema(SqliteConnection connection); string WriteSchemaBootstrap(string? dataDirectory = null); JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null); JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null); } - -public sealed record JournalDatabaseStatus( - string DatabasePath, - int KeyLengthBytes, - int Iterations, - string KeyDerivation, - IReadOnlyList SchemaTables, - string SchemaBootstrapPath, - bool RuntimeReady, - string RuntimeMessage); - -public sealed record JournalDatabaseHydrationResult( - string DatabasePath, - string SchemaBootstrapPath, - int EntryFilesProcessed, - bool RuntimeReady, - string Message); diff --git a/Journal.Core/Services/JournalDatabaseService.cs b/Journal.Core/Services/Database/JournalDatabaseService.cs similarity index 92% rename from Journal.Core/Services/JournalDatabaseService.cs rename to Journal.Core/Services/Database/JournalDatabaseService.cs index 73c0657..301dcac 100644 --- a/Journal.Core/Services/JournalDatabaseService.cs +++ b/Journal.Core/Services/Database/JournalDatabaseService.cs @@ -1,25 +1,22 @@ using System.Security.Cryptography; using System.Text; +using Journal.Core.Dtos; +using Journal.Core.Services.Config; using Microsoft.Data.Sqlite; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Database; -public sealed class JournalDatabaseService : IJournalDatabaseService +public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService { public const int KeySize = 32; public const int Iterations = 600_000; private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv"); - private static readonly object SqliteInitLock = new(); + private static readonly Lock SqliteInitLock = new(); private static bool _sqliteInitialized; private static readonly IReadOnlyList RequiredSchemaTables = ["entries", "sections", "fragments", "tags", "fragment_tags"]; - private readonly IJournalConfigService _config; - - public JournalDatabaseService(IJournalConfigService config) - { - _config = config; - } + private readonly IJournalConfigService _config = config; public string GetDatabasePath(string? dataDirectory = null) { @@ -72,7 +69,8 @@ public sealed class JournalDatabaseService : IJournalDatabaseService ["fragments"] = """ CREATE TABLE IF NOT EXISTS fragments ( id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_id INTEGER NOT NULL, + guid TEXT UNIQUE, + entry_id INTEGER, type TEXT NOT NULL, description TEXT, time TEXT, @@ -137,7 +135,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService Directory.CreateDirectory(directory); using var connection = OpenEncryptedConnection(password, directory); - CreateSchema(connection); + EnsureSchema(connection); var runtimeReady = HasRequiredTables(connection); var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length; @@ -168,7 +166,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService } } - private SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null) + public SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null) { if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Password cannot be empty.", nameof(password)); @@ -189,7 +187,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService return connection; } - private void CreateSchema(SqliteConnection connection) + public void EnsureSchema(SqliteConnection connection) { foreach (var statement in GetSchemaStatements().Values) { @@ -219,7 +217,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService try { using var connection = OpenEncryptedConnection(password, dataDirectory); - CreateSchema(connection); + EnsureSchema(connection); var ready = HasRequiredTables(connection); return ready ? (true, "SQLCipher runtime is available and schema tables are present.") diff --git a/Journal.Core/Services/Entries/EntryFileService.cs b/Journal.Core/Services/Entries/EntryFileService.cs new file mode 100644 index 0000000..124f968 --- /dev/null +++ b/Journal.Core/Services/Entries/EntryFileService.cs @@ -0,0 +1,80 @@ +using Journal.Core.Dtos; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Entries; + +public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService +{ + private readonly IEntryFileRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + public IReadOnlyList ListEntries(string dataDirectory) + { + return [.. _repo.ListMarkdownFiles(dataDirectory) + .Select(path => new EntryListItem( + FileName: _repo.GetFileName(path), + FilePath: _repo.GetFullPath(path)))]; + } + + public EntryLoadResult LoadEntry(string filePath) + { + var normalizedPath = _repo.GetFullPath(filePath); + if (!_repo.FileExists(normalizedPath)) + throw new FileNotFoundException($"Entry file not found: {normalizedPath}"); + + var rawContent = HtmlSanitizer.StripRichHtml(_repo.ReadFile(normalizedPath)); + var fileStem = _repo.GetFileNameWithoutExtension(normalizedPath); + var entry = JournalParser.ParseJournalContent(rawContent, fileStem); + + return new EntryLoadResult( + Date: entry.Date, + FileName: _repo.GetFileName(normalizedPath), + FilePath: normalizedPath, + RawContent: entry.RawContent); + } + + public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory) + { + var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory); + var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim(); + var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? ""); + _repo.EnsureDirectory(targetPath); + + if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase)) + { + _repo.WriteFile(targetPath, sanitizedContent); + return new EntrySaveResult(targetPath); + } + + if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase)) + { + _repo.AppendFile(targetPath, "\n\n" + sanitizedContent.Trim()); + return new EntrySaveResult(targetPath); + } + + string finalContent; + if (_repo.FileExists(targetPath)) + { + var existingContent = _repo.ReadFile(targetPath); + var fileStem = _repo.GetFileNameWithoutExtension(targetPath); + var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem); + var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem); + existingEntry.MergeWith(newEntryData); + finalContent = existingEntry.ToMarkdown(); + } + else + { + finalContent = sanitizedContent; + } + + _repo.WriteFile(targetPath, finalContent); + return new EntrySaveResult(targetPath); + } + + private string ResolveTargetPath(string? filePath, string defaultDataDirectory) + { + if (!string.IsNullOrWhiteSpace(filePath)) + return _repo.GetFullPath(filePath); + + return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md")); + } +} diff --git a/Journal.Core/Services/EntrySearchService.cs b/Journal.Core/Services/Entries/EntrySearchService.cs similarity index 99% rename from Journal.Core/Services/EntrySearchService.cs rename to Journal.Core/Services/Entries/EntrySearchService.cs index e2ff6dc..3194015 100644 --- a/Journal.Core/Services/EntrySearchService.cs +++ b/Journal.Core/Services/Entries/EntrySearchService.cs @@ -1,7 +1,7 @@ using Journal.Core.Dtos; using System.Globalization; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Entries; public class EntrySearchService : IEntrySearchService { diff --git a/Journal.Core/Services/Entries/HtmlSanitizer.cs b/Journal.Core/Services/Entries/HtmlSanitizer.cs new file mode 100644 index 0000000..163c538 --- /dev/null +++ b/Journal.Core/Services/Entries/HtmlSanitizer.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace Journal.Core.Services.Entries; + +public static class HtmlSanitizer +{ + public static string StripRichHtml(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return content; + if (!LooksLikeRichHtml(content)) + return content; + + var text = content.Replace("\r\n", "\n").Replace("\r", "\n"); + text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); + text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "]*>", "\n- ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", " ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "]*>", "\n---\n", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline); + text = WebUtility.HtmlDecode(text) + .Replace('\u00a0', ' ') + .Replace("\u200b", "", StringComparison.Ordinal); + text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd())); + text = Regex.Replace(text, "[ \\t]{2,}", " "); + text = Regex.Replace(text, "\n{3,}", "\n\n").Trim(); + return string.IsNullOrEmpty(text) ? content : text; + } + + public static bool LooksLikeRichHtml(string content) + { + var lowered = content.ToLowerInvariant(); + string[] markers = + [ + "", " lowered.Contains(marker, StringComparison.Ordinal))) + return true; + return Regex.Matches(lowered, "]*>").Count >= 8; + } +} diff --git a/Journal.Core/Services/Entries/IEntryFileService.cs b/Journal.Core/Services/Entries/IEntryFileService.cs new file mode 100644 index 0000000..107d85a --- /dev/null +++ b/Journal.Core/Services/Entries/IEntryFileService.cs @@ -0,0 +1,10 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Entries; + +public interface IEntryFileService +{ + IReadOnlyList ListEntries(string dataDirectory); + EntryLoadResult LoadEntry(string filePath); + EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory); +} diff --git a/Journal.Core/Services/IEntrySearchService.cs b/Journal.Core/Services/Entries/IEntrySearchService.cs similarity index 80% rename from Journal.Core/Services/IEntrySearchService.cs rename to Journal.Core/Services/Entries/IEntrySearchService.cs index e9bfede..6a2c24a 100644 --- a/Journal.Core/Services/IEntrySearchService.cs +++ b/Journal.Core/Services/Entries/IEntrySearchService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Entries; public interface IEntrySearchService { diff --git a/Journal.Core/Services/JournalParser.cs b/Journal.Core/Services/Entries/JournalParser.cs similarity index 99% rename from Journal.Core/Services/JournalParser.cs rename to Journal.Core/Services/Entries/JournalParser.cs index ff00634..7f74811 100644 --- a/Journal.Core/Services/JournalParser.cs +++ b/Journal.Core/Services/Entries/JournalParser.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; using Journal.Core.Models; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Entries; public static partial class JournalParser { diff --git a/Journal.Core/Services/FragmentService.cs b/Journal.Core/Services/Fragments/FragmentService.cs similarity index 55% rename from Journal.Core/Services/FragmentService.cs rename to Journal.Core/Services/Fragments/FragmentService.cs index fef24b7..55917fa 100644 --- a/Journal.Core/Services/FragmentService.cs +++ b/Journal.Core/Services/Fragments/FragmentService.cs @@ -3,13 +3,11 @@ using Journal.Core.Dtos; using Journal.Core.Models; using Journal.Core.Repositories; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Fragments; -public class FragmentService : IFragmentService +public class FragmentService(IFragmentRepository repo) : IFragmentService { - private readonly IFragmentRepository _repo; - - public FragmentService(IFragmentRepository repo) => _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + private readonly IFragmentRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); private static FragmentDto Map(Fragment f) => new( f.Id, @@ -19,7 +17,7 @@ public class FragmentService : IFragmentService f.Tags != null ? [.. f.Tags] : [] ); - public async Task CreateAsync(CreateFragmentDto dto) + public FragmentDto Create(CreateFragmentDto dto) { ArgumentNullException.ThrowIfNull(dto); @@ -30,11 +28,11 @@ public class FragmentService : IFragmentService if (dto.Tags != null) f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())]; - await _repo.AddAsync(f); + _repo.Add(f); return Map(f); } - public async Task UpdateAsync(Guid id, UpdateFragmentDto dto) + public bool Update(Guid id, UpdateFragmentDto dto) { ArgumentNullException.ThrowIfNull(dto); if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type)) @@ -46,38 +44,38 @@ public class FragmentService : IFragmentService var description = dto.Description?.Trim(); var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList(); - return await _repo.UpdateAsync(id, type, description, tags, dto.Time); + return _repo.Update(id, type, description, tags, dto.Time); } - public Task RemoveAsync(Guid id) => _repo.RemoveAsync(id); + public bool Remove(Guid id) => _repo.Remove(id); - public async Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + public List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) { - var items = await _repo.SearchAsync(type, tag, timeAfter); + var items = _repo.Search(type, tag, timeAfter); return [.. items.Select(Map)]; } - public async Task> GetByTagAsync(string tag) + public List GetByTag(string tag) { - var items = await _repo.GetByTagAsync(tag); + var items = _repo.GetByTag(tag); return [.. items.Select(Map)]; } - public async Task> GetByTypeAsync(string type) + public List GetByType(string type) { - var items = await _repo.GetByTypeAsync(type); + var items = _repo.GetByType(type); return [.. items.Select(Map)]; } - public async Task> GetAllAsync() + public List GetAll() { - var items = await _repo.GetAllAsync(); + var items = _repo.GetAll(); return [.. items.Select(Map)]; } - public async Task GetByIdAsync(Guid id) + public FragmentDto? GetById(Guid id) { - var f = await _repo.GetByIdAsync(id); + var f = _repo.GetById(id); return f is null ? null : Map(f); } } diff --git a/Journal.Core/Services/Fragments/IFragmentService.cs b/Journal.Core/Services/Fragments/IFragmentService.cs new file mode 100644 index 0000000..6087081 --- /dev/null +++ b/Journal.Core/Services/Fragments/IFragmentService.cs @@ -0,0 +1,15 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Fragments; + +public interface IFragmentService +{ + FragmentDto Create(CreateFragmentDto dto); + bool Update(Guid id, UpdateFragmentDto dto); + bool Remove(Guid id); + List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); + List GetByTag(string tag); + List GetByType(string type); + List GetAll(); + FragmentDto? GetById(Guid id); +} diff --git a/Journal.Core/Services/IFragmentService.cs b/Journal.Core/Services/IFragmentService.cs deleted file mode 100644 index 2bde778..0000000 --- a/Journal.Core/Services/IFragmentService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Journal.Core.Dtos; - -namespace Journal.Core.Services; - -public interface IFragmentService -{ - Task CreateAsync(CreateFragmentDto dto); - Task UpdateAsync(Guid id, UpdateFragmentDto dto); - Task RemoveAsync(Guid id); - Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); - Task> GetByTagAsync(string tag); - Task> GetByTypeAsync(string type); - Task> GetAllAsync(); - Task GetByIdAsync(Guid id); -} diff --git a/Journal.Core/Services/Logging/CommandLogger.cs b/Journal.Core/Services/Logging/CommandLogger.cs new file mode 100644 index 0000000..018dd5f --- /dev/null +++ b/Journal.Core/Services/Logging/CommandLogger.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text.Json; + +namespace Journal.Core.Services.Logging; + +public sealed class CommandLogger +{ + public static void LogStart(string action, string correlationId, JsonElement? payload) + { + var redactedPayload = LogRedactor.RedactPayload(payload); + EmitLog("information", action, correlationId, "start", redactedPayload); + } + + public static void LogSuccess(string action, string correlationId) => EmitLog("information", action, correlationId, "success"); + + public static void LogFailure(string action, string correlationId, string errorType, string? message = null) + { + var details = string.IsNullOrWhiteSpace(message) + ? "" + : (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)"); + EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details); + } + + private static void EmitLog( + string level, + string action, + string correlationId, + string outcome, + object? payload = null, + string? errorType = null, + string? details = null) + { + if (!ShouldLog(level)) + return; + + var envelope = new + { + timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), + level, + component = "Entry", + action, + correlation_id = correlationId, + outcome, + error_type = errorType, + details, + payload + }; + Console.Error.WriteLine(JsonSerializer.Serialize(envelope)); + } + + private static bool ShouldLog(string level) + { + var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning") + .Trim() + .ToLowerInvariant(); + var configuredRank = LogLevelRank(configured); + var incomingRank = LogLevelRank(level); + return incomingRank >= configuredRank; + } + + private static int LogLevelRank(string level) => level switch + { + "trace" => 0, + "debug" => 1, + "information" => 2, + "info" => 2, + "warning" => 3, + "warn" => 3, + "error" => 4, + "critical" => 5, + _ => 3 + }; +} diff --git a/Journal.Core/Services/LogRedactor.cs b/Journal.Core/Services/Logging/LogRedactor.cs similarity index 98% rename from Journal.Core/Services/LogRedactor.cs rename to Journal.Core/Services/Logging/LogRedactor.cs index 4554174..b116bb5 100644 --- a/Journal.Core/Services/LogRedactor.cs +++ b/Journal.Core/Services/Logging/LogRedactor.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Logging; public static class LogRedactor { diff --git a/Journal.Core/Services/PythonSidecarAiService.cs b/Journal.Core/Services/PythonSidecarAiService.cs deleted file mode 100644 index 767b82a..0000000 --- a/Journal.Core/Services/PythonSidecarAiService.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using Journal.Core.Dtos; -using Journal.Core.Models; - -namespace Journal.Core.Services; - -public sealed class PythonSidecarAiService : IAiService -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - private readonly JournalConfig _config; - - public PythonSidecarAiService(JournalConfig config) - { - _config = config; - if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath)) - throw new ArgumentException("Python AI sidecar path is required."); - if (!File.Exists(_config.PythonAiSidecarPath)) - throw new FileNotFoundException($"Python AI sidecar not found: {_config.PythonAiSidecarPath}"); - } - - public async Task HealthAsync(CancellationToken cancellationToken = default) - { - var data = await SendAsync("health", payload: new { }, cancellationToken); - if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object) - return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok"); - - var provider = payload.TryGetProperty("provider", out var providerNode) - ? providerNode.GetString() ?? "python-sidecar" - : "python-sidecar"; - var message = payload.TryGetProperty("message", out var messageNode) - ? messageNode.GetString() ?? "ok" - : "ok"; - var healthy = !payload.TryGetProperty("healthy", out var healthyNode) || - healthyNode.ValueKind is JsonValueKind.True || - (healthyNode.ValueKind is JsonValueKind.False ? false : true); - return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message); - } - - public async Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(content)) - throw new ArgumentException("Entry content is required.", nameof(content)); - - var data = await SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken); - return data?.GetString() ?? ""; - } - - public async Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default) - { - entries ??= []; - var data = await SendAsync("summarize_all", new { entries }, cancellationToken); - return data?.GetString() ?? ""; - } - - public async Task ChatAsync(string prompt, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(prompt)) - throw new ArgumentException("Prompt is required.", nameof(prompt)); - - var data = await SendAsync("chat", new { prompt }, cancellationToken); - return data?.GetString() ?? ""; - } - - public async Task> EmbedAsync(string content, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(content)) - throw new ArgumentException("Content is required.", nameof(content)); - - var data = await SendAsync("embed", new { content }, cancellationToken); - if (data is null || data.Value.ValueKind == JsonValueKind.Null) - return []; - - if (data.Value.ValueKind != JsonValueKind.Array) - throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array."); - - var values = new List(); - foreach (var item in data.Value.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.Number) - throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value."); - values.Add(item.GetDouble()); - } - - return values; - } - - private async Task SendAsync(string action, object payload, CancellationToken cancellationToken) - { - var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions); - - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = _config.PythonExecutable, - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - WorkingDirectory = _config.ProjectRoot - }; - process.StartInfo.ArgumentList.Add(_config.PythonAiSidecarPath); - - if (!process.Start()) - throw new InvalidOperationException("Failed to start Python AI sidecar process."); - - await process.StandardInput.WriteLineAsync(request); - process.StandardInput.Close(); - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_config.AiSidecarTimeoutMs); - - try - { - await process.WaitForExitAsync(timeoutCts.Token); - } - catch (OperationCanceledException) - { - TryKill(process); - throw new TimeoutException($"Python AI sidecar timed out after {_config.AiSidecarTimeoutMs} ms."); - } - - var stdout = await process.StandardOutput.ReadToEndAsync(); - var stderr = await process.StandardError.ReadToEndAsync(); - var line = LastJsonLine(stdout); - if (string.IsNullOrWhiteSpace(line)) - throw new InvalidOperationException($"Python AI sidecar returned no JSON response. stderr: {stderr}".Trim()); - - JsonDocument doc; - try - { - doc = JsonDocument.Parse(line); - } - catch (JsonException ex) - { - throw new InvalidOperationException($"Invalid JSON from Python AI sidecar: {line}", ex); - } - using (doc) - { - var root = doc.RootElement; - if (!root.TryGetProperty("ok", out var okNode) || okNode.ValueKind != JsonValueKind.True && okNode.ValueKind != JsonValueKind.False) - throw new InvalidOperationException("Python AI sidecar response missing boolean 'ok' field."); - - if (!okNode.GetBoolean()) - { - var error = root.TryGetProperty("error", out var errorNode) - ? errorNode.GetString() ?? "Unknown sidecar error." - : "Unknown sidecar error."; - throw new InvalidOperationException(error); - } - - if (!root.TryGetProperty("data", out var dataNode)) - return null; - - return dataNode.Clone(); - } - } - - private static string LastJsonLine(string text) - { - var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - for (var i = lines.Length - 1; i >= 0; i--) - { - var line = lines[i].Trim(); - if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) - return line; - } - - return ""; - } - - private static void TryKill(Process process) - { - try - { - if (!process.HasExited) - process.Kill(entireProcessTree: true); - } - catch - { - // Ignore cleanup errors while handling timeout/failure path. - } - } -} diff --git a/Journal.Core/Services/PythonSidecarSpeechService.cs b/Journal.Core/Services/Sidecar/PythonSidecarClient.cs similarity index 50% rename from Journal.Core/Services/PythonSidecarSpeechService.cs rename to Journal.Core/Services/Sidecar/PythonSidecarClient.cs index 582d1cf..01284bd 100644 --- a/Journal.Core/Services/PythonSidecarSpeechService.cs +++ b/Journal.Core/Services/Sidecar/PythonSidecarClient.cs @@ -1,90 +1,19 @@ using System.Diagnostics; using System.Text.Json; -using Journal.Core.Dtos; using Journal.Core.Models; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Sidecar; -public sealed class PythonSidecarSpeechService : ISpeechBridgeService +public sealed class PythonSidecarClient(JournalConfig config) { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - private readonly JournalConfig _config; + private readonly JournalConfig _config = config; - public PythonSidecarSpeechService(JournalConfig config) - { - _config = config; - if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath)) - throw new ArgumentException("Python sidecar path is required."); - if (!File.Exists(_config.PythonAiSidecarPath)) - throw new FileNotFoundException($"Python sidecar not found: {_config.PythonAiSidecarPath}"); - } - - public async Task ListDevicesAsync(CancellationToken cancellationToken = default) - { - var data = await SendAsync("speech.devices.list", new { }, cancellationToken); - if (data is null || data.Value.ValueKind != JsonValueKind.Object) - return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar."); - - var warning = data.Value.TryGetProperty("warning", out var warningNode) - ? warningNode.GetString() - : null; - - var devices = new List(); - if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array) - { - foreach (var device in devicesNode.EnumerateArray()) - { - if (device.ValueKind != JsonValueKind.Object) - continue; - - var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number - ? indexNode.GetInt32() - : -1; - var name = device.TryGetProperty("name", out var nameNode) - ? nameNode.GetString() ?? "" - : ""; - devices.Add(new SpeechDeviceDto(index, name)); - } - } - - return new SpeechDevicesResultDto(devices, warning); - } - - public async Task TranscribeAsync( - SpeechTranscribeRequestDto request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - var data = await SendAsync("speech.transcribe", new - { - audio_base64 = request.AudioBase64, - engine = request.Engine, - whisper_model = request.WhisperModel, - text = request.Text, - simulate_delay_ms = request.SimulateDelayMs - }, cancellationToken); - - if (data is null || data.Value.ValueKind != JsonValueKind.Object) - throw new InvalidOperationException("Python sidecar speech response must be a JSON object."); - - var text = data.Value.TryGetProperty("text", out var textNode) - ? textNode.GetString() ?? "" - : ""; - var engine = data.Value.TryGetProperty("engine", out var engineNode) - ? engineNode.GetString() ?? (request.Engine ?? "whisper") - : (request.Engine ?? "whisper"); - var warning = data.Value.TryGetProperty("warning", out var warningNode) - ? warningNode.GetString() - : null; - return new SpeechTranscribeResultDto(text, engine, warning); - } - - private async Task SendAsync(string action, object payload, CancellationToken cancellationToken) + public async Task SendAsync(string action, object payload, CancellationToken cancellationToken) { var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions); @@ -166,6 +95,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) return line; } + return ""; } @@ -178,7 +108,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService } catch { - // Ignore timeout cleanup failures. + // Ignore cleanup errors while handling timeout/failure path. } } } diff --git a/Journal.Core/Services/SidecarCli.cs b/Journal.Core/Services/Sidecar/SidecarCli.cs similarity index 94% rename from Journal.Core/Services/SidecarCli.cs rename to Journal.Core/Services/Sidecar/SidecarCli.cs index 86cf444..8c8057b 100644 --- a/Journal.Core/Services/SidecarCli.cs +++ b/Journal.Core/Services/Sidecar/SidecarCli.cs @@ -1,20 +1,16 @@ using System.Text; using Journal.Core.Dtos; +using Journal.Core.Services.Config; +using Journal.Core.Services.Entries; +using Journal.Core.Services.Vault; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Sidecar; -public sealed class SidecarCli +public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config) { - private readonly IVaultStorageService _vaultStorage; - private readonly IEntrySearchService _entrySearch; - private readonly IJournalConfigService _config; - - public SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config) - { - _vaultStorage = vaultStorage; - _entrySearch = entrySearch; - _config = config; - } + private readonly IVaultStorageService _vaultStorage = vaultStorage; + private readonly IEntrySearchService _entrySearch = entrySearch; + private readonly IJournalConfigService _config = config; public async Task RunAsync(string[] args, Entry entry) { @@ -34,9 +30,9 @@ public sealed class SidecarCli } if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase)) - return RunVaultCommand(args.Skip(1).ToArray()); + return RunVaultCommand([.. args.Skip(1)]); if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase)) - return RunSearchCommand(args.Skip(1).ToArray()); + return RunSearchCommand([.. args.Skip(1)]); Console.Error.WriteLine($"Unknown command: {args[0]}"); PrintUsage(); @@ -60,7 +56,7 @@ public sealed class SidecarCli return 2; } - if (!TryParseVaultOptions(args.Skip(1).ToArray(), out var options, out var parseError)) + if (!TryParseVaultOptions([.. args.Skip(1)], out var options, out var parseError)) { Console.Error.WriteLine(parseError); PrintVaultUsage(); diff --git a/Journal.Core/Services/DisabledSpeechBridgeService.cs b/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs similarity index 61% rename from Journal.Core/Services/DisabledSpeechBridgeService.cs rename to Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs index 9f9a658..027ddc1 100644 --- a/Journal.Core/Services/DisabledSpeechBridgeService.cs +++ b/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs @@ -1,17 +1,11 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Speech; -public sealed class DisabledSpeechBridgeService : ISpeechBridgeService +public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService { - private readonly string _provider; - private readonly string _message; - - public DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") - { - _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); - _message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim(); - } + private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); + private readonly string _message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim(); public Task ListDevicesAsync(CancellationToken cancellationToken = default) { diff --git a/Journal.Core/Services/ISpeechBridgeService.cs b/Journal.Core/Services/Speech/ISpeechBridgeService.cs similarity index 88% rename from Journal.Core/Services/ISpeechBridgeService.cs rename to Journal.Core/Services/Speech/ISpeechBridgeService.cs index 0294722..574ba78 100644 --- a/Journal.Core/Services/ISpeechBridgeService.cs +++ b/Journal.Core/Services/Speech/ISpeechBridgeService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Speech; public interface ISpeechBridgeService { diff --git a/Journal.Core/Services/Speech/PythonSidecarSpeechService.cs b/Journal.Core/Services/Speech/PythonSidecarSpeechService.cs new file mode 100644 index 0000000..7936f0d --- /dev/null +++ b/Journal.Core/Services/Speech/PythonSidecarSpeechService.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Services.Sidecar; + +namespace Journal.Core.Services.Speech; + +public sealed class PythonSidecarSpeechService : ISpeechBridgeService +{ + private readonly PythonSidecarClient _client; + + public PythonSidecarSpeechService(JournalConfig config) + { + if (string.IsNullOrWhiteSpace(config.PythonAiSidecarPath)) + throw new ArgumentException("Python sidecar path is required."); + if (!File.Exists(config.PythonAiSidecarPath)) + throw new FileNotFoundException($"Python sidecar not found: {config.PythonAiSidecarPath}"); + _client = new PythonSidecarClient(config); + } + + public async Task ListDevicesAsync(CancellationToken cancellationToken = default) + { + var data = await _client.SendAsync("speech.devices.list", new { }, cancellationToken); + if (data is null || data.Value.ValueKind != JsonValueKind.Object) + return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar."); + + var warning = data.Value.TryGetProperty("warning", out var warningNode) + ? warningNode.GetString() + : null; + + var devices = new List(); + if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array) + { + foreach (var device in devicesNode.EnumerateArray()) + { + if (device.ValueKind != JsonValueKind.Object) + continue; + + var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number + ? indexNode.GetInt32() + : -1; + var name = device.TryGetProperty("name", out var nameNode) + ? nameNode.GetString() ?? "" + : ""; + devices.Add(new SpeechDeviceDto(index, name)); + } + } + + return new SpeechDevicesResultDto(devices, warning); + } + + public async Task TranscribeAsync( + SpeechTranscribeRequestDto request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var data = await _client.SendAsync("speech.transcribe", new + { + audio_base64 = request.AudioBase64, + engine = request.Engine, + whisper_model = request.WhisperModel, + text = request.Text, + simulate_delay_ms = request.SimulateDelayMs + }, cancellationToken); + + if (data is null || data.Value.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("Python sidecar speech response must be a JSON object."); + + var text = data.Value.TryGetProperty("text", out var textNode) + ? textNode.GetString() ?? "" + : ""; + var engine = data.Value.TryGetProperty("engine", out var engineNode) + ? engineNode.GetString() ?? (request.Engine ?? "whisper") + : (request.Engine ?? "whisper"); + var warning = data.Value.TryGetProperty("warning", out var warningNode) + ? warningNode.GetString() + : null; + return new SpeechTranscribeResultDto(text, engine, warning); + } +} diff --git a/Journal.Core/Services/IVaultCryptoService.cs b/Journal.Core/Services/Vault/IVaultCryptoService.cs similarity index 84% rename from Journal.Core/Services/IVaultCryptoService.cs rename to Journal.Core/Services/Vault/IVaultCryptoService.cs index 85418e5..af9e388 100644 --- a/Journal.Core/Services/IVaultCryptoService.cs +++ b/Journal.Core/Services/Vault/IVaultCryptoService.cs @@ -1,4 +1,4 @@ -namespace Journal.Core.Services; +namespace Journal.Core.Services.Vault; public interface IVaultCryptoService { diff --git a/Journal.Core/Services/IVaultStorageService.cs b/Journal.Core/Services/Vault/IVaultStorageService.cs similarity index 91% rename from Journal.Core/Services/IVaultStorageService.cs rename to Journal.Core/Services/Vault/IVaultStorageService.cs index 525c1f3..3a76b20 100644 --- a/Journal.Core/Services/IVaultStorageService.cs +++ b/Journal.Core/Services/Vault/IVaultStorageService.cs @@ -1,4 +1,4 @@ -namespace Journal.Core.Services; +namespace Journal.Core.Services.Vault; public interface IVaultStorageService { diff --git a/Journal.Core/Services/VaultCryptoService.cs b/Journal.Core/Services/Vault/VaultCryptoService.cs similarity index 98% rename from Journal.Core/Services/VaultCryptoService.cs rename to Journal.Core/Services/Vault/VaultCryptoService.cs index e6c7df7..8ef4e96 100644 --- a/Journal.Core/Services/VaultCryptoService.cs +++ b/Journal.Core/Services/Vault/VaultCryptoService.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Vault; public class VaultCryptoService : IVaultCryptoService { diff --git a/Journal.Core/Services/VaultStorageService.cs b/Journal.Core/Services/Vault/VaultStorageService.cs similarity index 97% rename from Journal.Core/Services/VaultStorageService.cs rename to Journal.Core/Services/Vault/VaultStorageService.cs index 2ccf253..f2dd18f 100644 --- a/Journal.Core/Services/VaultStorageService.cs +++ b/Journal.Core/Services/Vault/VaultStorageService.cs @@ -2,18 +2,15 @@ using System.IO.Compression; using System.Globalization; using System.Security.Cryptography; using System.Text; -using System.Threading; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Vault; -public class VaultStorageService : IVaultStorageService +public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService { - private readonly IVaultCryptoService _crypto; + private readonly IVaultCryptoService _crypto = crypto; private readonly Dictionary _monthFingerprintCache = new(StringComparer.Ordinal); private readonly object _vaultIoLock = new(); - public VaultStorageService(IVaultCryptoService crypto) => _crypto = crypto; - public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault"; public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory) diff --git a/Journal.Sidecar/App.cs b/Journal.Sidecar/App.cs index cb0c2f1..e3246b5 100644 --- a/Journal.Sidecar/App.cs +++ b/Journal.Sidecar/App.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Journal.Core; -using Journal.Core.Services; +using Journal.Core.Services.Sidecar; var services = new ServiceCollection(); services.AddFragmentServices(); diff --git a/Journal.SmokeTests/Program.cs b/Journal.SmokeTests/Program.cs index 55eeddf..2360538 100644 --- a/Journal.SmokeTests/Program.cs +++ b/Journal.SmokeTests/Program.cs @@ -6,7 +6,15 @@ using Journal.Core; using Journal.Core.Dtos; using Journal.Core.Models; using Journal.Core.Repositories; -using Journal.Core.Services; +using Journal.Core.Services.Ai; +using Journal.Core.Services.Config; +using Journal.Core.Services.Database; +using Journal.Core.Services.Entries; +using Journal.Core.Services.Fragments; +using Journal.Core.Services.Logging; +using Journal.Core.Services.Speech; +using Journal.Core.Services.Sidecar; +using Journal.Core.Services.Vault; var tests = new List<(string Name, Func Run)> { @@ -106,73 +114,95 @@ static FragmentService NewService() return new FragmentService(repo); } -static Entry NewEntry() => new( - NewService(), - new EntrySearchService(), - new VaultStorageService(new VaultCryptoService()), - new JournalDatabaseService(new JournalConfigService()), - new JournalConfigService(), - new DisabledAiService("none"), - new DisabledSpeechBridgeService("none")); +static Entry NewEntry() +{ + var dbService = new JournalDatabaseService(new JournalConfigService()); + var session = new DatabaseSessionService(dbService); + return new Entry( + NewService(), + new EntrySearchService(), + new VaultStorageService(new VaultCryptoService()), + dbService, + session, + new JournalConfigService(), + new DisabledAiService("none"), + new DisabledSpeechBridgeService("none"), + new EntryFileService(new DiskEntryFileRepository()), + new CommandLogger()); +} static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService()); -static async Task TestCreateTrimsAsync() +static Task TestCreateTrimsAsync() { var service = NewService(); - var created = await service.CreateAsync(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "])); + var created = service.Create(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "])); Assert(created.Type == "!TRIGGER", "Type should be trimmed."); Assert(created.Description == "stomach drop", "Description should be trimmed."); Assert(created.Tags.Count == 2, "Expected two normalized tags."); Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved."); + return Task.CompletedTask; } -static async Task TestUpdateAcceptsTypeAsync() +static Task TestUpdateAcceptsTypeAsync() { var service = NewService(); - var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "one")); - var ok = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); + var created = service.Create(new CreateFragmentDto("!TRIGGER", "one")); + var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); Assert(ok, "Expected update to succeed."); - var updated = await service.GetByIdAsync(created.Id); + var updated = service.GetById(created.Id); Assert(updated is not null, "Updated fragment should exist."); Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored."); Assert(updated.Description == "two", "Updated description should be trimmed and stored."); Assert(updated.Tags.Count == 1 && updated.Tags[0] == "memory", "Updated tags should be normalized."); + return Task.CompletedTask; } -static async Task TestUpdateRejectsWhitespaceTypeAsync() +static Task TestUpdateRejectsWhitespaceTypeAsync() { var service = NewService(); - var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "desc")); + var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc")); try { - _ = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " ")); + _ = service.Update(created.Id, new UpdateFragmentDto(Type: " ")); } catch (ValidationException) { - return; + return Task.CompletedTask; } throw new InvalidOperationException("Expected ValidationException for whitespace type update."); } -static async Task TestFileRepositoryPersistsAsync() +static Task TestFileRepositoryPersistsAsync() { var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")); - var storePath = Path.Combine(tempRoot, "fragments.json"); + var dataDir = Path.Combine(tempRoot, "data"); + Directory.CreateDirectory(dataDir); + const string password = "smoke-test-password"; try { - IFragmentRepository repo1 = new FileFragmentRepository(storePath); - var service1 = new FragmentService(repo1); - var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); + // Set up encrypted DB session + var configService = new JournalConfigService(); + var dbService = new JournalDatabaseService(configService); - IFragmentRepository repo2 = new FileFragmentRepository(storePath); + // First session: create a fragment + using var session1 = new DatabaseSessionService(dbService); + session1.SetPassword(password, dataDir); + var repo1 = new SqliteFragmentRepository(session1); + var service1 = new FragmentService(repo1); + var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); + + // Second session: verify persistence + using var session2 = new DatabaseSessionService(dbService); + session2.SetPassword(password, dataDir); + var repo2 = new SqliteFragmentRepository(session2); var service2 = new FragmentService(repo2); - var loaded = await service2.GetByIdAsync(created.Id); + var loaded = service2.GetById(created.Id); Assert(loaded is not null, "Expected fragment to persist across repository instances."); Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch."); @@ -183,6 +213,8 @@ static async Task TestFileRepositoryPersistsAsync() if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, recursive: true); } + + return Task.CompletedTask; } static Task TestJournalEntryModelAsync() diff --git a/Journal.slnx b/Journal.slnx index a5a68e3..3e7d483 100644 --- a/Journal.slnx +++ b/Journal.slnx @@ -2,4 +2,5 @@ + diff --git a/README.md b/README.md index b07d408..6330ffb 100644 --- a/README.md +++ b/README.md @@ -1,264 +1,192 @@ -# Journal Backend (.NET) +# Project_Journal -A .NET 10 backend for the Project Journal app. Provides core journal functionality as a class library with a sidecar console app for Tauri integration and an optional HTTP API. +A structured journaling system with encrypted monthly vaults, desktop UI, CLI tools, and optional AI-assisted analysis. -## Project Structure +## Support Matrix -``` -backend/ -├── Journal.Core/ Class library — all business logic -│ ├── Models/ -│ │ ├── Fragment.cs Domain model (validated, owns Guid ID) -│ │ ├── Command.cs Stdin command shape for sidecar protocol -│ │ ├── ParsedSection.cs Parsed section model for entry parity work -│ │ ├── SectionTitles.cs Canonical section title list (Python parity) -│ │ └── JournalEntry.cs Entry domain (`date/raw_content/sections/fragments` + merge + markdown reconstruction) -│ ├── Dtos/ -│ │ └── FragmentDtos.cs Immutable records for API boundary -│ │ ├── FragmentDto Read (what goes out) -│ │ ├── CreateFragmentDto Create (what comes in) -│ │ └── UpdateFragmentDto Update (partial, all fields optional) -│ ├── Repositories/ -│ │ ├── IFragmentRepository.cs Interface (data access contract) -│ │ ├── InMemoryFragmentRepository.cs In-memory implementation (tests/dev) -│ │ └── FileFragmentRepository.cs File-backed implementation (default) -│ ├── Services/ -│ │ ├── IFragmentService.cs Interface (business logic contract) -│ │ ├── FragmentService.cs Validates, calls repo, maps to DTOs -│ │ ├── IEntrySearchService.cs Entry search contract (content parity) -│ │ ├── EntrySearchService.cs Searches decrypted `.md` entries by raw content query -│ │ ├── IJournalConfigService.cs Config contract for path/vault/AI/speech settings parity -│ │ ├── JournalConfigService.cs Env/default-backed config surface aligned with Python keys -│ │ ├── IAiService.cs AI bridge contract (optional provider) -│ │ ├── DisabledAiService.cs No-op AI provider for deterministic disabled mode -│ │ ├── PythonSidecarAiService.cs Local Python sidecar adapter (stdin/stdout JSON) -│ │ ├── SidecarCli.cs CLI runner (`vault` + `search`) used by Sidecar host -│ │ ├── JournalParser.cs Date + section + checkbox + fragment parser slices (Phase 2) -│ │ ├── IVaultCryptoService.cs Vault crypto contract -│ │ ├── VaultCryptoService.cs AES-256-GCM + PBKDF2 compatibility layer -│ │ ├── IVaultStorageService.cs Vault load/workflow contract -│ │ └── VaultStorageService.cs Monthly naming + load/decrypt/extract workflow -│ ├── Entry.cs Command dispatcher (stdin/stdout) -│ ├── ServiceCollectionExtensions.cs DI registration helper -│ └── Journal.Core.csproj -│ -├── Journal.Sidecar/ Console app — Tauri sidecar bridge -│ ├── App.cs Boots DI container, runs Entry.RunAsync() -│ └── Journal.Sidecar.csproj References Journal.Core -│ -├── Journal.Api/ Web API — HTTP endpoint wrapper (optional) -│ ├── Program.cs -│ └── Journal.Api.csproj -│ -└── README.md +- Python: `3.14` +- Platforms: Windows and Linux (first-class), macOS (best effort) +- Default profile: CPU +- Optional profiles: GPU, optional NLP backend + +## Dependency Profiles + +- `requirements_base.txt`: shared Journal runtime dependencies +- `requirements_cpu_only.txt`: base + CPU AI stack +- `requirements_gpu.txt`: base + GPU AI stack +- `requirements_nlp_optional.txt`: optional spaCy backend (auto-fallback if unavailable) + +## Quickstart + +### Linux (CPU default) + +```bash +cd Project_Journal +python3.14 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt ``` -## Architecture +### Linux (GPU optional) -Each layer only knows about the one below it: - -``` -Sidecar (stdin/stdout) ──┐ - ├──► Services (business logic) ──► Repositories (data access) -API (HTTP/JSON) ─────────┘ +```bash +cd Project_Journal +python3.14 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements_gpu.txt ``` -- **Models** — Domain objects with validation. The source of truth. -- **DTOs** — Immutable records that cross the API boundary. Internal logic never leaks out. -- **Repositories** — Where data lives. Current default is file-backed; can evolve to SQLite/EF Core without touching anything above. -- **Services** — Business rules, validation, orchestration. Doesn't know about HTTP or stdin. -- **Entry** — Transport adapter. Translates stdin/stdout JSON into service calls. - -## Dependencies - -- **Journal.Core** — `Microsoft.Extensions.DependencyInjection.Abstractions` (interface-only, lightweight) -- **Journal.Sidecar** — `Microsoft.Extensions.DependencyInjection` (full container implementation) + references `Journal.Core` -- **Journal.Api** — `Microsoft.AspNetCore.OpenApi` + ASP.NET shared framework - -## Building +### Windows PowerShell (CPU default) ```powershell -# Build everything (building Sidecar also rebuilds Core if changed) -dotnet build backend\Journal.Sidecar\Journal.Sidecar.csproj - -# Build just the library -dotnet build backend\Journal.Core\Journal.Core.csproj - -# Format code -dotnet format backend\Journal.Core\Journal.Core.csproj +cd Project_Journal +py -3.14 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt ``` -## Publishing +On Windows + Python 3.14, `pywebview` is intentionally skipped due upstream +`pythonnet` build compatibility. `run_desktop.py` will auto-fallback to opening +the app in your system browser. -Publish as a single-file self-contained executable (no .NET runtime install needed): +### Optional NLP backend (spaCy) + +```bash +python -m pip install -r requirements_nlp_optional.txt +python -m spacy download en_core_web_sm +``` + +If spaCy is missing or unsupported, Journal now auto-falls back to built-in NLP heuristics. +On current Python 3.14 environments, this optional install may be skipped due upstream spaCy compatibility. + +## Running + +### Desktop App + +```bash +python ./journal/run_desktop.py +``` + +### CLI + +```bash +python -m journal.cli.main --help +python -m journal.cli.main vault load +python -m journal.cli.main search "your query" +``` + +## NLP Backend Control + +Set `JOURNAL_NLP_BACKEND` to choose behavior: + +- `auto` (default): use spaCy when available, else fallback +- `spacy`: require spaCy backend and fail clearly if unavailable +- `fallback`: always use fallback heuristics + +Examples: + +```bash +export JOURNAL_NLP_BACKEND=fallback +python ./journal/run_desktop.py +``` ```powershell -dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true +$env:JOURNAL_NLP_BACKEND = "spacy" +python .\journal\run_desktop.py ``` -Output: `backend\Journal.Sidecar\bin\Release\net10.0\win-x64\publish\Journal.Sidecar.exe` (~70MB, everything bundled) +## Installer Script -To exclude debug symbols: add `-p:DebugType=none` +Use the Linux helper script: -For a smaller build that requires .NET 10 on the target machine: - -```powershell -dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 -p:PublishSingleFile=true +```bash +./installreqs.sh +./installreqs.sh --gpu +./installreqs.sh --with-nlp ``` -## Sidecar Protocol +## C# Backend -The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, one JSON line out. -When run with no command-line args, this protocol mode is used by default. +The `backend/` directory contains a .NET 10 implementation that provides the same journal functionality as the Python layer, with encrypted vault support and an identical JSON command protocol. -## Sidecar CLI +### Projects -`Journal.Sidecar` also supports direct vault and search CLI commands: +- **Journal.Core** — shared library: domain models, services, repositories, DTOs +- **Journal.Api** — minimal ASP.NET Core web API (`/api/command` POST endpoint) +- **Journal.Sidecar** — console app (stdin/stdout JSON protocol or CLI with `vault` and `search` subcommands) +- **Journal.SmokeTests** — 70+ integration tests (no test framework dependency) -```powershell -# Load vaults into decrypted data workspace -dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault load - -# Save (rebuild) monthly vaults from decrypted markdown files -dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault save - -# Search entries (query + filters) -dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- search "common text" --tag stress --type !TRIGGER --start-date 2026-02-01 --end-date 2026-02-28 --section Summary --checked "med taken" -``` - -Password prompt behavior: -- If `--password` is omitted, CLI prompts with `Vault password:` (hidden input in terminal mode). -- For automation/non-interactive use, pass `--password `. - -Optional path overrides: -- `--vault-dir ` -- `--data-dir ` -- Env fallback: `JOURNAL_VAULT_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_APP_DIR` - -Search CLI flags: -- positional `query` (optional) -- `--tag` / `-t` (repeatable) -- `--type` / `-y` (repeatable) -- `--start-date` / `-s` (`yyyy-MM-dd`) -- `--end-date` / `-e` (`yyyy-MM-dd`) -- `--section` / `-sec` -- `--checked` / `-chk` (repeatable) -- `--unchecked` / `-uchk` (repeatable) -- `--data-dir ` (optional override) - -## Config Keys (Parity Surface) - -`JournalConfigService` exposes and normalizes key settings expected from Python config: - -- Paths: `JOURNAL_PROJECT_ROOT`, `JOURNAL_APP_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_VAULT_DIR`, `JOURNAL_LOG_DIR`, `JOURNAL_PID_FILE`, `JOURNAL_SERVER_CONTROL_FILE` -- Vault format: `JOURNAL_MONTHLY_VAULT_FORMAT` (default `%Y-%m.vault`) -- AI endpoints/models: `CLOUDAI_API_KEY`, `CLOUDAI_API_URL`, `LLAMA_CPP_URL`, `LLAMA_CPP_MODEL`, `LLAMA_CPP_TIMEOUT`, `EMBEDDING_API_URL`, `EMBEDDING_MODEL_NAME`, `MODEL_CONTEXT_TOKENS`, `CHUNK_TOKEN_BUDGET` -- AI bridge mode: `JOURNAL_AI_PROVIDER` (`none` or `python-sidecar`), `JOURNAL_PYTHON_EXE`, `JOURNAL_AI_SIDECAR_PATH`, `JOURNAL_AI_TIMEOUT_MS` -- Speech/NLP: `MICROPHONE_DEVICE_INDEX`, `SPEECH_RECOGNITION_ENGINE`, `WHISPER_MODEL_SIZE`, `JOURNAL_NLP_BACKEND` - -### Command Format - -```json -{ - "action": "fragments.create", - "id": null, - "type": null, - "tag": null, - "payload": { "type": "!TRIGGER", "description": "stomach drop" } -} -``` - -**Fields:** -- `action` — The operation to perform (e.g. `fragments.list`, `fragments.create`) -- `id` — Target entity ID (for get/update/delete) -- `type` / `tag` — Filter parameters (for search) -- `payload` — Request body, deserialized into the appropriate DTO per action - -### Available Actions - -| Action | Description | Requires | -|--------|-------------|----------| -| `fragments.list` | List all fragments | — | -| `fragments.get` | Get fragment by ID | `id` | -| `fragments.create` | Create a new fragment | `payload` (CreateFragmentDto) | -| `fragments.update` | Update a fragment | `id`, `payload` (UpdateFragmentDto) | -| `fragments.delete` | Delete a fragment | `id` | -| `fragments.search` | Search by type/tag | `type` and/or `tag` | -| `entries.list` | List decrypted markdown entries in a data directory | optional `payload.dataDirectory` | -| `entries.load` | Load one entry file and return parsed metadata + raw content | `payload.filePath` | -| `entries.save` | Save/merge entry content to file (fragment append or full merge path) | `payload.content`, optional `payload.filePath`, `payload.mode` | -| `db.status` | Return DB key/schema compatibility status snapshot | `payload.password`, optional `payload.dataDirectory` | -| `db.initialize_schema` | Write SQL schema bootstrap (`journal_schema.sql`) for parity tables | optional `payload.dataDirectory` | -| `db.hydrate_workspace` | Perform C# DB hydration step for decrypted workspace (schema bootstrap + metadata) | `payload.password`, optional `payload.dataDirectory` | -| `config.get` | Return current backend config snapshot | — | -| `ai.health` | Return AI bridge health/provider status | — | -| `ai.summarize_entry` | Summarize one entry through AI provider | `payload.content`, optional `payload.fileStem` | -| `ai.summarize_all` | Summarize a set of entries through AI provider | `payload.entries[]` | -| `ai.chat` | Send chat prompt through AI provider bridge | `payload.prompt` | -| `ai.embed` | Generate embedding vector through AI provider bridge | `payload.content` | -| `search.entries` | Search decrypted entry content with optional parity filters | `payload.dataDirectory`, optional `payload.query`, `payload.section`, `payload.startDate`, `payload.endDate`, `payload.tags[]`, `payload.types[]`, `payload.checked[]`, `payload.unchecked[]` | -| `vault.initialize` | Ensure vault directory exists | `payload.password`, `payload.vaultDirectory` | -| `vault.load_all` | Load/decrypt all monthly vaults into data directory | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` | -| `vault.save_current_month` | Save only current month vault (optimized path) | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory`, optional `payload.nowUtc` | -| `vault.rebuild_all` | Rebuild all monthly vaults from decrypted `.md` data | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` | -| `vault.clear_data_directory` | Clear decrypted data directory and recreate it | `payload.dataDirectory` | - -### Response Format - -Success: -```json -{ "ok": true, "data": { "id": "abc-123", "type": "!TRIGGER", "description": "...", "time": "...", "tags": [] } } -``` - -Error: -```json -{ "ok": false, "error": "Description is required" } -``` - -## Extending with New Modules - -The `Command` class is generic — new modules use the same dot-notation pattern: +### Architecture ``` -vault.unlock → IVaultService (future) -vault.lock -entries.list → IEntryService (future) -entries.create -ai.health → IAiService (implemented bridge) -ai.summarize_* → IAiService (implemented bridge) -ai.chat → IAiService (implemented bridge) -ai.embed → IAiService (implemented bridge) -db.status → IJournalDatabaseService (in-progress DB parity) -search.query → ISearchService (future) +Entry (thin command dispatcher) + ├── Fragments/ IFragmentService → FragmentService → IFragmentRepository (SQLCipher) + ├── Entries/ IEntryFileService, IEntrySearchService, JournalParser, HtmlSanitizer + ├── Vault/ IVaultStorageService → VaultStorageService → IVaultCryptoService + ├── Database/ IJournalDatabaseService (SQLCipher schema/key derivation) + │ IDatabaseSessionService (encrypted connection lifecycle after auth) + ├── Ai/ IAiService → PythonSidecarAiService | DisabledAiService + ├── Speech/ ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService + ├── Sidecar/ PythonSidecarClient (shared Python process I/O), SidecarCli + ├── Logging/ CommandLogger, LogRedactor + └── Config/ IJournalConfigService → JournalConfigService ``` -To add a module: -1. Create model, DTO, repository, and service in `Journal.Core/` -2. Register the new service in `ServiceCollectionExtensions.cs` -3. Inject the service into `Entry.cs` and add cases to the action switch -4. No changes needed to `Command.cs` or `App.cs` +Services are organized under `Journal.Core/Services/` in domain-specific subdirectories, each with its own namespace (e.g. `Journal.Core.Services.Ai`). -## Dependency Injection +### Build & Run -`ServiceCollectionExtensions.cs` wires everything up. Any host (sidecar, API, tests) calls: - -```csharp -services.AddFragmentServices(); +```bash +cd backend +dotnet build ``` -This registers: -- `IFragmentRepository` → `FileFragmentRepository` (singleton — persisted fragment store) -- `IFragmentService` → `FragmentService` (transient — fresh instance per request) +Run the API server: -## Fragment Store Location +```bash +dotnet run --project Journal.Api +``` -`FileFragmentRepository` persists data to: +Run the sidecar (stdin/stdout mode): -- default: `.journal-sidecar/fragments.json` under current working directory -- override: `JOURNAL_FRAGMENT_STORE_PATH` environment variable +```bash +dotnet run --project Journal.Sidecar +``` -## Legacy Vault Compatibility Note +Sidecar CLI commands: -The legacy Python placeholder file `_init_vault.vault` is treated as obsolete. -During vault load, the C# backend ignores this file for decryption and removes it. -This preserves compatibility while migrating older vault directories forward. +```bash +dotnet run --project Journal.Sidecar -- vault load --password +dotnet run --project Journal.Sidecar -- vault save --password +dotnet run --project Journal.Sidecar -- search "your query" --tag stress --start-date 2026-02-01 +``` + +Run smoke tests: + +```bash +dotnet run --project Journal.SmokeTests +``` + +### Environment Variables + +- `JOURNAL_PROJECT_ROOT` — override project root detection +- `JOURNAL_DATA_DIR` / `JOURNAL_VAULT_DIR` — override data/vault paths +- `JOURNAL_AI_PROVIDER` — `none` (default) or `python-sidecar` +- `JOURNAL_PYTHON_EXE` — Python executable path (default: `python`) +- `JOURNAL_LOG_LEVEL` — `trace`, `debug`, `information`, `warning` (default), `error`, `critical` + +### Encryption + +- Vault: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iterations) +- Database: SQLCipher with PBKDF2-derived key +- Standalone fragments are stored in the encrypted SQLCipher database (requires auth via `vault.load_all` or `db.hydrate_workspace`) +- `DatabaseSessionService` holds the encryption password in memory after first authentication +- Wire format matches the Python implementation for cross-language parity + +## Notes + +- Decrypted journal data in `journal/data` is cleared on graceful shutdown. +- Vault save/load commands remain unchanged. diff --git a/REFACTORING_SUMMARY.md b/REFACTORING_SUMMARY.md new file mode 100644 index 0000000..bda1ebd --- /dev/null +++ b/REFACTORING_SUMMARY.md @@ -0,0 +1,84 @@ +# Backend Refactoring Summary + +## Problem +`Entry.cs` was a 550-line god class that contained command dispatching, business logic, HTML sanitization, logging infrastructure, and 13 private payload record types. The two Python sidecar services (`PythonSidecarAiService` and `PythonSidecarSpeechService`) duplicated ~80 lines of identical process/JSON plumbing. Payload DTOs were hidden as private records inside `Entry.cs` instead of being in the `Dtos/` folder. + +## What Changed + +### 1. Slimmed `Entry.cs` to a thin dispatcher (~330 lines) +Removed all business logic, HTML processing, logging implementation, and private record types. `Entry` now only parses the incoming JSON command, routes to the correct service, and returns the `{ok, data}` / `{ok: false, error}` envelope. + +### 2. Extracted `HtmlSanitizer` (new file) +`StripRichHtml` and `LooksLikeRichHtml` moved from `Entry.cs` to `Services/HtmlSanitizer.cs` as a static utility class. + +### 3. Extracted `CommandLogger` (new file) +`LogStart`, `LogSuccess`, `LogFailure`, `EmitLog`, `ShouldLog`, and `LogLevelRank` moved from `Entry.cs` to `Services/CommandLogger.cs`. Entry now receives this as a dependency. + +### 4. Extracted `IEntryFileService` + `EntryFileService` (new files) +`SaveEntry`, `LoadEntry`, `ListEntries`, and `ResolveTargetPath` moved out of `Entry.cs` into a proper service with an interface. This follows the same pattern as `IFragmentService` / `FragmentService`. + +### 5. Added `IEntryFileRepository` + `DiskEntryFileRepository` (new files) +`EntryFileService` now delegates all filesystem I/O (read, write, append, list, exists) to an `IEntryFileRepository`, keeping only business logic (HTML sanitization, parsing, merging) in the service. This mirrors the Fragment module's repository pattern (`IFragmentRepository` → `FragmentService`). An in-memory implementation can be swapped in for testing. + +### 6. Extracted `PythonSidecarClient` (new file) +The duplicated `SendAsync`, `LastJsonLine`, and `TryKill` methods were extracted from both `PythonSidecarAiService` and `PythonSidecarSpeechService` into a shared `Services/PythonSidecarClient.cs`. Both services now delegate to it. + +### 7. Moved payload records to `Dtos/CommandDtos.cs` (new file) +The 16 private payload/result records that were inside `Entry.cs` are now in `Dtos/CommandDtos.cs`. Records used through public interfaces (`EntrySavePayload`, `EntryListItem`, `EntryLoadResult`, `EntrySaveResult`) are public; the rest remain internal. + +### 8. Moved database result records to `Dtos/DatabaseDtos.cs` (new file) +`JournalDatabaseStatus` and `JournalDatabaseHydrationResult` moved from `IJournalDatabaseService.cs` to `Dtos/DatabaseDtos.cs` for consistency with the other DTO files. + +### 9. Moved fragment storage to encrypted SQLCipher database +Standalone fragments were previously stored in an unencrypted JSON file (`fragments.json`), then briefly in an unencrypted SQLite database. For medical/sensitive use cases, fragments are now stored in the **existing encrypted SQLCipher database** (`journal_cache.db`) alongside entries, sections, and tags. + +- Updated `fragments` table schema: `entry_id` is now nullable, added `guid TEXT UNIQUE` column. Standalone fragments use `guid` + `entry_id IS NULL`; entry-linked fragments use `entry_id` + `guid NULL`. +- `SqliteFragmentRepository` now depends on `IDatabaseSessionService` instead of managing its own database connection. +- Tags use the shared normalized `tags` + `fragment_tags` tables (join via integer IDs). +- Fragment CRUD requires the database to be unlocked first (via `vault.load_all` or `db.hydrate_workspace`). + +### 10. Added `IDatabaseSessionService` + `DatabaseSessionService` (new files) +A singleton that stores the encryption password in memory after authentication. When `vault.load_all` or `db.hydrate_workspace` is called, the session stores the password and lazily opens/caches an encrypted SQLCipher connection. All encrypted database consumers (currently `SqliteFragmentRepository`) use this shared session. + +### 11. Organized Services directory into domain modules +The flat `Services/` directory (28 files) was reorganized into 9 subdirectories with dedicated namespaces: + +- `Services/Ai/` — `IAiService`, `DisabledAiService`, `PythonSidecarAiService` +- `Services/Config/` — `IJournalConfigService`, `JournalConfigService` +- `Services/Database/` — `IJournalDatabaseService`, `JournalDatabaseService`, `IDatabaseSessionService`, `DatabaseSessionService` +- `Services/Entries/` — `IEntryFileService`, `EntryFileService`, `IEntrySearchService`, `EntrySearchService`, `JournalParser`, `HtmlSanitizer` +- `Services/Fragments/` — `IFragmentService`, `FragmentService` +- `Services/Logging/` — `CommandLogger`, `LogRedactor` +- `Services/Sidecar/` — `PythonSidecarClient`, `SidecarCli` +- `Services/Speech/` — `ISpeechBridgeService`, `DisabledSpeechBridgeService`, `PythonSidecarSpeechService` +- `Services/Vault/` — `IVaultCryptoService`, `VaultCryptoService`, `IVaultStorageService`, `VaultStorageService` + +Each subdirectory has its own namespace (e.g. `Journal.Core.Services.Ai`). All consumer files updated with explicit using statements. + +## Files Created +- `Journal.Core/Services/Entries/HtmlSanitizer.cs` +- `Journal.Core/Services/Logging/CommandLogger.cs` +- `Journal.Core/Services/Entries/IEntryFileService.cs` +- `Journal.Core/Services/Entries/EntryFileService.cs` +- `Journal.Core/Services/Sidecar/PythonSidecarClient.cs` +- `Journal.Core/Repositories/IEntryFileRepository.cs` +- `Journal.Core/Repositories/DiskEntryFileRepository.cs` +- `Journal.Core/Repositories/SqliteFragmentRepository.cs` +- `Journal.Core/Dtos/CommandDtos.cs` +- `Journal.Core/Dtos/DatabaseDtos.cs` +- `Journal.Core/Services/Database/IDatabaseSessionService.cs` +- `Journal.Core/Services/Database/DatabaseSessionService.cs` + +## Files Modified +- `Journal.Core/Entry.cs` — slimmed to thin dispatcher, wired `IDatabaseSessionService` +- `Journal.Core/Services/Ai/PythonSidecarAiService.cs` — delegates to PythonSidecarClient +- `Journal.Core/Services/Speech/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient +- `Journal.Core/Services/Database/IJournalDatabaseService.cs` — result records moved to Dtos +- `Journal.Core/Services/Database/JournalDatabaseService.cs` — schema updated (nullable entry_id, guid column) +- `Journal.Core/ServiceCollectionExtensions.cs` — registers all new services, updated namespaces +- `Journal.SmokeTests/Program.cs` — updated with new dependencies and encrypted DB persistence test +- `Journal.Sidecar/App.cs` — updated namespace imports + +## Verification +- All 4 projects build successfully +- 65/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)