From f06c1d15bb5b9306c2170d7ef8914bb00a3ffdb2 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Mon, 23 Feb 2026 21:58:45 -0600 Subject: [PATCH] refactor: encrypt fragments in SQLCipher DB, organize services into domain modules - Move standalone fragment storage from unencrypted SQLite to the existing encrypted SQLCipher database (journal_cache.db) - Add IDatabaseSessionService/DatabaseSessionService for shared encrypted connection management after authentication - Update fragments table schema: nullable entry_id, add guid column - Reorganize flat Services/ directory (28 files) into 9 domain modules: Ai, Config, Database, Entries, Fragments, Logging, Sidecar, Speech, Vault - Update all namespace declarations and using statements across all projects - Update REFACTORING_SUMMARY.md with all changes Co-Authored-By: Warp --- Journal.Core/Entry.cs | 13 +- .../Repositories/SqliteFragmentRepository.cs | 320 ++++++++++++++++++ Journal.Core/ServiceCollectionExtensions.cs | 13 +- .../Services/{ => Ai}/DisabledAiService.cs | 2 +- Journal.Core/Services/{ => Ai}/IAiService.cs | 2 +- .../{ => Ai}/PythonSidecarAiService.cs | 3 +- .../{ => Config}/IJournalConfigService.cs | 2 +- .../{ => Config}/JournalConfigService.cs | 2 +- .../Database/DatabaseSessionService.cs | 99 ++++++ .../Database/IDatabaseSessionService.cs | 10 + .../{ => Database}/IJournalDatabaseService.cs | 2 +- .../{ => Database}/JournalDatabaseService.cs | 6 +- .../{ => Entries}/EntryFileService.cs | 2 +- .../{ => Entries}/EntrySearchService.cs | 2 +- .../Services/{ => Entries}/HtmlSanitizer.cs | 2 +- .../{ => Entries}/IEntryFileService.cs | 2 +- .../{ => Entries}/IEntrySearchService.cs | 2 +- .../Services/{ => Entries}/JournalParser.cs | 2 +- .../{ => Fragments}/FragmentService.cs | 2 +- .../{ => Fragments}/IFragmentService.cs | 2 +- .../Services/{ => Logging}/CommandLogger.cs | 2 +- .../Services/{ => Logging}/LogRedactor.cs | 2 +- .../{ => Sidecar}/PythonSidecarClient.cs | 2 +- .../Services/{ => Sidecar}/SidecarCli.cs | 5 +- .../DisabledSpeechBridgeService.cs | 2 +- .../{ => Speech}/ISpeechBridgeService.cs | 2 +- .../PythonSidecarSpeechService.cs | 3 +- .../{ => Vault}/IVaultCryptoService.cs | 2 +- .../{ => Vault}/IVaultStorageService.cs | 2 +- .../{ => Vault}/VaultCryptoService.cs | 2 +- .../{ => Vault}/VaultStorageService.cs | 2 +- Journal.Sidecar/App.cs | 2 +- Journal.SmokeTests/Program.cs | 54 ++- REFACTORING_SUMMARY.md | 64 ++-- 34 files changed, 570 insertions(+), 66 deletions(-) create mode 100644 Journal.Core/Repositories/SqliteFragmentRepository.cs rename Journal.Core/Services/{ => Ai}/DisabledAiService.cs (97%) rename Journal.Core/Services/{ => Ai}/IAiService.cs (94%) rename Journal.Core/Services/{ => Ai}/PythonSidecarAiService.cs (98%) rename Journal.Core/Services/{ => Config}/IJournalConfigService.cs (72%) rename Journal.Core/Services/{ => Config}/JournalConfigService.cs (99%) create mode 100644 Journal.Core/Services/Database/DatabaseSessionService.cs create mode 100644 Journal.Core/Services/Database/IDatabaseSessionService.cs rename Journal.Core/Services/{ => Database}/IJournalDatabaseService.cs (92%) rename Journal.Core/Services/{ => Database}/JournalDatabaseService.cs (98%) rename Journal.Core/Services/{ => Entries}/EntryFileService.cs (98%) rename Journal.Core/Services/{ => Entries}/EntrySearchService.cs (99%) rename Journal.Core/Services/{ => Entries}/HtmlSanitizer.cs (98%) rename Journal.Core/Services/{ => Entries}/IEntryFileService.cs (86%) rename Journal.Core/Services/{ => Entries}/IEntrySearchService.cs (80%) rename Journal.Core/Services/{ => Entries}/JournalParser.cs (99%) rename Journal.Core/Services/{ => Fragments}/FragmentService.cs (98%) rename Journal.Core/Services/{ => Fragments}/IFragmentService.cs (92%) rename Journal.Core/Services/{ => Logging}/CommandLogger.cs (98%) rename Journal.Core/Services/{ => Logging}/LogRedactor.cs (98%) rename Journal.Core/Services/{ => Sidecar}/PythonSidecarClient.cs (98%) rename Journal.Core/Services/{ => Sidecar}/SidecarCli.cs (98%) rename Journal.Core/Services/{ => Speech}/DisabledSpeechBridgeService.cs (96%) rename Journal.Core/Services/{ => Speech}/ISpeechBridgeService.cs (88%) rename Journal.Core/Services/{ => Speech}/PythonSidecarSpeechService.cs (97%) rename Journal.Core/Services/{ => Vault}/IVaultCryptoService.cs (84%) rename Journal.Core/Services/{ => Vault}/IVaultStorageService.cs (91%) rename Journal.Core/Services/{ => Vault}/VaultCryptoService.cs (98%) rename Journal.Core/Services/{ => Vault}/VaultStorageService.cs (99%) diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs index 588b0c9..058e0be 100644 --- a/Journal.Core/Entry.cs +++ b/Journal.Core/Entry.cs @@ -3,7 +3,14 @@ using System.Globalization; 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; @@ -12,6 +19,7 @@ public class Entry( IEntrySearchService entrySearch, IVaultStorageService vaultStorage, IJournalDatabaseService database, + IDatabaseSessionService databaseSession, IJournalConfigService config, IAiService ai, ISpeechBridgeService speech, @@ -22,6 +30,7 @@ public class Entry( 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; @@ -202,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); @@ -245,6 +255,7 @@ 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: CommandLogger.LogFailure(action, correlationId, "unknown_action"); diff --git a/Journal.Core/Repositories/SqliteFragmentRepository.cs b/Journal.Core/Repositories/SqliteFragmentRepository.cs new file mode 100644 index 0000000..d69a52f --- /dev/null +++ b/Journal.Core/Repositories/SqliteFragmentRepository.cs @@ -0,0 +1,320 @@ +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 Task> GetAllAsync() + { + var conn = _session.GetConnection(); + var fragments = ReadAllFragments(conn); + return Task.FromResult(fragments); + } + + public Task GetByIdAsync(Guid id) + { + var conn = _session.GetConnection(); + var fragment = ReadFragment(conn, id); + return Task.FromResult(fragment); + } + + public Task AddAsync(Fragment fragment) + { + ArgumentNullException.ThrowIfNull(fragment); + Normalize(fragment); + var conn = _session.GetConnection(); + InsertFragment(conn, fragment); + return Task.CompletedTask; + } + + public Task RemoveAsync(Guid id) + { + var conn = _session.GetConnection(); + var deleted = DeleteFragment(conn, id); + return Task.FromResult(deleted); + } + + public Task UpdateAsync( + 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 Task.FromResult(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 Task.FromResult(true); + } + + public Task> GetByTagAsync(string tag) + { + var q = tag?.Trim(); + if (string.IsNullOrWhiteSpace(q)) + return Task.FromResult(new List()); + + var conn = _session.GetConnection(); + var all = ReadAllFragments(conn); + var items = all + .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()); + + var conn = _session.GetConnection(); + var all = ReadAllFragments(conn); + var items = all + .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 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 Task.FromResult(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 9b3c648..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(); diff --git a/Journal.Core/Services/DisabledAiService.cs b/Journal.Core/Services/Ai/DisabledAiService.cs similarity index 97% rename from Journal.Core/Services/DisabledAiService.cs rename to Journal.Core/Services/Ai/DisabledAiService.cs index 0dbbc9d..d5a7ad6 100644 --- a/Journal.Core/Services/DisabledAiService.cs +++ b/Journal.Core/Services/Ai/DisabledAiService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Ai; public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService { 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/PythonSidecarAiService.cs b/Journal.Core/Services/Ai/PythonSidecarAiService.cs similarity index 98% rename from Journal.Core/Services/PythonSidecarAiService.cs rename to Journal.Core/Services/Ai/PythonSidecarAiService.cs index 8bf4e1d..daba179 100644 --- a/Journal.Core/Services/PythonSidecarAiService.cs +++ b/Journal.Core/Services/Ai/PythonSidecarAiService.cs @@ -1,8 +1,9 @@ using System.Text.Json; using Journal.Core.Dtos; using Journal.Core.Models; +using Journal.Core.Services.Sidecar; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Ai; public sealed class PythonSidecarAiService : IAiService { 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..086bcd1 --- /dev/null +++ b/Journal.Core/Services/Database/DatabaseSessionService.cs @@ -0,0 +1,99 @@ +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 = OpenEncryptedConnection(_password, _dataDirectory); + EnsureSchema(_connection, _database); + return _connection; + } + } + + public void Dispose() + { + lock (_lock) + { + _connection?.Dispose(); + _connection = null; + } + } + + private SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory) + { + var dbPath = _database.GetDatabasePath(dataDirectory); + var directory = Path.GetDirectoryName(dbPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var connection = new SqliteConnection( + $"Data Source={dbPath};Mode=ReadWriteCreate;Pooling=False"); + connection.Open(); + + using var keyCmd = connection.CreateCommand(); + keyCmd.CommandText = _database.BuildPragmaKeyStatement(password) + ";"; + keyCmd.ExecuteNonQuery(); + + // Verify the key is correct + using var verifyCmd = connection.CreateCommand(); + verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; + _ = verifyCmd.ExecuteScalar(); + + return connection; + } + + private static void EnsureSchema(SqliteConnection connection, IJournalDatabaseService database) + { + foreach (var statement in database.GetSchemaStatements().Values) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = statement; + cmd.ExecuteNonQuery(); + } + } +} 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 92% rename from Journal.Core/Services/IJournalDatabaseService.cs rename to Journal.Core/Services/Database/IJournalDatabaseService.cs index 40af126..ab23abb 100644 --- a/Journal.Core/Services/IJournalDatabaseService.cs +++ b/Journal.Core/Services/Database/IJournalDatabaseService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Database; public interface IJournalDatabaseService { diff --git a/Journal.Core/Services/JournalDatabaseService.cs b/Journal.Core/Services/Database/JournalDatabaseService.cs similarity index 98% rename from Journal.Core/Services/JournalDatabaseService.cs rename to Journal.Core/Services/Database/JournalDatabaseService.cs index d4c3105..1b5bfcc 100644 --- a/Journal.Core/Services/JournalDatabaseService.cs +++ b/Journal.Core/Services/Database/JournalDatabaseService.cs @@ -1,9 +1,10 @@ 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(IJournalConfigService config) : IJournalDatabaseService { @@ -68,7 +69,8 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour ["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, diff --git a/Journal.Core/Services/EntryFileService.cs b/Journal.Core/Services/Entries/EntryFileService.cs similarity index 98% rename from Journal.Core/Services/EntryFileService.cs rename to Journal.Core/Services/Entries/EntryFileService.cs index b3c59c1..124f968 100644 --- a/Journal.Core/Services/EntryFileService.cs +++ b/Journal.Core/Services/Entries/EntryFileService.cs @@ -1,7 +1,7 @@ using Journal.Core.Dtos; using Journal.Core.Repositories; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Entries; public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService { 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/HtmlSanitizer.cs b/Journal.Core/Services/Entries/HtmlSanitizer.cs similarity index 98% rename from Journal.Core/Services/HtmlSanitizer.cs rename to Journal.Core/Services/Entries/HtmlSanitizer.cs index 9b7b895..163c538 100644 --- a/Journal.Core/Services/HtmlSanitizer.cs +++ b/Journal.Core/Services/Entries/HtmlSanitizer.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.RegularExpressions; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Entries; public static class HtmlSanitizer { diff --git a/Journal.Core/Services/IEntryFileService.cs b/Journal.Core/Services/Entries/IEntryFileService.cs similarity index 86% rename from Journal.Core/Services/IEntryFileService.cs rename to Journal.Core/Services/Entries/IEntryFileService.cs index 1469869..107d85a 100644 --- a/Journal.Core/Services/IEntryFileService.cs +++ b/Journal.Core/Services/Entries/IEntryFileService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Entries; public interface IEntryFileService { 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 98% rename from Journal.Core/Services/FragmentService.cs rename to Journal.Core/Services/Fragments/FragmentService.cs index 04a52e3..f5e8035 100644 --- a/Journal.Core/Services/FragmentService.cs +++ b/Journal.Core/Services/Fragments/FragmentService.cs @@ -3,7 +3,7 @@ using Journal.Core.Dtos; using Journal.Core.Models; using Journal.Core.Repositories; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Fragments; public class FragmentService(IFragmentRepository repo) : IFragmentService { diff --git a/Journal.Core/Services/IFragmentService.cs b/Journal.Core/Services/Fragments/IFragmentService.cs similarity index 92% rename from Journal.Core/Services/IFragmentService.cs rename to Journal.Core/Services/Fragments/IFragmentService.cs index 2bde778..d56252f 100644 --- a/Journal.Core/Services/IFragmentService.cs +++ b/Journal.Core/Services/Fragments/IFragmentService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Fragments; public interface IFragmentService { diff --git a/Journal.Core/Services/CommandLogger.cs b/Journal.Core/Services/Logging/CommandLogger.cs similarity index 98% rename from Journal.Core/Services/CommandLogger.cs rename to Journal.Core/Services/Logging/CommandLogger.cs index b9eadf4..2023cb1 100644 --- a/Journal.Core/Services/CommandLogger.cs +++ b/Journal.Core/Services/Logging/CommandLogger.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text.Json; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Logging; public sealed class CommandLogger { 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/PythonSidecarClient.cs b/Journal.Core/Services/Sidecar/PythonSidecarClient.cs similarity index 98% rename from Journal.Core/Services/PythonSidecarClient.cs rename to Journal.Core/Services/Sidecar/PythonSidecarClient.cs index 978fa2d..01284bd 100644 --- a/Journal.Core/Services/PythonSidecarClient.cs +++ b/Journal.Core/Services/Sidecar/PythonSidecarClient.cs @@ -2,7 +2,7 @@ using System.Diagnostics; using System.Text.Json; using Journal.Core.Models; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Sidecar; public sealed class PythonSidecarClient(JournalConfig config) { diff --git a/Journal.Core/Services/SidecarCli.cs b/Journal.Core/Services/Sidecar/SidecarCli.cs similarity index 98% rename from Journal.Core/Services/SidecarCli.cs rename to Journal.Core/Services/Sidecar/SidecarCli.cs index 81cd1b6..8c8057b 100644 --- a/Journal.Core/Services/SidecarCli.cs +++ b/Journal.Core/Services/Sidecar/SidecarCli.cs @@ -1,7 +1,10 @@ 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(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config) { diff --git a/Journal.Core/Services/DisabledSpeechBridgeService.cs b/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs similarity index 96% rename from Journal.Core/Services/DisabledSpeechBridgeService.cs rename to Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs index 093ebcc..027ddc1 100644 --- a/Journal.Core/Services/DisabledSpeechBridgeService.cs +++ b/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs @@ -1,6 +1,6 @@ using Journal.Core.Dtos; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Speech; public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService { 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/PythonSidecarSpeechService.cs b/Journal.Core/Services/Speech/PythonSidecarSpeechService.cs similarity index 97% rename from Journal.Core/Services/PythonSidecarSpeechService.cs rename to Journal.Core/Services/Speech/PythonSidecarSpeechService.cs index 82fa6b8..7936f0d 100644 --- a/Journal.Core/Services/PythonSidecarSpeechService.cs +++ b/Journal.Core/Services/Speech/PythonSidecarSpeechService.cs @@ -1,8 +1,9 @@ using System.Text.Json; using Journal.Core.Dtos; using Journal.Core.Models; +using Journal.Core.Services.Sidecar; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Speech; public sealed class PythonSidecarSpeechService : ISpeechBridgeService { 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 99% rename from Journal.Core/Services/VaultStorageService.cs rename to Journal.Core/Services/Vault/VaultStorageService.cs index 93fabb3..f2dd18f 100644 --- a/Journal.Core/Services/VaultStorageService.cs +++ b/Journal.Core/Services/Vault/VaultStorageService.cs @@ -3,7 +3,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Vault; public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService { 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 ea14e8b..4d6cfbd 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,16 +114,22 @@ 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"), - new EntryFileService(new DiskEntryFileRepository()), - new CommandLogger()); +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()); @@ -164,15 +178,27 @@ static async Task TestUpdateRejectsWhitespaceTypeAsync() static async 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); + // Set up encrypted DB session + var configService = new JournalConfigService(); + var dbService = new JournalDatabaseService(configService); + + // 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 = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); - IFragmentRepository repo2 = new FileFragmentRepository(storePath); + // 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); diff --git a/REFACTORING_SUMMARY.md b/REFACTORING_SUMMARY.md index b4c7cc7..bda1ebd 100644 --- a/REFACTORING_SUMMARY.md +++ b/REFACTORING_SUMMARY.md @@ -29,34 +29,56 @@ The 16 private payload/result records that were inside `Entry.cs` are now in `Dt ### 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/HtmlSanitizer.cs` -- `Journal.Core/Services/CommandLogger.cs` -- `Journal.Core/Services/IEntryFileService.cs` -- `Journal.Core/Services/EntryFileService.cs` -- `Journal.Core/Services/PythonSidecarClient.cs` +- `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 -- `Journal.Core/Services/PythonSidecarAiService.cs` — delegates to PythonSidecarClient -- `Journal.Core/Services/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient -- `Journal.Core/Services/IJournalDatabaseService.cs` — result records moved to Dtos -- `Journal.Core/Services/JournalDatabaseService.cs` — added Dtos using -- `Journal.Core/ServiceCollectionExtensions.cs` — registers new services and repository -- `Journal.SmokeTests/Program.cs` — updated NewEntry() with new dependencies - -## What Was NOT Changed -- **Fragment module** — already clean, untouched -- **Config module** — singleton reader, no changes needed -- **Vault module** — already well-separated (crypto/storage), untouched -- **AI/Speech interfaces and disabled variants** — untouched (only the sidecar implementations were refactored) -- **Search module** — stateless query service, no repository needed -- **All test logic** — no assertions or test behavior changed +- `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 -- 70/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing) +- 65/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)