diff --git a/.gitignore b/.gitignore index df0104f..08aca57 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,3 @@ logs/ .journal-sidecar/ .nuget _hybrid_tmp*/ -journal-master/journal/tls_registry_backup_before_fix.txt diff --git a/journal-master/journal/Journal.Core/Entry.cs b/journal-master/journal/Journal.Core/Entry.cs index 588b0c9..860a0ae 100644 --- a/journal-master/journal/Journal.Core/Entry.cs +++ b/journal-master/journal/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; @@ -72,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)) @@ -91,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); @@ -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-master/journal/Journal.Core/Repositories/FileFragmentRepository.cs b/journal-master/journal/Journal.Core/Repositories/FileFragmentRepository.cs deleted file mode 100644 index 89ace7e..0000000 --- a/journal-master/journal/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))]; - } - - 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-master/journal/Journal.Core/Repositories/IFragmentRepository.cs b/journal-master/journal/Journal.Core/Repositories/IFragmentRepository.cs index 54011c1..bb05f0d 100644 --- a/journal-master/journal/Journal.Core/Repositories/IFragmentRepository.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Repositories/InMemoryFragmentRepository.cs b/journal-master/journal/Journal.Core/Repositories/InMemoryFragmentRepository.cs index d283645..b9f6cf5 100644 --- a/journal-master/journal/Journal.Core/Repositories/InMemoryFragmentRepository.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Repositories/SqliteFragmentRepository.cs b/journal-master/journal/Journal.Core/Repositories/SqliteFragmentRepository.cs new file mode 100644 index 0000000..50c24a7 --- /dev/null +++ b/journal-master/journal/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-master/journal/Journal.Core/ServiceCollectionExtensions.cs b/journal-master/journal/Journal.Core/ServiceCollectionExtensions.cs index 9b3c648..e617b41 100644 --- a/journal-master/journal/Journal.Core/ServiceCollectionExtensions.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/DisabledAiService.cs b/journal-master/journal/Journal.Core/Services/Ai/DisabledAiService.cs similarity index 97% rename from journal-master/journal/Journal.Core/Services/DisabledAiService.cs rename to journal-master/journal/Journal.Core/Services/Ai/DisabledAiService.cs index 0dbbc9d..d5a7ad6 100644 --- a/journal-master/journal/Journal.Core/Services/DisabledAiService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/IAiService.cs b/journal-master/journal/Journal.Core/Services/Ai/IAiService.cs similarity index 94% rename from journal-master/journal/Journal.Core/Services/IAiService.cs rename to journal-master/journal/Journal.Core/Services/Ai/IAiService.cs index 791873b..5300641 100644 --- a/journal-master/journal/Journal.Core/Services/IAiService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/PythonSidecarAiService.cs b/journal-master/journal/Journal.Core/Services/Ai/PythonSidecarAiService.cs similarity index 96% rename from journal-master/journal/Journal.Core/Services/PythonSidecarAiService.cs rename to journal-master/journal/Journal.Core/Services/Ai/PythonSidecarAiService.cs index f101ab3..daba179 100644 --- a/journal-master/journal/Journal.Core/Services/PythonSidecarAiService.cs +++ b/journal-master/journal/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 { @@ -31,7 +32,7 @@ public sealed class PythonSidecarAiService : IAiService : "ok"; var healthy = !payload.TryGetProperty("healthy", out var healthyNode) || healthyNode.ValueKind is JsonValueKind.True || - (healthyNode.ValueKind is JsonValueKind.False ? false : true); + (healthyNode.ValueKind is not JsonValueKind.False); return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message); } diff --git a/journal-master/journal/Journal.Core/Services/IJournalConfigService.cs b/journal-master/journal/Journal.Core/Services/Config/IJournalConfigService.cs similarity index 72% rename from journal-master/journal/Journal.Core/Services/IJournalConfigService.cs rename to journal-master/journal/Journal.Core/Services/Config/IJournalConfigService.cs index 3e0a7b5..5da1f33 100644 --- a/journal-master/journal/Journal.Core/Services/IJournalConfigService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/JournalConfigService.cs b/journal-master/journal/Journal.Core/Services/Config/JournalConfigService.cs similarity index 99% rename from journal-master/journal/Journal.Core/Services/JournalConfigService.cs rename to journal-master/journal/Journal.Core/Services/Config/JournalConfigService.cs index bce9602..826ec43 100644 --- a/journal-master/journal/Journal.Core/Services/JournalConfigService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/Database/DatabaseSessionService.cs b/journal-master/journal/Journal.Core/Services/Database/DatabaseSessionService.cs new file mode 100644 index 0000000..f16f7c6 --- /dev/null +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/Database/IDatabaseSessionService.cs b/journal-master/journal/Journal.Core/Services/Database/IDatabaseSessionService.cs new file mode 100644 index 0000000..264334d --- /dev/null +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/IJournalDatabaseService.cs b/journal-master/journal/Journal.Core/Services/Database/IJournalDatabaseService.cs similarity index 71% rename from journal-master/journal/Journal.Core/Services/IJournalDatabaseService.cs rename to journal-master/journal/Journal.Core/Services/Database/IJournalDatabaseService.cs index 40af126..8d240fd 100644 --- a/journal-master/journal/Journal.Core/Services/IJournalDatabaseService.cs +++ b/journal-master/journal/Journal.Core/Services/Database/IJournalDatabaseService.cs @@ -1,6 +1,7 @@ using Journal.Core.Dtos; +using Microsoft.Data.Sqlite; -namespace Journal.Core.Services; +namespace Journal.Core.Services.Database; public interface IJournalDatabaseService { @@ -8,6 +9,8 @@ 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); diff --git a/journal-master/journal/Journal.Core/Services/JournalDatabaseService.cs b/journal-master/journal/Journal.Core/Services/Database/JournalDatabaseService.cs similarity index 94% rename from journal-master/journal/Journal.Core/Services/JournalDatabaseService.cs rename to journal-master/journal/Journal.Core/Services/Database/JournalDatabaseService.cs index 8ea7cec..301dcac 100644 --- a/journal-master/journal/Journal.Core/Services/JournalDatabaseService.cs +++ b/journal-master/journal/Journal.Core/Services/Database/JournalDatabaseService.cs @@ -1,16 +1,17 @@ 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 { 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"]; @@ -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, @@ -133,7 +135,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour 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; @@ -164,7 +166,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour } } - 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)); @@ -185,7 +187,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour return connection; } - private void CreateSchema(SqliteConnection connection) + public void EnsureSchema(SqliteConnection connection) { foreach (var statement in GetSchemaStatements().Values) { @@ -215,7 +217,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour 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-master/journal/Journal.Core/Services/EntryFileService.cs b/journal-master/journal/Journal.Core/Services/Entries/EntryFileService.cs similarity index 98% rename from journal-master/journal/Journal.Core/Services/EntryFileService.cs rename to journal-master/journal/Journal.Core/Services/Entries/EntryFileService.cs index b3c59c1..124f968 100644 --- a/journal-master/journal/Journal.Core/Services/EntryFileService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/EntrySearchService.cs b/journal-master/journal/Journal.Core/Services/Entries/EntrySearchService.cs similarity index 99% rename from journal-master/journal/Journal.Core/Services/EntrySearchService.cs rename to journal-master/journal/Journal.Core/Services/Entries/EntrySearchService.cs index e2ff6dc..3194015 100644 --- a/journal-master/journal/Journal.Core/Services/EntrySearchService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/HtmlSanitizer.cs b/journal-master/journal/Journal.Core/Services/Entries/HtmlSanitizer.cs similarity index 98% rename from journal-master/journal/Journal.Core/Services/HtmlSanitizer.cs rename to journal-master/journal/Journal.Core/Services/Entries/HtmlSanitizer.cs index 9b7b895..163c538 100644 --- a/journal-master/journal/Journal.Core/Services/HtmlSanitizer.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/IEntryFileService.cs b/journal-master/journal/Journal.Core/Services/Entries/IEntryFileService.cs similarity index 86% rename from journal-master/journal/Journal.Core/Services/IEntryFileService.cs rename to journal-master/journal/Journal.Core/Services/Entries/IEntryFileService.cs index 1469869..107d85a 100644 --- a/journal-master/journal/Journal.Core/Services/IEntryFileService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/IEntrySearchService.cs b/journal-master/journal/Journal.Core/Services/Entries/IEntrySearchService.cs similarity index 80% rename from journal-master/journal/Journal.Core/Services/IEntrySearchService.cs rename to journal-master/journal/Journal.Core/Services/Entries/IEntrySearchService.cs index e9bfede..6a2c24a 100644 --- a/journal-master/journal/Journal.Core/Services/IEntrySearchService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/JournalParser.cs b/journal-master/journal/Journal.Core/Services/Entries/JournalParser.cs similarity index 99% rename from journal-master/journal/Journal.Core/Services/JournalParser.cs rename to journal-master/journal/Journal.Core/Services/Entries/JournalParser.cs index ff00634..7f74811 100644 --- a/journal-master/journal/Journal.Core/Services/JournalParser.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/FragmentService.cs b/journal-master/journal/Journal.Core/Services/Fragments/FragmentService.cs similarity index 62% rename from journal-master/journal/Journal.Core/Services/FragmentService.cs rename to journal-master/journal/Journal.Core/Services/Fragments/FragmentService.cs index 04a52e3..55917fa 100644 --- a/journal-master/journal/Journal.Core/Services/FragmentService.cs +++ b/journal-master/journal/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 { @@ -17,7 +17,7 @@ public class FragmentService(IFragmentRepository repo) : IFragmentService f.Tags != null ? [.. f.Tags] : [] ); - public async Task CreateAsync(CreateFragmentDto dto) + public FragmentDto Create(CreateFragmentDto dto) { ArgumentNullException.ThrowIfNull(dto); @@ -28,11 +28,11 @@ public class FragmentService(IFragmentRepository repo) : 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)) @@ -44,38 +44,38 @@ public class FragmentService(IFragmentRepository repo) : 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-master/journal/Journal.Core/Services/Fragments/IFragmentService.cs b/journal-master/journal/Journal.Core/Services/Fragments/IFragmentService.cs new file mode 100644 index 0000000..6087081 --- /dev/null +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/IFragmentService.cs b/journal-master/journal/Journal.Core/Services/IFragmentService.cs deleted file mode 100644 index 2bde778..0000000 --- a/journal-master/journal/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-master/journal/Journal.Core/Services/CommandLogger.cs b/journal-master/journal/Journal.Core/Services/Logging/CommandLogger.cs similarity index 94% rename from journal-master/journal/Journal.Core/Services/CommandLogger.cs rename to journal-master/journal/Journal.Core/Services/Logging/CommandLogger.cs index b9eadf4..018dd5f 100644 --- a/journal-master/journal/Journal.Core/Services/CommandLogger.cs +++ b/journal-master/journal/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 { @@ -11,10 +11,7 @@ public sealed class CommandLogger EmitLog("information", action, correlationId, "start", redactedPayload); } - public static void LogSuccess(string action, string correlationId) - { - EmitLog("information", action, correlationId, "success"); - } + 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) { diff --git a/journal-master/journal/Journal.Core/Services/LogRedactor.cs b/journal-master/journal/Journal.Core/Services/Logging/LogRedactor.cs similarity index 98% rename from journal-master/journal/Journal.Core/Services/LogRedactor.cs rename to journal-master/journal/Journal.Core/Services/Logging/LogRedactor.cs index 4554174..b116bb5 100644 --- a/journal-master/journal/Journal.Core/Services/LogRedactor.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/PythonSidecarClient.cs b/journal-master/journal/Journal.Core/Services/Sidecar/PythonSidecarClient.cs similarity index 98% rename from journal-master/journal/Journal.Core/Services/PythonSidecarClient.cs rename to journal-master/journal/Journal.Core/Services/Sidecar/PythonSidecarClient.cs index 978fa2d..01284bd 100644 --- a/journal-master/journal/Journal.Core/Services/PythonSidecarClient.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/SidecarCli.cs b/journal-master/journal/Journal.Core/Services/Sidecar/SidecarCli.cs similarity index 98% rename from journal-master/journal/Journal.Core/Services/SidecarCli.cs rename to journal-master/journal/Journal.Core/Services/Sidecar/SidecarCli.cs index 81cd1b6..8c8057b 100644 --- a/journal-master/journal/Journal.Core/Services/SidecarCli.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/DisabledSpeechBridgeService.cs b/journal-master/journal/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs similarity index 96% rename from journal-master/journal/Journal.Core/Services/DisabledSpeechBridgeService.cs rename to journal-master/journal/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs index 093ebcc..027ddc1 100644 --- a/journal-master/journal/Journal.Core/Services/DisabledSpeechBridgeService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/ISpeechBridgeService.cs b/journal-master/journal/Journal.Core/Services/Speech/ISpeechBridgeService.cs similarity index 88% rename from journal-master/journal/Journal.Core/Services/ISpeechBridgeService.cs rename to journal-master/journal/Journal.Core/Services/Speech/ISpeechBridgeService.cs index 0294722..574ba78 100644 --- a/journal-master/journal/Journal.Core/Services/ISpeechBridgeService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/PythonSidecarSpeechService.cs b/journal-master/journal/Journal.Core/Services/Speech/PythonSidecarSpeechService.cs similarity index 97% rename from journal-master/journal/Journal.Core/Services/PythonSidecarSpeechService.cs rename to journal-master/journal/Journal.Core/Services/Speech/PythonSidecarSpeechService.cs index 82fa6b8..7936f0d 100644 --- a/journal-master/journal/Journal.Core/Services/PythonSidecarSpeechService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/IVaultCryptoService.cs b/journal-master/journal/Journal.Core/Services/Vault/IVaultCryptoService.cs similarity index 84% rename from journal-master/journal/Journal.Core/Services/IVaultCryptoService.cs rename to journal-master/journal/Journal.Core/Services/Vault/IVaultCryptoService.cs index 85418e5..af9e388 100644 --- a/journal-master/journal/Journal.Core/Services/IVaultCryptoService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/IVaultStorageService.cs b/journal-master/journal/Journal.Core/Services/Vault/IVaultStorageService.cs similarity index 91% rename from journal-master/journal/Journal.Core/Services/IVaultStorageService.cs rename to journal-master/journal/Journal.Core/Services/Vault/IVaultStorageService.cs index 525c1f3..3a76b20 100644 --- a/journal-master/journal/Journal.Core/Services/IVaultStorageService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/VaultCryptoService.cs b/journal-master/journal/Journal.Core/Services/Vault/VaultCryptoService.cs similarity index 98% rename from journal-master/journal/Journal.Core/Services/VaultCryptoService.cs rename to journal-master/journal/Journal.Core/Services/Vault/VaultCryptoService.cs index e6c7df7..8ef4e96 100644 --- a/journal-master/journal/Journal.Core/Services/VaultCryptoService.cs +++ b/journal-master/journal/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-master/journal/Journal.Core/Services/VaultStorageService.cs b/journal-master/journal/Journal.Core/Services/Vault/VaultStorageService.cs similarity index 99% rename from journal-master/journal/Journal.Core/Services/VaultStorageService.cs rename to journal-master/journal/Journal.Core/Services/Vault/VaultStorageService.cs index b9ff614..f2dd18f 100644 --- a/journal-master/journal/Journal.Core/Services/VaultStorageService.cs +++ b/journal-master/journal/Journal.Core/Services/Vault/VaultStorageService.cs @@ -2,9 +2,8 @@ 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(IVaultCryptoService crypto) : IVaultStorageService { diff --git a/journal-master/journal/Journal.Sidecar/App.cs b/journal-master/journal/Journal.Sidecar/App.cs index cb0c2f1..e3246b5 100644 --- a/journal-master/journal/Journal.Sidecar/App.cs +++ b/journal-master/journal/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-master/journal/Journal.SmokeTests/Program.cs b/journal-master/journal/Journal.SmokeTests/Program.cs index ea14e8b..2360538 100644 --- a/journal-master/journal/Journal.SmokeTests/Program.cs +++ b/journal-master/journal/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,75 +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"), - 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()); -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."); @@ -185,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-master/journal/README.md b/journal-master/journal/README.md index b9744b1..6330ffb 100644 --- a/journal-master/journal/README.md +++ b/journal-master/journal/README.md @@ -123,17 +123,20 @@ The `backend/` directory contains a .NET 10 implementation that provides the sam ``` Entry (thin command dispatcher) - ├── IFragmentService → FragmentService → IFragmentRepository - ├── IEntryFileService → EntryFileService → IEntryFileRepository - ├── IEntrySearchService → EntrySearchService - ├── IVaultStorageService → VaultStorageService → IVaultCryptoService - ├── IJournalDatabaseService → JournalDatabaseService (SQLCipher) - ├── IAiService → PythonSidecarAiService | DisabledAiService - ├── ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService - ├── CommandLogger - └── IJournalConfigService → JournalConfigService + ├── 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 ``` +Services are organized under `Journal.Core/Services/` in domain-specific subdirectories, each with its own namespace (e.g. `Journal.Core.Services.Ai`). + ### Build & Run ```bash @@ -179,6 +182,8 @@ dotnet run --project Journal.SmokeTests - 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 diff --git a/journal-master/journal/REFACTORING_SUMMARY.md b/journal-master/journal/REFACTORING_SUMMARY.md index b4c7cc7..bda1ebd 100644 --- a/journal-master/journal/REFACTORING_SUMMARY.md +++ b/journal-master/journal/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)