More of J's changes.

This commit is contained in:
stan44 2026-02-23 22:48:31 -06:00
parent ac3fc7b302
commit b3b27a99e9
40 changed files with 635 additions and 392 deletions

1
.gitignore vendored
View File

@ -35,4 +35,3 @@ logs/
.journal-sidecar/ .journal-sidecar/
.nuget .nuget
_hybrid_tmp*/ _hybrid_tmp*/
journal-master/journal/tls_registry_backup_before_fix.txt

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;
@ -72,18 +81,18 @@ public class Entry(
switch (action) switch (action)
{ {
case "fragments.list": case "fragments.list":
result = await _fragments.GetAllAsync(); result = _fragments.GetAll();
break; break;
case "fragments.get": case "fragments.get":
if (!Guid.TryParse(cmd.Id, out var getId)) if (!Guid.TryParse(cmd.Id, out var getId))
return Error("Invalid or missing id"); return Error("Invalid or missing id");
result = await _fragments.GetByIdAsync(getId); result = _fragments.GetById(getId);
break; break;
case "fragments.create": case "fragments.create":
var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload); var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload);
if (createDto is null) if (createDto is null)
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = await _fragments.CreateAsync(createDto); result = _fragments.Create(createDto);
break; break;
case "fragments.update": case "fragments.update":
if (!Guid.TryParse(cmd.Id, out var updateId)) if (!Guid.TryParse(cmd.Id, out var updateId))
@ -91,15 +100,15 @@ public class Entry(
var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload); var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload);
if (updateDto is null) if (updateDto is null)
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = await _fragments.UpdateAsync(updateId, updateDto); result = _fragments.Update(updateId, updateDto);
break; break;
case "fragments.delete": case "fragments.delete":
if (!Guid.TryParse(cmd.Id, out var deleteId)) if (!Guid.TryParse(cmd.Id, out var deleteId))
return Error("Invalid or missing id"); return Error("Invalid or missing id");
result = await _fragments.RemoveAsync(deleteId); result = _fragments.Remove(deleteId);
break; break;
case "fragments.search": case "fragments.search":
result = await _fragments.SearchAsync(cmd.Type, cmd.Tag); result = _fragments.Search(cmd.Type, cmd.Tag);
break; break;
case "search.entries": case "search.entries":
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload); var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
@ -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

