More of J's changes.
This commit is contained in:
parent
ac3fc7b302
commit
b3b27a99e9
1
.gitignore
vendored
1
.gitignore
vendored
@ -35,4 +35,3 @@ logs/
|
|||||||
.journal-sidecar/
|
.journal-sidecar/
|
||||||
.nuget
|
.nuget
|
||||||
_hybrid_tmp*/
|
_hybrid_tmp*/
|
||||||
journal-master/journal/tls_registry_backup_before_fix.txt
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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; } = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>();
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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.")
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Vault;
|
||||||
|
|
||||||
public interface IVaultCryptoService
|
public interface IVaultCryptoService
|
||||||
{
|
{
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Vault;
|
||||||
|
|
||||||
public interface IVaultStorageService
|
public interface IVaultStorageService
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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()
|
||||||
NewService(),
|
{
|
||||||
new EntrySearchService(),
|
var dbService = new JournalDatabaseService(new JournalConfigService());
|
||||||
new VaultStorageService(new VaultCryptoService()),
|
var session = new DatabaseSessionService(dbService);
|
||||||
new JournalDatabaseService(new JournalConfigService()),
|
return new Entry(
|
||||||
new JournalConfigService(),
|
NewService(),
|
||||||
new DisabledAiService("none"),
|
new EntrySearchService(),
|
||||||
new DisabledSpeechBridgeService("none"),
|
new VaultStorageService(new VaultCryptoService()),
|
||||||
new EntryFileService(new DiskEntryFileRepository()),
|
dbService,
|
||||||
new CommandLogger());
|
session,
|
||||||
|
new JournalConfigService(),
|
||||||
|
new DisabledAiService("none"),
|
||||||
|
new DisabledSpeechBridgeService("none"),
|
||||||
|
new EntryFileService(new DiskEntryFileRepository()),
|
||||||
|
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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user