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 <agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-02-23 21:58:45 -06:00
parent 8a29bd4bd1
commit f06c1d15bb
34 changed files with 570 additions and 66 deletions

View File

@ -3,7 +3,14 @@ using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Journal.Core.Dtos; using Journal.Core.Dtos;
using Journal.Core.Models; 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; namespace Journal.Core;
@ -12,6 +19,7 @@ public class Entry(
IEntrySearchService entrySearch, IEntrySearchService entrySearch,
IVaultStorageService vaultStorage, IVaultStorageService vaultStorage,
IJournalDatabaseService database, IJournalDatabaseService database,
IDatabaseSessionService databaseSession,
IJournalConfigService config, IJournalConfigService config,
IAiService ai, IAiService ai,
ISpeechBridgeService speech, ISpeechBridgeService speech,
@ -22,6 +30,7 @@ public class Entry(
private readonly IEntrySearchService _entrySearch = entrySearch; private readonly IEntrySearchService _entrySearch = entrySearch;
private readonly IVaultStorageService _vaultStorage = vaultStorage; private readonly IVaultStorageService _vaultStorage = vaultStorage;
private readonly IJournalDatabaseService _database = database; private readonly IJournalDatabaseService _database = database;
private readonly IDatabaseSessionService _databaseSession = databaseSession;
private readonly IJournalConfigService _config = config; private readonly IJournalConfigService _config = config;
private readonly IAiService _ai = ai; private readonly IAiService _ai = ai;
private readonly ISpeechBridgeService _speech = speech; private readonly ISpeechBridgeService _speech = speech;
@ -202,6 +211,7 @@ public class Entry(
if (loadPayload is null) if (loadPayload is null)
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory); result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory);
break; break;
case "vault.save_current_month": case "vault.save_current_month":
var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload); var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload);
@ -245,6 +255,7 @@ public class Entry(
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password)) if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory); result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
_databaseSession.SetPassword(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
break; break;
default: default:
CommandLogger.LogFailure(action, correlationId, "unknown_action"); CommandLogger.LogFailure(action, correlationId, "unknown_action");

View File

@ -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<List<Fragment>> GetAllAsync()
{
var conn = _session.GetConnection();
var fragments = ReadAllFragments(conn);
return Task.FromResult(fragments);
}
public Task<Fragment?> 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<bool> RemoveAsync(Guid id)
{
var conn = _session.GetConnection();
var deleted = DeleteFragment(conn, id);
return Task.FromResult(deleted);
}
public Task<bool> UpdateAsync(
Guid id,
string? type = null,
string? description = null,
IEnumerable<string>? 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<List<Fragment>> GetByTagAsync(string tag)
{
var q = tag?.Trim();
if (string.IsNullOrWhiteSpace(q))
return Task.FromResult(new List<Fragment>());
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<List<Fragment>> GetByTypeAsync(string type)
{
var q = type?.Trim();
if (string.IsNullOrWhiteSpace(q))
return Task.FromResult(new List<Fragment>());
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<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
{
var conn = _session.GetConnection();
IEnumerable<Fragment> 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<Fragment> ReadAllFragments(SqliteConnection conn)
{
var fragments = new List<Fragment>();
var rowIds = new List<long>();
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<string> 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<string>();
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<string> 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())];
}
}

View File

@ -1,6 +1,14 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Journal.Core.Repositories; 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; namespace Journal.Core;
@ -8,7 +16,8 @@ public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddFragmentServices(this IServiceCollection services) public static IServiceCollection AddFragmentServices(this IServiceCollection services)
{ {
services.AddSingleton<IFragmentRepository, FileFragmentRepository>(); services.AddSingleton<IDatabaseSessionService, DatabaseSessionService>();
services.AddSingleton<IFragmentRepository, SqliteFragmentRepository>();
services.AddSingleton<IJournalConfigService, JournalConfigService>(); services.AddSingleton<IJournalConfigService, JournalConfigService>();
services.AddTransient<IFragmentService, FragmentService>(); services.AddTransient<IFragmentService, FragmentService>();
services.AddTransient<IEntrySearchService, EntrySearchService>(); services.AddTransient<IEntrySearchService, EntrySearchService>();

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; 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 public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Ai;
public interface IAiService public interface IAiService
{ {

View File

@ -1,8 +1,9 @@
using System.Text.Json; using System.Text.Json;
using Journal.Core.Dtos; using Journal.Core.Dtos;
using Journal.Core.Models; using Journal.Core.Models;
using Journal.Core.Services.Sidecar;
namespace Journal.Core.Services; namespace Journal.Core.Services.Ai;
public sealed class PythonSidecarAiService : IAiService public sealed class PythonSidecarAiService : IAiService
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Models; using Journal.Core.Models;
namespace Journal.Core.Services; namespace Journal.Core.Services.Config;
public interface IJournalConfigService public interface IJournalConfigService
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Models; using Journal.Core.Models;
namespace Journal.Core.Services; namespace Journal.Core.Services.Config;
public sealed class JournalConfigService : IJournalConfigService public sealed class JournalConfigService : IJournalConfigService
{ {

View File

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

View File

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

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Database;
public interface IJournalDatabaseService public interface IJournalDatabaseService
{ {

View File

@ -1,9 +1,10 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Journal.Core.Dtos; using Journal.Core.Dtos;
using Journal.Core.Services.Config;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace Journal.Core.Services; namespace Journal.Core.Services.Database;
public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService
{ {
@ -68,7 +69,8 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
["fragments"] = """ ["fragments"] = """
CREATE TABLE IF NOT EXISTS fragments ( CREATE TABLE IF NOT EXISTS fragments (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL, guid TEXT UNIQUE,
entry_id INTEGER,
type TEXT NOT NULL, type TEXT NOT NULL,
description TEXT, description TEXT,
time TEXT, time TEXT,

View File

@ -1,7 +1,7 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
using Journal.Core.Repositories; using Journal.Core.Repositories;
namespace Journal.Core.Services; namespace Journal.Core.Services.Entries;
public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService
{ {

View File

@ -1,7 +1,7 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
using System.Globalization; using System.Globalization;
namespace Journal.Core.Services; namespace Journal.Core.Services.Entries;
public class EntrySearchService : IEntrySearchService public class EntrySearchService : IEntrySearchService
{ {

View File

@ -1,7 +1,7 @@
using System.Net; using System.Net;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace Journal.Core.Services; namespace Journal.Core.Services.Entries;
public static class HtmlSanitizer public static class HtmlSanitizer
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Entries;
public interface IEntryFileService public interface IEntryFileService
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Entries;
public interface IEntrySearchService public interface IEntrySearchService
{ {

View File

@ -1,7 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Journal.Core.Models; using Journal.Core.Models;
namespace Journal.Core.Services; namespace Journal.Core.Services.Entries;
public static partial class JournalParser public static partial class JournalParser
{ {

View File

@ -3,7 +3,7 @@ using Journal.Core.Dtos;
using Journal.Core.Models; using Journal.Core.Models;
using Journal.Core.Repositories; using Journal.Core.Repositories;
namespace Journal.Core.Services; namespace Journal.Core.Services.Fragments;
public class FragmentService(IFragmentRepository repo) : IFragmentService public class FragmentService(IFragmentRepository repo) : IFragmentService
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Fragments;
public interface IFragmentService public interface IFragmentService
{ {

View File

@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
namespace Journal.Core.Services; namespace Journal.Core.Services.Logging;
public sealed class CommandLogger public sealed class CommandLogger
{ {

View File

@ -1,6 +1,6 @@
using System.Text.Json; using System.Text.Json;
namespace Journal.Core.Services; namespace Journal.Core.Services.Logging;
public static class LogRedactor public static class LogRedactor
{ {

View File

@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
using Journal.Core.Models; using Journal.Core.Models;
namespace Journal.Core.Services; namespace Journal.Core.Services.Sidecar;
public sealed class PythonSidecarClient(JournalConfig config) public sealed class PythonSidecarClient(JournalConfig config)
{ {

View File

@ -1,7 +1,10 @@
using System.Text; using System.Text;
using Journal.Core.Dtos; 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) public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; 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 public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService
{ {

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Speech;
public interface ISpeechBridgeService public interface ISpeechBridgeService
{ {

View File

@ -1,8 +1,9 @@
using System.Text.Json; using System.Text.Json;
using Journal.Core.Dtos; using Journal.Core.Dtos;
using Journal.Core.Models; using Journal.Core.Models;
using Journal.Core.Services.Sidecar;
namespace Journal.Core.Services; namespace Journal.Core.Services.Speech;
public sealed class PythonSidecarSpeechService : ISpeechBridgeService public sealed class PythonSidecarSpeechService : ISpeechBridgeService
{ {

View File

@ -1,4 +1,4 @@
namespace Journal.Core.Services; namespace Journal.Core.Services.Vault;
public interface IVaultCryptoService public interface IVaultCryptoService
{ {

View File

@ -1,4 +1,4 @@
namespace Journal.Core.Services; namespace Journal.Core.Services.Vault;
public interface IVaultStorageService public interface IVaultStorageService
{ {

View File

@ -1,7 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
namespace Journal.Core.Services; namespace Journal.Core.Services.Vault;
public class VaultCryptoService : IVaultCryptoService public class VaultCryptoService : IVaultCryptoService
{ {

View File

@ -3,7 +3,7 @@ using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
namespace Journal.Core.Services; namespace Journal.Core.Services.Vault;
public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService
{ {

View File

@ -1,6 +1,6 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Journal.Core; using Journal.Core;
using Journal.Core.Services; using Journal.Core.Services.Sidecar;
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddFragmentServices(); services.AddFragmentServices();

View File

@ -6,7 +6,15 @@ using Journal.Core;
using Journal.Core.Dtos; using Journal.Core.Dtos;
using Journal.Core.Models; using Journal.Core.Models;
using Journal.Core.Repositories; 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<Task> Run)> var tests = new List<(string Name, Func<Task> Run)>
{ {
@ -106,16 +114,22 @@ static FragmentService NewService()
return new FragmentService(repo); return new FragmentService(repo);
} }
static Entry NewEntry() => new( static Entry NewEntry()
{
var dbService = new JournalDatabaseService(new JournalConfigService());
var session = new DatabaseSessionService(dbService);
return new Entry(
NewService(), NewService(),
new EntrySearchService(), new EntrySearchService(),
new VaultStorageService(new VaultCryptoService()), new VaultStorageService(new VaultCryptoService()),
new JournalDatabaseService(new JournalConfigService()), dbService,
session,
new JournalConfigService(), new JournalConfigService(),
new DisabledAiService("none"), new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"), new DisabledSpeechBridgeService("none"),
new EntryFileService(new DiskEntryFileRepository()), new EntryFileService(new DiskEntryFileRepository()),
new CommandLogger()); new CommandLogger());
}
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService()); static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
@ -164,15 +178,27 @@ static async Task TestUpdateRejectsWhitespaceTypeAsync()
static async Task TestFileRepositoryPersistsAsync() static async Task TestFileRepositoryPersistsAsync()
{ {
var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")); 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 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 service1 = new FragmentService(repo1);
var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); 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 service2 = new FragmentService(repo2);
var loaded = await service2.GetByIdAsync(created.Id); var loaded = await service2.GetByIdAsync(created.Id);

View File

@ -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) ### 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. `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 ## Files Created
- `Journal.Core/Services/HtmlSanitizer.cs` - `Journal.Core/Services/Entries/HtmlSanitizer.cs`
- `Journal.Core/Services/CommandLogger.cs` - `Journal.Core/Services/Logging/CommandLogger.cs`
- `Journal.Core/Services/IEntryFileService.cs` - `Journal.Core/Services/Entries/IEntryFileService.cs`
- `Journal.Core/Services/EntryFileService.cs` - `Journal.Core/Services/Entries/EntryFileService.cs`
- `Journal.Core/Services/PythonSidecarClient.cs` - `Journal.Core/Services/Sidecar/PythonSidecarClient.cs`
- `Journal.Core/Repositories/IEntryFileRepository.cs` - `Journal.Core/Repositories/IEntryFileRepository.cs`
- `Journal.Core/Repositories/DiskEntryFileRepository.cs` - `Journal.Core/Repositories/DiskEntryFileRepository.cs`
- `Journal.Core/Repositories/SqliteFragmentRepository.cs`
- `Journal.Core/Dtos/CommandDtos.cs` - `Journal.Core/Dtos/CommandDtos.cs`
- `Journal.Core/Dtos/DatabaseDtos.cs` - `Journal.Core/Dtos/DatabaseDtos.cs`
- `Journal.Core/Services/Database/IDatabaseSessionService.cs`
- `Journal.Core/Services/Database/DatabaseSessionService.cs`
## Files Modified ## Files Modified
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher - `Journal.Core/Entry.cs` — slimmed to thin dispatcher, wired `IDatabaseSessionService`
- `Journal.Core/Services/PythonSidecarAiService.cs` — delegates to PythonSidecarClient - `Journal.Core/Services/Ai/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient - `Journal.Core/Services/Speech/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/IJournalDatabaseService.cs` — result records moved to Dtos - `Journal.Core/Services/Database/IJournalDatabaseService.cs` — result records moved to Dtos
- `Journal.Core/Services/JournalDatabaseService.cs` — added Dtos using - `Journal.Core/Services/Database/JournalDatabaseService.cs` — schema updated (nullable entry_id, guid column)
- `Journal.Core/ServiceCollectionExtensions.cs` — registers new services and repository - `Journal.Core/ServiceCollectionExtensions.cs` — registers all new services, updated namespaces
- `Journal.SmokeTests/Program.cs` — updated NewEntry() with new dependencies - `Journal.SmokeTests/Program.cs` — updated with new dependencies and encrypted DB persistence test
- `Journal.Sidecar/App.cs` — updated namespace imports
## 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
## Verification ## Verification
- All 4 projects build successfully - 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)