@ -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<Fragment> _store;
public FileFragmentRepository() : this(storagePath: null)
{
}
public FileFragmentRepository(string? storagePath)
{
_storagePath = ResolveStoragePath(storagePath);
_store = LoadStore(_storagePath);
}
public Task<List<Fragment>> GetAllAsync()
{
lock (_lock)
{
return Task.FromResult(_store.ToList());
}
}
public Task<Fragment?> 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<bool> 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<bool> UpdateAsync(
Guid id,
string? type = null,
string? description = null,
IEnumerable<string>? 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<List<Fragment>> GetByTagAsync(string tag)
{
var q = tag?.Trim();
if (string.IsNullOrWhiteSpace(q))
return Task.FromResult(new List<Fragment>());
lock (_lock)
{
var items = _store
.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>());
lock (_lock)
{
var items = _store
.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 qType = type?.Trim();
var qTag = tag?.Trim();
lock (_lock)
{
IEnumerable<Fragment> 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<Fragment> 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<List<FragmentDocument>>(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<string> Tags { get; init; } = [];
}
}

View File

@ -4,12 +4,12 @@ namespace Journal.Core.Repositories;
public interface IFragmentRepository public interface IFragmentRepository
{ {
Task<List<Fragment>> GetAllAsync(); List<Fragment> GetAll();
Task<Fragment?> GetByIdAsync(Guid id); Fragment? GetById(Guid id);
Task AddAsync(Fragment fragment); void Add(Fragment fragment);
Task<bool> RemoveAsync(Guid id); bool Remove(Guid id);
Task<bool> UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null); bool Update(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null);
Task<List<Fragment>> GetByTagAsync(string tag); List<Fragment> GetByTag(string tag);
Task<List<Fragment>> GetByTypeAsync(string type); List<Fragment> GetByType(string type);
Task<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
} }

View File

@ -7,23 +7,23 @@ public class InMemoryFragmentRepository : IFragmentRepository
private readonly List<Fragment> _store = []; private readonly List<Fragment> _store = [];
private readonly Lock _lock = new(); private readonly Lock _lock = new();
public Task<List<Fragment>> GetAllAsync() public List<Fragment> GetAll()
{ {
lock (_lock) lock (_lock)
{ {
return Task.FromResult(_store.ToList()); return _store.ToList();
} }
} }
public Task<Fragment?> GetByIdAsync(Guid id) public Fragment? GetById(Guid id)
{ {
lock (_lock) 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)); if (fragment is null) throw new ArgumentNullException(nameof(fragment));
lock (_lock) lock (_lock)
@ -39,25 +39,24 @@ public class InMemoryFragmentRepository : IFragmentRepository
_store.Add(fragment); _store.Add(fragment);
} }
return Task.CompletedTask;
} }
public Task<bool> RemoveAsync(Guid id) public bool Remove(Guid id)
{ {
lock (_lock) lock (_lock)
{ {
var item = _store.FirstOrDefault(f => f.Id == id); var item = _store.FirstOrDefault(f => f.Id == id);
if (item is null) return Task.FromResult(false); if (item is null) return false;
return Task.FromResult(_store.Remove(item)); return _store.Remove(item);
} }
} }
public Task<bool> UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null) public bool Update(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null)
{ {
lock (_lock) lock (_lock)
{ {
var item = _store.FirstOrDefault(f => f.Id == id); 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) if (type != null)
{ {
@ -81,31 +80,31 @@ public class InMemoryFragmentRepository : IFragmentRepository
if (time.HasValue) if (time.HasValue)
item.Time = time.Value; item.Time = time.Value;
return Task.FromResult(true); return true;
} }
} }
public Task<List<Fragment>> GetByTagAsync(string tag) public List<Fragment> GetByTag(string tag)
{ {
var q = tag?.Trim(); var q = tag?.Trim();
if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List<Fragment>()); if (string.IsNullOrWhiteSpace(q)) return [];
lock (_lock) 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<List<Fragment>> GetByTypeAsync(string type) public List<Fragment> GetByType(string type)
{ {
var q = type?.Trim(); var q = type?.Trim();
if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List<Fragment>()); if (string.IsNullOrWhiteSpace(q)) return [];
lock (_lock) 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<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) public List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
{ {
var results = _store.AsEnumerable(); var results = _store.AsEnumerable();
var qType = type?.Trim(); var qType = type?.Trim();
@ -120,7 +119,7 @@ public class InMemoryFragmentRepository : IFragmentRepository
if (timeAfter.HasValue) if (timeAfter.HasValue)
results = results.Where(f => f.Time > timeAfter.Value); results = results.Where(f => f.Time > timeAfter.Value);
return Task.FromResult(results.ToList()); return results.ToList();
} }
} }
} }

View File

@ -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<Fragment> 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<string>? 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<Fragment> 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<Fragment> 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<Fragment> Search(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 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
{ {
@ -31,7 +32,7 @@ public sealed class PythonSidecarAiService : IAiService
: "ok"; : "ok";
var healthy = !payload.TryGetProperty("healthy", out var healthyNode) || var healthy = !payload.TryGetProperty("healthy", out var healthyNode) ||
healthyNode.ValueKind is JsonValueKind.True || 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); return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message);
} }

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

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,7 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services; namespace Journal.Core.Services.Database;
public interface IJournalDatabaseService public interface IJournalDatabaseService
{ {
@ -8,6 +9,8 @@ public interface IJournalDatabaseService
byte[] DeriveDatabaseKey(string password); byte[] DeriveDatabaseKey(string password);
string BuildPragmaKeyStatement(string password); string BuildPragmaKeyStatement(string password);
IReadOnlyDictionary<string, string> GetSchemaStatements(); IReadOnlyDictionary<string, string> GetSchemaStatements();
SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null);
void EnsureSchema(SqliteConnection connection);
string WriteSchemaBootstrap(string? dataDirectory = null); string WriteSchemaBootstrap(string? dataDirectory = null);
JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null); JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null);
JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null); JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null);

View File

@ -1,16 +1,17 @@
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
{ {
public const int KeySize = 32; public const int KeySize = 32;
public const int Iterations = 600_000; 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 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 bool _sqliteInitialized;
private static readonly IReadOnlyList<string> RequiredSchemaTables = private static readonly IReadOnlyList<string> RequiredSchemaTables =
["entries", "sections", "fragments", "tags", "fragment_tags"]; ["entries", "sections", "fragments", "tags", "fragment_tags"];
@ -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,
@ -133,7 +135,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
using var connection = OpenEncryptedConnection(password, directory); using var connection = OpenEncryptedConnection(password, directory);
CreateSchema(connection); EnsureSchema(connection);
var runtimeReady = HasRequiredTables(connection); var runtimeReady = HasRequiredTables(connection);
var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length; 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)) if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("Password cannot be empty.", nameof(password)); throw new ArgumentException("Password cannot be empty.", nameof(password));
@ -185,7 +187,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
return connection; return connection;
} }
private void CreateSchema(SqliteConnection connection) public void EnsureSchema(SqliteConnection connection)
{ {
foreach (var statement in GetSchemaStatements().Values) foreach (var statement in GetSchemaStatements().Values)
{ {
@ -215,7 +217,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
try try
{ {
using var connection = OpenEncryptedConnection(password, dataDirectory); using var connection = OpenEncryptedConnection(password, dataDirectory);
CreateSchema(connection); EnsureSchema(connection);
var ready = HasRequiredTables(connection); var ready = HasRequiredTables(connection);
return ready return ready
? (true, "SQLCipher runtime is available and schema tables are present.") ? (true, "SQLCipher runtime is available and schema tables are present.")

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
{ {
@ -17,7 +17,7 @@ public class FragmentService(IFragmentRepository repo) : IFragmentService
f.Tags != null ? [.. f.Tags] : [] f.Tags != null ? [.. f.Tags] : []
); );
public async Task<FragmentDto> CreateAsync(CreateFragmentDto dto) public FragmentDto Create(CreateFragmentDto dto)
{ {
ArgumentNullException.ThrowIfNull(dto); ArgumentNullException.ThrowIfNull(dto);
@ -28,11 +28,11 @@ public class FragmentService(IFragmentRepository repo) : IFragmentService
if (dto.Tags != null) if (dto.Tags != null)
f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())]; f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())];
await _repo.AddAsync(f); _repo.Add(f);
return Map(f); return Map(f);
} }
public async Task<bool> UpdateAsync(Guid id, UpdateFragmentDto dto) public bool Update(Guid id, UpdateFragmentDto dto)
{ {
ArgumentNullException.ThrowIfNull(dto); ArgumentNullException.ThrowIfNull(dto);
if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type)) if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type))
@ -44,38 +44,38 @@ public class FragmentService(IFragmentRepository repo) : IFragmentService
var description = dto.Description?.Trim(); var description = dto.Description?.Trim();
var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList(); 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<bool> RemoveAsync(Guid id) => _repo.RemoveAsync(id); public bool Remove(Guid id) => _repo.Remove(id);
public async Task<List<FragmentDto>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) public List<FragmentDto> 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)]; return [.. items.Select(Map)];
} }
public async Task<List<FragmentDto>> GetByTagAsync(string tag) public List<FragmentDto> GetByTag(string tag)
{ {
var items = await _repo.GetByTagAsync(tag); var items = _repo.GetByTag(tag);
return [.. items.Select(Map)]; return [.. items.Select(Map)];
} }
public async Task<List<FragmentDto>> GetByTypeAsync(string type) public List<FragmentDto> GetByType(string type)
{ {
var items = await _repo.GetByTypeAsync(type); var items = _repo.GetByType(type);
return [.. items.Select(Map)]; return [.. items.Select(Map)];
} }
public async Task<List<FragmentDto>> GetAllAsync() public List<FragmentDto> GetAll()
{ {
var items = await _repo.GetAllAsync(); var items = _repo.GetAll();
return [.. items.Select(Map)]; return [.. items.Select(Map)];
} }
public async Task<FragmentDto?> 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); return f is null ? null : Map(f);
} }
} }

View File

@ -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<FragmentDto> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
List<FragmentDto> GetByTag(string tag);
List<FragmentDto> GetByType(string type);
List<FragmentDto> GetAll();
FragmentDto? GetById(Guid id);
}

View File

@ -1,15 +0,0 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
public interface IFragmentService
{
Task<FragmentDto> CreateAsync(CreateFragmentDto dto);
Task<bool> UpdateAsync(Guid id, UpdateFragmentDto dto);
Task<bool> RemoveAsync(Guid id);
Task<List<FragmentDto>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
Task<List<FragmentDto>> GetByTagAsync(string tag);
Task<List<FragmentDto>> GetByTypeAsync(string type);
Task<List<FragmentDto>> GetAllAsync();
Task<FragmentDto?> GetByIdAsync(Guid id);
}

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
{ {
@ -11,10 +11,7 @@ public sealed class CommandLogger
EmitLog("information", action, correlationId, "start", redactedPayload); EmitLog("information", action, correlationId, "start", redactedPayload);
} }
public static void LogSuccess(string action, string correlationId) public static void LogSuccess(string action, string correlationId) => EmitLog("information", action, correlationId, "success");
{
EmitLog("information", action, correlationId, "success");
}
public static void LogFailure(string action, string correlationId, string errorType, string? message = null) public static void LogFailure(string action, string correlationId, string errorType, string? message = null)
{ {

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

@ -2,9 +2,8 @@ using System.IO.Compression;
using System.Globalization; using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading;
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,75 +114,95 @@ 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());
static async Task TestCreateTrimsAsync() static Task TestCreateTrimsAsync()
{ {
var service = NewService(); 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.Type == "!TRIGGER", "Type should be trimmed.");
Assert(created.Description == "stomach drop", "Description should be trimmed."); Assert(created.Description == "stomach drop", "Description should be trimmed.");
Assert(created.Tags.Count == 2, "Expected two normalized tags."); Assert(created.Tags.Count == 2, "Expected two normalized tags.");
Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved."); 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 service = NewService();
var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "one")); var created = service.Create(new CreateFragmentDto("!TRIGGER", "one"));
var ok = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "]));
Assert(ok, "Expected update to succeed."); 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 is not null, "Updated fragment should exist.");
Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored."); Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored.");
Assert(updated.Description == "two", "Updated description 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."); 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 service = NewService();
var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "desc")); var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc"));
try try
{ {
_ = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " ")); _ = service.Update(created.Id, new UpdateFragmentDto(Type: " "));
} }
catch (ValidationException) catch (ValidationException)
{ {
return; return Task.CompletedTask;
} }
throw new InvalidOperationException("Expected ValidationException for whitespace type update."); 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 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 service1 = new FragmentService(repo1); var configService = new JournalConfigService();
var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); 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 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 is not null, "Expected fragment to persist across repository instances.");
Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch."); Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch.");
@ -185,6 +213,8 @@ static async Task TestFileRepositoryPersistsAsync()
if (Directory.Exists(tempRoot)) if (Directory.Exists(tempRoot))
Directory.Delete(tempRoot, recursive: true); Directory.Delete(tempRoot, recursive: true);
} }
return Task.CompletedTask;
} }
static Task TestJournalEntryModelAsync() static Task TestJournalEntryModelAsync()

View File

@ -123,17 +123,20 @@ The `backend/` directory contains a .NET 10 implementation that provides the sam
``` ```
Entry (thin command dispatcher) Entry (thin command dispatcher)
├── IFragmentService → FragmentService → IFragmentRepository ├── Fragments/ IFragmentService → FragmentService → IFragmentRepository (SQLCipher)
├── IEntryFileService → EntryFileService → IEntryFileRepository ├── Entries/ IEntryFileService, IEntrySearchService, JournalParser, HtmlSanitizer
├── IEntrySearchService → EntrySearchService ├── Vault/ IVaultStorageService → VaultStorageService → IVaultCryptoService
├── IVaultStorageService → VaultStorageService → IVaultCryptoService ├── Database/ IJournalDatabaseService (SQLCipher schema/key derivation)
├── IJournalDatabaseService → JournalDatabaseService (SQLCipher) │ IDatabaseSessionService (encrypted connection lifecycle after auth)
├── IAiService → PythonSidecarAiService | DisabledAiService ├── Ai/ IAiService → PythonSidecarAiService | DisabledAiService
├── ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService ├── Speech/ ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService
├── CommandLogger ├── Sidecar/ PythonSidecarClient (shared Python process I/O), SidecarCli
└── IJournalConfigService → JournalConfigService ├── 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 ### Build & Run
```bash ```bash
@ -179,6 +182,8 @@ dotnet run --project Journal.SmokeTests
- Vault: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iterations) - Vault: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iterations)
- Database: SQLCipher with PBKDF2-derived key - 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 - Wire format matches the Python implementation for cross-language parity
## Notes ## Notes

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)