Merge pull request 'development' (#1) from development into master

Reviewed-on: #1
This commit is contained in:
Jacob Schmidt 2026-02-23 22:59:01 -06:00
commit e2ede4b3e9
45 changed files with 1346 additions and 1164 deletions

View File

@ -0,0 +1,35 @@
namespace Journal.Core.Dtos;
internal sealed record VaultInitializePayload(string Password, string VaultDirectory);
internal sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
internal sealed record ClearDataPayload(string DataDirectory);
internal sealed record EntryListPayload(string? DataDirectory = null);
internal sealed record EntryLoadPayload(string FilePath);
public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
public sealed record EntryListItem(string FileName, string FilePath);
public sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
public sealed record EntrySaveResult(string FilePath);
internal sealed record DatabasePayload(string Password, string? DataDirectory = null);
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
internal sealed record AiChatPayload(string Prompt);
internal sealed record AiEmbedPayload(string Content);
internal sealed record SpeechTranscribePayload(
string? AudioBase64 = null,
string? Audio_Base64 = null,
string? Engine = null,
string? WhisperModel = null,
string? Whisper_Model = null,
string? Text = null,
int? SimulateDelayMs = null,
int? Simulate_Delay_Ms = null);
internal sealed record SearchEntriesPayload(
string DataDirectory,
string? Query = null,
string? Section = null,
string? StartDate = null,
string? EndDate = null,
List<string>? Tags = null,
List<string>? Types = null,
List<string>? Checked = null,
List<string>? Unchecked = null);

View File

@ -0,0 +1,18 @@
namespace Journal.Core.Dtos;
public sealed record JournalDatabaseStatus(
string DatabasePath,
int KeyLengthBytes,
int Iterations,
string KeyDerivation,
IReadOnlyList<string> SchemaTables,
string SchemaBootstrapPath,
bool RuntimeReady,
string RuntimeMessage);
public sealed record JournalDatabaseHydrationResult(
string DatabasePath,
string SchemaBootstrapPath,
int EntryFilesProcessed,
bool RuntimeReady,
string Message);

View File

@ -1,45 +1,45 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
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;
public class Entry public class Entry(
{
private readonly IFragmentService _fragments;
private readonly IEntrySearchService _entrySearch;
private readonly IVaultStorageService _vaultStorage;
private readonly IJournalDatabaseService _database;
private readonly IJournalConfigService _config;
private readonly IAiService _ai;
private readonly ISpeechBridgeService _speech;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public Entry(
IFragmentService fragments, IFragmentService fragments,
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,
IEntryFileService entryFiles,
CommandLogger logger)
{ {
_fragments = fragments; private readonly IFragmentService _fragments = fragments;
_entrySearch = entrySearch; private readonly IEntrySearchService _entrySearch = entrySearch;
_vaultStorage = vaultStorage; private readonly IVaultStorageService _vaultStorage = vaultStorage;
_database = database; private readonly IJournalDatabaseService _database = database;
_config = config; private readonly IDatabaseSessionService _databaseSession = databaseSession;
_ai = ai; private readonly IJournalConfigService _config = config;
_speech = speech; private readonly IAiService _ai = ai;
} private readonly ISpeechBridgeService _speech = speech;
private readonly IEntryFileService _entryFiles = entryFiles;
private readonly CommandLogger _logger = logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public async Task RunAsync() public async Task RunAsync()
{ {
@ -73,7 +73,7 @@ public class Entry
var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId) var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId)
? Guid.NewGuid().ToString("N") ? Guid.NewGuid().ToString("N")
: cmd.CorrelationId.Trim(); : cmd.CorrelationId.Trim();
LogStart(action, correlationId, cmd.Payload); CommandLogger.LogStart(action, correlationId, cmd.Payload);
object? result; object? result;
try try
@ -81,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))
@ -100,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);
@ -131,19 +131,19 @@ public class Entry
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory) var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
? listPayload.DataDirectory ? listPayload.DataDirectory
: _config.Current.DataDirectory; : _config.Current.DataDirectory;
result = ListEntries(listDataDirectory); result = _entryFiles.ListEntries(listDataDirectory);
break; break;
case "entries.load": case "entries.load":
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload); var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath)) if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = LoadEntry(loadEntryPayload.FilePath); result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
break; break;
case "entries.save": case "entries.save":
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload); var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content)) if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory); result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
break; break;
case "config.get": case "config.get":
result = _config.Current; result = _config.Current;
@ -211,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);
@ -254,124 +255,56 @@ 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:
LogFailure(action, correlationId, "unknown_action"); CommandLogger.LogFailure(action, correlationId, "unknown_action");
return Error($"Unknown action: {action}"); return Error($"Unknown action: {action}");
} }
} }
catch (JsonException) catch (JsonException)
{ {
LogFailure(action, correlationId, "invalid_payload_json"); CommandLogger.LogFailure(action, correlationId, "invalid_payload_json");
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
LogFailure(action, correlationId, "validation", ex.Message); CommandLogger.LogFailure(action, correlationId, "validation", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
LogFailure(action, correlationId, "argument", ex.Message); CommandLogger.LogFailure(action, correlationId, "argument", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (TimeoutException ex) catch (TimeoutException ex)
{ {
LogFailure(action, correlationId, "timeout", ex.Message); CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
LogFailure(action, correlationId, "invalid_operation", ex.Message); CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (FileNotFoundException ex) catch (FileNotFoundException ex)
{ {
LogFailure(action, correlationId, "not_found", ex.Message); CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch catch
{ {
LogFailure(action, correlationId, "internal_error"); CommandLogger.LogFailure(action, correlationId, "internal_error");
return Error("Internal error"); return Error("Internal error");
} }
LogSuccess(action, correlationId); CommandLogger.LogSuccess(action, correlationId);
return JsonSerializer.Serialize(new { ok = true, data = result }); return JsonSerializer.Serialize(new { ok = true, data = result });
} }
private static string Error(string message) private static string Error(string message)
=> JsonSerializer.Serialize(new { ok = false, error = message }); => JsonSerializer.Serialize(new { ok = false, error = message });
private void LogStart(string action, string correlationId, JsonElement? payload)
{
var redactedPayload = LogRedactor.RedactPayload(payload);
EmitLog("information", action, correlationId, "start", redactedPayload);
}
private void LogSuccess(string action, string correlationId)
{
EmitLog("information", action, correlationId, "success");
}
private void LogFailure(string action, string correlationId, string errorType, string? message = null)
{
var details = string.IsNullOrWhiteSpace(message)
? ""
: (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)");
EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details);
}
private static void EmitLog(
string level,
string action,
string correlationId,
string outcome,
object? payload = null,
string? errorType = null,
string? details = null)
{
if (!ShouldLog(level))
return;
var envelope = new
{
timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
level,
component = nameof(Entry),
action,
correlation_id = correlationId,
outcome,
error_type = errorType,
details,
payload
};
Console.Error.WriteLine(JsonSerializer.Serialize(envelope));
}
private static bool ShouldLog(string level)
{
var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning")
.Trim()
.ToLowerInvariant();
var configuredRank = LogLevelRank(configured);
var incomingRank = LogLevelRank(level);
return incomingRank >= configuredRank;
}
private static int LogLevelRank(string level) => level switch
{
"trace" => 0,
"debug" => 1,
"information" => 2,
"info" => 2,
"warning" => 3,
"warn" => 3,
"error" => 4,
"critical" => 5,
_ => 3
};
private static T? DeserializePayload<T>(JsonElement? payload) private static T? DeserializePayload<T>(JsonElement? payload)
{ {
if (payload is null) if (payload is null)
@ -395,153 +328,4 @@ public class Entry
throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time."); throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time.");
} }
private static IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
{
if (!Directory.Exists(dataDirectory))
return [];
return Directory.GetFiles(dataDirectory, "*.md")
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
.Select(path => new EntryListItem(
FileName: Path.GetFileName(path),
FilePath: Path.GetFullPath(path)))
.ToArray();
}
private static EntryLoadResult LoadEntry(string filePath)
{
var normalizedPath = Path.GetFullPath(filePath);
if (!File.Exists(normalizedPath))
throw new FileNotFoundException($"Entry file not found: {normalizedPath}");
var rawContent = StripRichHtml(File.ReadAllText(normalizedPath));
var fileStem = Path.GetFileNameWithoutExtension(normalizedPath);
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
return new EntryLoadResult(
Date: entry.Date,
FileName: Path.GetFileName(normalizedPath),
FilePath: normalizedPath,
RawContent: entry.RawContent);
}
private static EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
{
var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory);
var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
var sanitizedContent = StripRichHtml(payload.Content ?? "");
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase))
{
File.WriteAllText(targetPath, sanitizedContent);
return new EntrySaveResult(targetPath);
}
if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase))
{
File.AppendAllText(targetPath, "\n\n" + sanitizedContent.Trim());
return new EntrySaveResult(targetPath);
}
string finalContent;
if (File.Exists(targetPath))
{
var existingContent = File.ReadAllText(targetPath);
var fileStem = Path.GetFileNameWithoutExtension(targetPath);
var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem);
var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem);
existingEntry.MergeWith(newEntryData);
finalContent = existingEntry.ToMarkdown();
}
else
{
finalContent = sanitizedContent;
}
File.WriteAllText(targetPath, finalContent);
return new EntrySaveResult(targetPath);
}
private static string ResolveTargetPath(string? filePath, string defaultDataDirectory)
{
if (!string.IsNullOrWhiteSpace(filePath))
return Path.GetFullPath(filePath);
return Path.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"));
}
private static bool LooksLikeRichHtml(string content)
{
var lowered = content.ToLowerInvariant();
string[] markers =
[
"<p", "</p>", "<div", "<span", "<table", "<tr", "<td", "<li", "<ul", "<ol",
"style=", "font-family:", "-webkit-text-stroke"
];
if (markers.Any(marker => lowered.Contains(marker, StringComparison.Ordinal)))
return true;
return Regex.Matches(lowered, "</?[a-z][^>]*>").Count >= 8;
}
private static string StripRichHtml(string content)
{
if (string.IsNullOrWhiteSpace(content))
return content;
if (!LooksLikeRichHtml(content))
return content;
var text = content.Replace("\r\n", "\n").Replace("\r", "\n");
text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
text = Regex.Replace(text, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "</(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<li\\b[^>]*>", "\n- ", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "</li>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "</(td|th)>", " ", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<hr\\b[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline);
text = WebUtility.HtmlDecode(text)
.Replace('\u00a0', ' ')
.Replace("\u200b", "", StringComparison.Ordinal);
text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd()));
text = Regex.Replace(text, "[ \\t]{2,}", " ");
text = Regex.Replace(text, "\n{3,}", "\n\n").Trim();
return string.IsNullOrEmpty(text) ? content : text;
}
private sealed record VaultInitializePayload(string Password, string VaultDirectory);
private sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
private sealed record ClearDataPayload(string DataDirectory);
private sealed record EntryListPayload(string? DataDirectory = null);
private sealed record EntryLoadPayload(string FilePath);
private sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
private sealed record EntryListItem(string FileName, string FilePath);
private sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
private sealed record EntrySaveResult(string FilePath);
private sealed record DatabasePayload(string Password, string? DataDirectory = null);
private sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
private sealed record AiSummarizeAllPayload(List<string>? Entries);
private sealed record AiChatPayload(string Prompt);
private sealed record AiEmbedPayload(string Content);
private sealed record SpeechTranscribePayload(
string? AudioBase64 = null,
string? Audio_Base64 = null,
string? Engine = null,
string? WhisperModel = null,
string? Whisper_Model = null,
string? Text = null,
int? SimulateDelayMs = null,
int? Simulate_Delay_Ms = null);
private sealed record SearchEntriesPayload(
string DataDirectory,
string? Query = null,
string? Section = null,
string? StartDate = null,
string? EndDate = null,
List<string>? Tags = null,
List<string>? Types = null,
List<string>? Checked = null,
List<string>? Unchecked = null);
} }

View File

@ -0,0 +1,33 @@
namespace Journal.Core.Repositories;
public sealed class DiskEntryFileRepository : IEntryFileRepository
{
public IReadOnlyList<string> ListMarkdownFiles(string dataDirectory)
{
if (!Directory.Exists(dataDirectory))
return [];
return [.. Directory.GetFiles(dataDirectory, "*.md").OrderBy(Path.GetFileName, StringComparer.Ordinal)];
}
public string ReadFile(string filePath) => File.ReadAllText(filePath);
public void WriteFile(string filePath, string content) => File.WriteAllText(filePath, content);
public void AppendFile(string filePath, string content) => File.AppendAllText(filePath, content);
public bool FileExists(string filePath) => File.Exists(filePath);
public string GetFullPath(string filePath) => Path.GetFullPath(filePath);
public string GetFileName(string filePath) => Path.GetFileName(filePath);
public string GetFileNameWithoutExtension(string filePath) => Path.GetFileNameWithoutExtension(filePath);
public void EnsureDirectory(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
}
}

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

@ -0,0 +1,14 @@
namespace Journal.Core.Repositories;
public interface IEntryFileRepository
{
IReadOnlyList<string> ListMarkdownFiles(string dataDirectory);
string ReadFile(string filePath);
void WriteFile(string filePath, string content);
void AppendFile(string filePath, string content);
bool FileExists(string filePath);
string GetFullPath(string filePath);
string GetFileName(string filePath);
string GetFileNameWithoutExtension(string filePath);
void EnsureDirectory(string path);
}

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>();
@ -47,6 +56,9 @@ public static class ServiceCollectionExtensions
message: $"Python speech sidecar unavailable: {ex.Message}"); message: $"Python speech sidecar unavailable: {ex.Message}");
} }
}); });
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
services.AddSingleton<IEntryFileService, EntryFileService>();
services.AddSingleton<CommandLogger>();
services.AddSingleton<SidecarCli>(); services.AddSingleton<SidecarCli>();
return services; return services;
} }

View File

@ -1,19 +1,12 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Ai;
public sealed class DisabledAiService : IAiService public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService
{ {
private readonly string _provider; private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
private readonly string _message; private readonly string _message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim();
private readonly bool _healthy; private readonly bool _healthy = healthy;
public DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true)
{
_provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
_message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim();
_healthy = healthy;
}
public Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default) => public Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message)); Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message));

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

@ -0,0 +1,86 @@
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Sidecar;
namespace Journal.Core.Services.Ai;
public sealed class PythonSidecarAiService : IAiService
{
private readonly PythonSidecarClient _client;
public PythonSidecarAiService(JournalConfig config)
{
if (string.IsNullOrWhiteSpace(config.PythonAiSidecarPath))
throw new ArgumentException("Python AI sidecar path is required.");
if (!File.Exists(config.PythonAiSidecarPath))
throw new FileNotFoundException($"Python AI sidecar not found: {config.PythonAiSidecarPath}");
_client = new PythonSidecarClient(config);
}
public async Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default)
{
var data = await _client.SendAsync("health", payload: new { }, cancellationToken);
if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object)
return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok");
var provider = payload.TryGetProperty("provider", out var providerNode)
? providerNode.GetString() ?? "python-sidecar"
: "python-sidecar";
var message = payload.TryGetProperty("message", out var messageNode)
? messageNode.GetString() ?? "ok"
: "ok";
var healthy = !payload.TryGetProperty("healthy", out var healthyNode) ||
healthyNode.ValueKind is JsonValueKind.True ||
(healthyNode.ValueKind is not JsonValueKind.False);
return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message);
}
public async Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentException("Entry content is required.", nameof(content));
var data = await _client.SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken);
return data?.GetString() ?? "";
}
public async Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default)
{
entries ??= [];
var data = await _client.SendAsync("summarize_all", new { entries }, cancellationToken);
return data?.GetString() ?? "";
}
public async Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required.", nameof(prompt));
var data = await _client.SendAsync("chat", new { prompt }, cancellationToken);
return data?.GetString() ?? "";
}
public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentException("Content is required.", nameof(content));
var data = await _client.SendAsync("embed", new { content }, cancellationToken);
if (data is null || data.Value.ValueKind == JsonValueKind.Null)
return [];
if (data.Value.ValueKind != JsonValueKind.Array)
throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array.");
var values = new List<double>();
foreach (var item in data.Value.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Number)
throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value.");
values.Add(item.GetDouble());
}
return values;
}
}

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,4 +1,7 @@
namespace Journal.Core.Services; using Journal.Core.Dtos;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services.Database;
public interface IJournalDatabaseService public interface IJournalDatabaseService
{ {
@ -6,24 +9,9 @@ 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);
} }
public sealed record JournalDatabaseStatus(
string DatabasePath,
int KeyLengthBytes,
int Iterations,
string KeyDerivation,
IReadOnlyList<string> SchemaTables,
string SchemaBootstrapPath,
bool RuntimeReady,
string RuntimeMessage);
public sealed record JournalDatabaseHydrationResult(
string DatabasePath,
string SchemaBootstrapPath,
int EntryFilesProcessed,
bool RuntimeReady,
string Message);

View File

@ -1,25 +1,22 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
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 : 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"];
private readonly IJournalConfigService _config; private readonly IJournalConfigService _config = config;
public JournalDatabaseService(IJournalConfigService config)
{
_config = config;
}
public string GetDatabasePath(string? dataDirectory = null) public string GetDatabasePath(string? dataDirectory = null)
{ {
@ -72,7 +69,8 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
["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,
@ -137,7 +135,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
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;
@ -168,7 +166,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
} }
} }
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));
@ -189,7 +187,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
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)
{ {
@ -219,7 +217,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
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

@ -0,0 +1,80 @@
using Journal.Core.Dtos;
using Journal.Core.Repositories;
namespace Journal.Core.Services.Entries;
public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService
{
private readonly IEntryFileRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
public IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
{
return [.. _repo.ListMarkdownFiles(dataDirectory)
.Select(path => new EntryListItem(
FileName: _repo.GetFileName(path),
FilePath: _repo.GetFullPath(path)))];
}
public EntryLoadResult LoadEntry(string filePath)
{
var normalizedPath = _repo.GetFullPath(filePath);
if (!_repo.FileExists(normalizedPath))
throw new FileNotFoundException($"Entry file not found: {normalizedPath}");
var rawContent = HtmlSanitizer.StripRichHtml(_repo.ReadFile(normalizedPath));
var fileStem = _repo.GetFileNameWithoutExtension(normalizedPath);
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
return new EntryLoadResult(
Date: entry.Date,
FileName: _repo.GetFileName(normalizedPath),
FilePath: normalizedPath,
RawContent: entry.RawContent);
}
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
{
var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory);
var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? "");
_repo.EnsureDirectory(targetPath);
if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase))
{
_repo.WriteFile(targetPath, sanitizedContent);
return new EntrySaveResult(targetPath);
}
if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase))
{
_repo.AppendFile(targetPath, "\n\n" + sanitizedContent.Trim());
return new EntrySaveResult(targetPath);
}
string finalContent;
if (_repo.FileExists(targetPath))
{
var existingContent = _repo.ReadFile(targetPath);
var fileStem = _repo.GetFileNameWithoutExtension(targetPath);
var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem);
var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem);
existingEntry.MergeWith(newEntryData);
finalContent = existingEntry.ToMarkdown();
}
else
{
finalContent = sanitizedContent;
}
_repo.WriteFile(targetPath, finalContent);
return new EntrySaveResult(targetPath);
}
private string ResolveTargetPath(string? filePath, string defaultDataDirectory)
{
if (!string.IsNullOrWhiteSpace(filePath))
return _repo.GetFullPath(filePath);
return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"));
}
}

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

@ -0,0 +1,46 @@
using System.Net;
using System.Text.RegularExpressions;
namespace Journal.Core.Services.Entries;
public static class HtmlSanitizer
{
public static string StripRichHtml(string content)
{
if (string.IsNullOrWhiteSpace(content))
return content;
if (!LooksLikeRichHtml(content))
return content;
var text = content.Replace("\r\n", "\n").Replace("\r", "\n");
text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
text = Regex.Replace(text, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "</(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<li\\b[^>]*>", "\n- ", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "</li>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "</(td|th)>", " ", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<hr\\b[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline);
text = WebUtility.HtmlDecode(text)
.Replace('\u00a0', ' ')
.Replace("\u200b", "", StringComparison.Ordinal);
text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd()));
text = Regex.Replace(text, "[ \\t]{2,}", " ");
text = Regex.Replace(text, "\n{3,}", "\n\n").Trim();
return string.IsNullOrEmpty(text) ? content : text;
}
public static bool LooksLikeRichHtml(string content)
{
var lowered = content.ToLowerInvariant();
string[] markers =
[
"<p", "</p>", "<div", "<span", "<table", "<tr", "<td", "<li", "<ul", "<ol",
"style=", "font-family:", "-webkit-text-stroke"
];
if (markers.Any(marker => lowered.Contains(marker, StringComparison.Ordinal)))
return true;
return Regex.Matches(lowered, "</?[a-z][^>]*>").Count >= 8;
}
}

View File

@ -0,0 +1,10 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services.Entries;
public interface IEntryFileService
{
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
EntryLoadResult LoadEntry(string filePath);
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
}

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,13 +3,11 @@ 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 : IFragmentService public class FragmentService(IFragmentRepository repo) : IFragmentService
{ {
private readonly IFragmentRepository _repo; private readonly IFragmentRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
public FragmentService(IFragmentRepository repo) => _repo = repo ?? throw new ArgumentNullException(nameof(repo));
private static FragmentDto Map(Fragment f) => new( private static FragmentDto Map(Fragment f) => new(
f.Id, f.Id,
@ -19,7 +17,7 @@ public class FragmentService : 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);
@ -30,11 +28,11 @@ public class FragmentService : 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))
@ -46,38 +44,38 @@ public class FragmentService : 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

@ -0,0 +1,73 @@
using System.Globalization;
using System.Text.Json;
namespace Journal.Core.Services.Logging;
public sealed class CommandLogger
{
public static void LogStart(string action, string correlationId, JsonElement? payload)
{
var redactedPayload = LogRedactor.RedactPayload(payload);
EmitLog("information", action, correlationId, "start", redactedPayload);
}
public static void LogSuccess(string action, string correlationId) => EmitLog("information", action, correlationId, "success");
public static void LogFailure(string action, string correlationId, string errorType, string? message = null)
{
var details = string.IsNullOrWhiteSpace(message)
? ""
: (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)");
EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details);
}
private static void EmitLog(
string level,
string action,
string correlationId,
string outcome,
object? payload = null,
string? errorType = null,
string? details = null)
{
if (!ShouldLog(level))
return;
var envelope = new
{
timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
level,
component = "Entry",
action,
correlation_id = correlationId,
outcome,
error_type = errorType,
details,
payload
};
Console.Error.WriteLine(JsonSerializer.Serialize(envelope));
}
private static bool ShouldLog(string level)
{
var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning")
.Trim()
.ToLowerInvariant();
var configuredRank = LogLevelRank(configured);
var incomingRank = LogLevelRank(level);
return incomingRank >= configuredRank;
}
private static int LogLevelRank(string level) => level switch
{
"trace" => 0,
"debug" => 1,
"information" => 2,
"info" => 2,
"warning" => 3,
"warn" => 3,
"error" => 4,
"critical" => 5,
_ => 3
};
}

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

@ -1,190 +0,0 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
namespace Journal.Core.Services;
public sealed class PythonSidecarAiService : IAiService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly JournalConfig _config;
public PythonSidecarAiService(JournalConfig config)
{
_config = config;
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
throw new ArgumentException("Python AI sidecar path is required.");
if (!File.Exists(_config.PythonAiSidecarPath))
throw new FileNotFoundException($"Python AI sidecar not found: {_config.PythonAiSidecarPath}");
}
public async Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default)
{
var data = await SendAsync("health", payload: new { }, cancellationToken);
if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object)
return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok");
var provider = payload.TryGetProperty("provider", out var providerNode)
? providerNode.GetString() ?? "python-sidecar"
: "python-sidecar";
var message = payload.TryGetProperty("message", out var messageNode)
? messageNode.GetString() ?? "ok"
: "ok";
var healthy = !payload.TryGetProperty("healthy", out var healthyNode) ||
healthyNode.ValueKind is JsonValueKind.True ||
(healthyNode.ValueKind is JsonValueKind.False ? false : true);
return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message);
}
public async Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentException("Entry content is required.", nameof(content));
var data = await SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken);
return data?.GetString() ?? "";
}
public async Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default)
{
entries ??= [];
var data = await SendAsync("summarize_all", new { entries }, cancellationToken);
return data?.GetString() ?? "";
}
public async Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required.", nameof(prompt));
var data = await SendAsync("chat", new { prompt }, cancellationToken);
return data?.GetString() ?? "";
}
public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentException("Content is required.", nameof(content));
var data = await SendAsync("embed", new { content }, cancellationToken);
if (data is null || data.Value.ValueKind == JsonValueKind.Null)
return [];
if (data.Value.ValueKind != JsonValueKind.Array)
throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array.");
var values = new List<double>();
foreach (var item in data.Value.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Number)
throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value.");
values.Add(item.GetDouble());
}
return values;
}
private async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
{
var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions);
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = _config.PythonExecutable,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = _config.ProjectRoot
};
process.StartInfo.ArgumentList.Add(_config.PythonAiSidecarPath);
if (!process.Start())
throw new InvalidOperationException("Failed to start Python AI sidecar process.");
await process.StandardInput.WriteLineAsync(request);
process.StandardInput.Close();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_config.AiSidecarTimeoutMs);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException)
{
TryKill(process);
throw new TimeoutException($"Python AI sidecar timed out after {_config.AiSidecarTimeoutMs} ms.");
}
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
var line = LastJsonLine(stdout);
if (string.IsNullOrWhiteSpace(line))
throw new InvalidOperationException($"Python AI sidecar returned no JSON response. stderr: {stderr}".Trim());
JsonDocument doc;
try
{
doc = JsonDocument.Parse(line);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Invalid JSON from Python AI sidecar: {line}", ex);
}
using (doc)
{
var root = doc.RootElement;
if (!root.TryGetProperty("ok", out var okNode) || okNode.ValueKind != JsonValueKind.True && okNode.ValueKind != JsonValueKind.False)
throw new InvalidOperationException("Python AI sidecar response missing boolean 'ok' field.");
if (!okNode.GetBoolean())
{
var error = root.TryGetProperty("error", out var errorNode)
? errorNode.GetString() ?? "Unknown sidecar error."
: "Unknown sidecar error.";
throw new InvalidOperationException(error);
}
if (!root.TryGetProperty("data", out var dataNode))
return null;
return dataNode.Clone();
}
}
private static string LastJsonLine(string text)
{
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
for (var i = lines.Length - 1; i >= 0; i--)
{
var line = lines[i].Trim();
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
return line;
}
return "";
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch
{
// Ignore cleanup errors while handling timeout/failure path.
}
}
}

View File

@ -1,90 +1,19 @@
using System.Diagnostics; using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models; using Journal.Core.Models;
namespace Journal.Core.Services; namespace Journal.Core.Services.Sidecar;
public sealed class PythonSidecarSpeechService : ISpeechBridgeService public sealed class PythonSidecarClient(JournalConfig config)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private readonly JournalConfig _config; private readonly JournalConfig _config = config;
public PythonSidecarSpeechService(JournalConfig config) public async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
{
_config = config;
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
throw new ArgumentException("Python sidecar path is required.");
if (!File.Exists(_config.PythonAiSidecarPath))
throw new FileNotFoundException($"Python sidecar not found: {_config.PythonAiSidecarPath}");
}
public async Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
{
var data = await SendAsync("speech.devices.list", new { }, cancellationToken);
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar.");
var warning = data.Value.TryGetProperty("warning", out var warningNode)
? warningNode.GetString()
: null;
var devices = new List<SpeechDeviceDto>();
if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array)
{
foreach (var device in devicesNode.EnumerateArray())
{
if (device.ValueKind != JsonValueKind.Object)
continue;
var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number
? indexNode.GetInt32()
: -1;
var name = device.TryGetProperty("name", out var nameNode)
? nameNode.GetString() ?? ""
: "";
devices.Add(new SpeechDeviceDto(index, name));
}
}
return new SpeechDevicesResultDto(devices, warning);
}
public async Task<SpeechTranscribeResultDto> TranscribeAsync(
SpeechTranscribeRequestDto request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var data = await SendAsync("speech.transcribe", new
{
audio_base64 = request.AudioBase64,
engine = request.Engine,
whisper_model = request.WhisperModel,
text = request.Text,
simulate_delay_ms = request.SimulateDelayMs
}, cancellationToken);
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
throw new InvalidOperationException("Python sidecar speech response must be a JSON object.");
var text = data.Value.TryGetProperty("text", out var textNode)
? textNode.GetString() ?? ""
: "";
var engine = data.Value.TryGetProperty("engine", out var engineNode)
? engineNode.GetString() ?? (request.Engine ?? "whisper")
: (request.Engine ?? "whisper");
var warning = data.Value.TryGetProperty("warning", out var warningNode)
? warningNode.GetString()
: null;
return new SpeechTranscribeResultDto(text, engine, warning);
}
private async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
{ {
var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions); var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions);
@ -166,6 +95,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
return line; return line;
} }
return ""; return "";
} }
@ -178,7 +108,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
} }
catch catch
{ {
// Ignore timeout cleanup failures. // Ignore cleanup errors while handling timeout/failure path.
} }
} }
} }

View File

@ -1,20 +1,16 @@
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 public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
{ {
private readonly IVaultStorageService _vaultStorage; private readonly IVaultStorageService _vaultStorage = vaultStorage;
private readonly IEntrySearchService _entrySearch; private readonly IEntrySearchService _entrySearch = entrySearch;
private readonly IJournalConfigService _config; private readonly IJournalConfigService _config = config;
public SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
{
_vaultStorage = vaultStorage;
_entrySearch = entrySearch;
_config = config;
}
public async Task<int> RunAsync(string[] args, Entry entry) public async Task<int> RunAsync(string[] args, Entry entry)
{ {
@ -34,9 +30,9 @@ public sealed class SidecarCli
} }
if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase)) if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase))
return RunVaultCommand(args.Skip(1).ToArray()); return RunVaultCommand([.. args.Skip(1)]);
if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase)) if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase))
return RunSearchCommand(args.Skip(1).ToArray()); return RunSearchCommand([.. args.Skip(1)]);
Console.Error.WriteLine($"Unknown command: {args[0]}"); Console.Error.WriteLine($"Unknown command: {args[0]}");
PrintUsage(); PrintUsage();
@ -60,7 +56,7 @@ public sealed class SidecarCli
return 2; return 2;
} }
if (!TryParseVaultOptions(args.Skip(1).ToArray(), out var options, out var parseError)) if (!TryParseVaultOptions([.. args.Skip(1)], out var options, out var parseError))
{ {
Console.Error.WriteLine(parseError); Console.Error.WriteLine(parseError);
PrintVaultUsage(); PrintVaultUsage();

View File

@ -1,17 +1,11 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services; namespace Journal.Core.Services.Speech;
public sealed class DisabledSpeechBridgeService : ISpeechBridgeService public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService
{ {
private readonly string _provider; private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
private readonly string _message; private readonly string _message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim();
public DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.")
{
_provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
_message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim();
}
public Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default) public Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
{ {

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

@ -0,0 +1,81 @@
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Sidecar;
namespace Journal.Core.Services.Speech;
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
{
private readonly PythonSidecarClient _client;
public PythonSidecarSpeechService(JournalConfig config)
{
if (string.IsNullOrWhiteSpace(config.PythonAiSidecarPath))
throw new ArgumentException("Python sidecar path is required.");
if (!File.Exists(config.PythonAiSidecarPath))
throw new FileNotFoundException($"Python sidecar not found: {config.PythonAiSidecarPath}");
_client = new PythonSidecarClient(config);
}
public async Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
{
var data = await _client.SendAsync("speech.devices.list", new { }, cancellationToken);
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar.");
var warning = data.Value.TryGetProperty("warning", out var warningNode)
? warningNode.GetString()
: null;
var devices = new List<SpeechDeviceDto>();
if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array)
{
foreach (var device in devicesNode.EnumerateArray())
{
if (device.ValueKind != JsonValueKind.Object)
continue;
var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number
? indexNode.GetInt32()
: -1;
var name = device.TryGetProperty("name", out var nameNode)
? nameNode.GetString() ?? ""
: "";
devices.Add(new SpeechDeviceDto(index, name));
}
}
return new SpeechDevicesResultDto(devices, warning);
}
public async Task<SpeechTranscribeResultDto> TranscribeAsync(
SpeechTranscribeRequestDto request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var data = await _client.SendAsync("speech.transcribe", new
{
audio_base64 = request.AudioBase64,
engine = request.Engine,
whisper_model = request.WhisperModel,
text = request.Text,
simulate_delay_ms = request.SimulateDelayMs
}, cancellationToken);
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
throw new InvalidOperationException("Python sidecar speech response must be a JSON object.");
var text = data.Value.TryGetProperty("text", out var textNode)
? textNode.GetString() ?? ""
: "";
var engine = data.Value.TryGetProperty("engine", out var engineNode)
? engineNode.GetString() ?? (request.Engine ?? "whisper")
: (request.Engine ?? "whisper");
var warning = data.Value.TryGetProperty("warning", out var warningNode)
? warningNode.GetString()
: null;
return new SpeechTranscribeResultDto(text, engine, warning);
}
}

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,18 +2,15 @@ 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 : IVaultStorageService public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService
{ {
private readonly IVaultCryptoService _crypto; private readonly IVaultCryptoService _crypto = crypto;
private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal);
private readonly object _vaultIoLock = new(); private readonly object _vaultIoLock = new();
public VaultStorageService(IVaultCryptoService crypto) => _crypto = crypto;
public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault"; public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault";
public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory) public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory)

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,73 +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 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.");
@ -183,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

@ -2,4 +2,5 @@
<Project Path="Journal.Api/Journal.Api.csproj" /> <Project Path="Journal.Api/Journal.Api.csproj" />
<Project Path="Journal.Core/Journal.Core.csproj" /> <Project Path="Journal.Core/Journal.Core.csproj" />
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" /> <Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
<Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" />
</Solution> </Solution>

380
README.md
View File

@ -1,264 +1,192 @@
# Journal Backend (.NET) # Project_Journal
A .NET 10 backend for the Project Journal app. Provides core journal functionality as a class library with a sidecar console app for Tauri integration and an optional HTTP API. A structured journaling system with encrypted monthly vaults, desktop UI, CLI tools, and optional AI-assisted analysis.
## Project Structure ## Support Matrix
``` - Python: `3.14`
backend/ - Platforms: Windows and Linux (first-class), macOS (best effort)
├── Journal.Core/ Class library — all business logic - Default profile: CPU
│ ├── Models/ - Optional profiles: GPU, optional NLP backend
│ │ ├── Fragment.cs Domain model (validated, owns Guid ID)
│ │ ├── Command.cs Stdin command shape for sidecar protocol ## Dependency Profiles
│ │ ├── ParsedSection.cs Parsed section model for entry parity work
│ │ ├── SectionTitles.cs Canonical section title list (Python parity) - `requirements_base.txt`: shared Journal runtime dependencies
│ │ └── JournalEntry.cs Entry domain (`date/raw_content/sections/fragments` + merge + markdown reconstruction) - `requirements_cpu_only.txt`: base + CPU AI stack
│ ├── Dtos/ - `requirements_gpu.txt`: base + GPU AI stack
│ │ └── FragmentDtos.cs Immutable records for API boundary - `requirements_nlp_optional.txt`: optional spaCy backend (auto-fallback if unavailable)
│ │ ├── FragmentDto Read (what goes out)
│ │ ├── CreateFragmentDto Create (what comes in) ## Quickstart
│ │ └── UpdateFragmentDto Update (partial, all fields optional)
│ ├── Repositories/ ### Linux (CPU default)
│ │ ├── IFragmentRepository.cs Interface (data access contract)
│ │ ├── InMemoryFragmentRepository.cs In-memory implementation (tests/dev) ```bash
│ │ └── FileFragmentRepository.cs File-backed implementation (default) cd Project_Journal
│ ├── Services/ python3.14 -m venv .venv
│ │ ├── IFragmentService.cs Interface (business logic contract) source .venv/bin/activate
│ │ ├── FragmentService.cs Validates, calls repo, maps to DTOs python -m pip install --upgrade pip
│ │ ├── IEntrySearchService.cs Entry search contract (content parity) python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt
│ │ ├── EntrySearchService.cs Searches decrypted `.md` entries by raw content query
│ │ ├── IJournalConfigService.cs Config contract for path/vault/AI/speech settings parity
│ │ ├── JournalConfigService.cs Env/default-backed config surface aligned with Python keys
│ │ ├── IAiService.cs AI bridge contract (optional provider)
│ │ ├── DisabledAiService.cs No-op AI provider for deterministic disabled mode
│ │ ├── PythonSidecarAiService.cs Local Python sidecar adapter (stdin/stdout JSON)
│ │ ├── SidecarCli.cs CLI runner (`vault` + `search`) used by Sidecar host
│ │ ├── JournalParser.cs Date + section + checkbox + fragment parser slices (Phase 2)
│ │ ├── IVaultCryptoService.cs Vault crypto contract
│ │ ├── VaultCryptoService.cs AES-256-GCM + PBKDF2 compatibility layer
│ │ ├── IVaultStorageService.cs Vault load/workflow contract
│ │ └── VaultStorageService.cs Monthly naming + load/decrypt/extract workflow
│ ├── Entry.cs Command dispatcher (stdin/stdout)
│ ├── ServiceCollectionExtensions.cs DI registration helper
│ └── Journal.Core.csproj
├── Journal.Sidecar/ Console app — Tauri sidecar bridge
│ ├── App.cs Boots DI container, runs Entry.RunAsync()
│ └── Journal.Sidecar.csproj References Journal.Core
├── Journal.Api/ Web API — HTTP endpoint wrapper (optional)
│ ├── Program.cs
│ └── Journal.Api.csproj
└── README.md
``` ```
## Architecture ### Linux (GPU optional)
Each layer only knows about the one below it: ```bash
cd Project_Journal
``` python3.14 -m venv .venv
Sidecar (stdin/stdout) ──┐ source .venv/bin/activate
├──► Services (business logic) ──► Repositories (data access) python -m pip install --upgrade pip
API (HTTP/JSON) ─────────┘ python -m pip install -r requirements_gpu.txt
``` ```
- **Models** — Domain objects with validation. The source of truth. ### Windows PowerShell (CPU default)
- **DTOs** — Immutable records that cross the API boundary. Internal logic never leaks out.
- **Repositories** — Where data lives. Current default is file-backed; can evolve to SQLite/EF Core without touching anything above.
- **Services** — Business rules, validation, orchestration. Doesn't know about HTTP or stdin.
- **Entry** — Transport adapter. Translates stdin/stdout JSON into service calls.
## Dependencies
- **Journal.Core**`Microsoft.Extensions.DependencyInjection.Abstractions` (interface-only, lightweight)
- **Journal.Sidecar**`Microsoft.Extensions.DependencyInjection` (full container implementation) + references `Journal.Core`
- **Journal.Api**`Microsoft.AspNetCore.OpenApi` + ASP.NET shared framework
## Building
```powershell ```powershell
# Build everything (building Sidecar also rebuilds Core if changed) cd Project_Journal
dotnet build backend\Journal.Sidecar\Journal.Sidecar.csproj py -3.14 -m venv .venv
.\.venv\Scripts\Activate.ps1
# Build just the library python -m pip install --upgrade pip
dotnet build backend\Journal.Core\Journal.Core.csproj python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt
# Format code
dotnet format backend\Journal.Core\Journal.Core.csproj
``` ```
## Publishing On Windows + Python 3.14, `pywebview` is intentionally skipped due upstream
`pythonnet` build compatibility. `run_desktop.py` will auto-fallback to opening
the app in your system browser.
Publish as a single-file self-contained executable (no .NET runtime install needed): ### Optional NLP backend (spaCy)
```bash
python -m pip install -r requirements_nlp_optional.txt
python -m spacy download en_core_web_sm
```
If spaCy is missing or unsupported, Journal now auto-falls back to built-in NLP heuristics.
On current Python 3.14 environments, this optional install may be skipped due upstream spaCy compatibility.
## Running
### Desktop App
```bash
python ./journal/run_desktop.py
```
### CLI
```bash
python -m journal.cli.main --help
python -m journal.cli.main vault load
python -m journal.cli.main search "your query"
```
## NLP Backend Control
Set `JOURNAL_NLP_BACKEND` to choose behavior:
- `auto` (default): use spaCy when available, else fallback
- `spacy`: require spaCy backend and fail clearly if unavailable
- `fallback`: always use fallback heuristics
Examples:
```bash
export JOURNAL_NLP_BACKEND=fallback
python ./journal/run_desktop.py
```
```powershell ```powershell
dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true $env:JOURNAL_NLP_BACKEND = "spacy"
python .\journal\run_desktop.py
``` ```
Output: `backend\Journal.Sidecar\bin\Release\net10.0\win-x64\publish\Journal.Sidecar.exe` (~70MB, everything bundled) ## Installer Script
To exclude debug symbols: add `-p:DebugType=none` Use the Linux helper script:
For a smaller build that requires .NET 10 on the target machine: ```bash
./installreqs.sh
```powershell ./installreqs.sh --gpu
dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 -p:PublishSingleFile=true ./installreqs.sh --with-nlp
``` ```
## Sidecar Protocol ## C# Backend
The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, one JSON line out. The `backend/` directory contains a .NET 10 implementation that provides the same journal functionality as the Python layer, with encrypted vault support and an identical JSON command protocol.
When run with no command-line args, this protocol mode is used by default.
## Sidecar CLI ### Projects
`Journal.Sidecar` also supports direct vault and search CLI commands: - **Journal.Core** — shared library: domain models, services, repositories, DTOs
- **Journal.Api** — minimal ASP.NET Core web API (`/api/command` POST endpoint)
- **Journal.Sidecar** — console app (stdin/stdout JSON protocol or CLI with `vault` and `search` subcommands)
- **Journal.SmokeTests** — 70+ integration tests (no test framework dependency)
```powershell ### Architecture
# Load vaults into decrypted data workspace
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault load
# Save (rebuild) monthly vaults from decrypted markdown files
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault save
# Search entries (query + filters)
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- search "common text" --tag stress --type !TRIGGER --start-date 2026-02-01 --end-date 2026-02-28 --section Summary --checked "med taken"
```
Password prompt behavior:
- If `--password` is omitted, CLI prompts with `Vault password:` (hidden input in terminal mode).
- For automation/non-interactive use, pass `--password <value>`.
Optional path overrides:
- `--vault-dir <path>`
- `--data-dir <path>`
- Env fallback: `JOURNAL_VAULT_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_APP_DIR`
Search CLI flags:
- positional `query` (optional)
- `--tag` / `-t` (repeatable)
- `--type` / `-y` (repeatable)
- `--start-date` / `-s` (`yyyy-MM-dd`)
- `--end-date` / `-e` (`yyyy-MM-dd`)
- `--section` / `-sec`
- `--checked` / `-chk` (repeatable)
- `--unchecked` / `-uchk` (repeatable)
- `--data-dir <path>` (optional override)
## Config Keys (Parity Surface)
`JournalConfigService` exposes and normalizes key settings expected from Python config:
- Paths: `JOURNAL_PROJECT_ROOT`, `JOURNAL_APP_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_VAULT_DIR`, `JOURNAL_LOG_DIR`, `JOURNAL_PID_FILE`, `JOURNAL_SERVER_CONTROL_FILE`
- Vault format: `JOURNAL_MONTHLY_VAULT_FORMAT` (default `%Y-%m.vault`)
- AI endpoints/models: `CLOUDAI_API_KEY`, `CLOUDAI_API_URL`, `LLAMA_CPP_URL`, `LLAMA_CPP_MODEL`, `LLAMA_CPP_TIMEOUT`, `EMBEDDING_API_URL`, `EMBEDDING_MODEL_NAME`, `MODEL_CONTEXT_TOKENS`, `CHUNK_TOKEN_BUDGET`
- AI bridge mode: `JOURNAL_AI_PROVIDER` (`none` or `python-sidecar`), `JOURNAL_PYTHON_EXE`, `JOURNAL_AI_SIDECAR_PATH`, `JOURNAL_AI_TIMEOUT_MS`
- Speech/NLP: `MICROPHONE_DEVICE_INDEX`, `SPEECH_RECOGNITION_ENGINE`, `WHISPER_MODEL_SIZE`, `JOURNAL_NLP_BACKEND`
### Command Format
```json
{
"action": "fragments.create",
"id": null,
"type": null,
"tag": null,
"payload": { "type": "!TRIGGER", "description": "stomach drop" }
}
```
**Fields:**
- `action` — The operation to perform (e.g. `fragments.list`, `fragments.create`)
- `id` — Target entity ID (for get/update/delete)
- `type` / `tag` — Filter parameters (for search)
- `payload` — Request body, deserialized into the appropriate DTO per action
### Available Actions
| Action | Description | Requires |
|--------|-------------|----------|
| `fragments.list` | List all fragments | — |
| `fragments.get` | Get fragment by ID | `id` |
| `fragments.create` | Create a new fragment | `payload` (CreateFragmentDto) |
| `fragments.update` | Update a fragment | `id`, `payload` (UpdateFragmentDto) |
| `fragments.delete` | Delete a fragment | `id` |
| `fragments.search` | Search by type/tag | `type` and/or `tag` |
| `entries.list` | List decrypted markdown entries in a data directory | optional `payload.dataDirectory` |
| `entries.load` | Load one entry file and return parsed metadata + raw content | `payload.filePath` |
| `entries.save` | Save/merge entry content to file (fragment append or full merge path) | `payload.content`, optional `payload.filePath`, `payload.mode` |
| `db.status` | Return DB key/schema compatibility status snapshot | `payload.password`, optional `payload.dataDirectory` |
| `db.initialize_schema` | Write SQL schema bootstrap (`journal_schema.sql`) for parity tables | optional `payload.dataDirectory` |
| `db.hydrate_workspace` | Perform C# DB hydration step for decrypted workspace (schema bootstrap + metadata) | `payload.password`, optional `payload.dataDirectory` |
| `config.get` | Return current backend config snapshot | — |
| `ai.health` | Return AI bridge health/provider status | — |
| `ai.summarize_entry` | Summarize one entry through AI provider | `payload.content`, optional `payload.fileStem` |
| `ai.summarize_all` | Summarize a set of entries through AI provider | `payload.entries[]` |
| `ai.chat` | Send chat prompt through AI provider bridge | `payload.prompt` |
| `ai.embed` | Generate embedding vector through AI provider bridge | `payload.content` |
| `search.entries` | Search decrypted entry content with optional parity filters | `payload.dataDirectory`, optional `payload.query`, `payload.section`, `payload.startDate`, `payload.endDate`, `payload.tags[]`, `payload.types[]`, `payload.checked[]`, `payload.unchecked[]` |
| `vault.initialize` | Ensure vault directory exists | `payload.password`, `payload.vaultDirectory` |
| `vault.load_all` | Load/decrypt all monthly vaults into data directory | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` |
| `vault.save_current_month` | Save only current month vault (optimized path) | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory`, optional `payload.nowUtc` |
| `vault.rebuild_all` | Rebuild all monthly vaults from decrypted `.md` data | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` |
| `vault.clear_data_directory` | Clear decrypted data directory and recreate it | `payload.dataDirectory` |
### Response Format
Success:
```json
{ "ok": true, "data": { "id": "abc-123", "type": "!TRIGGER", "description": "...", "time": "...", "tags": [] } }
```
Error:
```json
{ "ok": false, "error": "Description is required" }
```
## Extending with New Modules
The `Command` class is generic — new modules use the same dot-notation pattern:
``` ```
vault.unlock → IVaultService (future) Entry (thin command dispatcher)
vault.lock ├── Fragments/ IFragmentService → FragmentService → IFragmentRepository (SQLCipher)
entries.list → IEntryService (future) ├── Entries/ IEntryFileService, IEntrySearchService, JournalParser, HtmlSanitizer
entries.create ├── Vault/ IVaultStorageService → VaultStorageService → IVaultCryptoService
ai.health → IAiService (implemented bridge) ├── Database/ IJournalDatabaseService (SQLCipher schema/key derivation)
ai.summarize_* → IAiService (implemented bridge) │ IDatabaseSessionService (encrypted connection lifecycle after auth)
ai.chat → IAiService (implemented bridge) ├── Ai/ IAiService → PythonSidecarAiService | DisabledAiService
ai.embed → IAiService (implemented bridge) ├── Speech/ ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService
db.status → IJournalDatabaseService (in-progress DB parity) ├── Sidecar/ PythonSidecarClient (shared Python process I/O), SidecarCli
search.query → ISearchService (future) ├── Logging/ CommandLogger, LogRedactor
└── Config/ IJournalConfigService → JournalConfigService
``` ```
To add a module: Services are organized under `Journal.Core/Services/` in domain-specific subdirectories, each with its own namespace (e.g. `Journal.Core.Services.Ai`).
1. Create model, DTO, repository, and service in `Journal.Core/`
2. Register the new service in `ServiceCollectionExtensions.cs`
3. Inject the service into `Entry.cs` and add cases to the action switch
4. No changes needed to `Command.cs` or `App.cs`
## Dependency Injection ### Build & Run
`ServiceCollectionExtensions.cs` wires everything up. Any host (sidecar, API, tests) calls: ```bash
cd backend
```csharp dotnet build
services.AddFragmentServices();
``` ```
This registers: Run the API server:
- `IFragmentRepository``FileFragmentRepository` (singleton — persisted fragment store)
- `IFragmentService``FragmentService` (transient — fresh instance per request)
## Fragment Store Location ```bash
dotnet run --project Journal.Api
```
`FileFragmentRepository` persists data to: Run the sidecar (stdin/stdout mode):
- default: `.journal-sidecar/fragments.json` under current working directory ```bash
- override: `JOURNAL_FRAGMENT_STORE_PATH` environment variable dotnet run --project Journal.Sidecar
```
## Legacy Vault Compatibility Note Sidecar CLI commands:
The legacy Python placeholder file `_init_vault.vault` is treated as obsolete. ```bash
During vault load, the C# backend ignores this file for decryption and removes it. dotnet run --project Journal.Sidecar -- vault load --password <value>
This preserves compatibility while migrating older vault directories forward. dotnet run --project Journal.Sidecar -- vault save --password <value>
dotnet run --project Journal.Sidecar -- search "your query" --tag stress --start-date 2026-02-01
```
Run smoke tests:
```bash
dotnet run --project Journal.SmokeTests
```
### Environment Variables
- `JOURNAL_PROJECT_ROOT` — override project root detection
- `JOURNAL_DATA_DIR` / `JOURNAL_VAULT_DIR` — override data/vault paths
- `JOURNAL_AI_PROVIDER``none` (default) or `python-sidecar`
- `JOURNAL_PYTHON_EXE` — Python executable path (default: `python`)
- `JOURNAL_LOG_LEVEL``trace`, `debug`, `information`, `warning` (default), `error`, `critical`
### Encryption
- Vault: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iterations)
- Database: SQLCipher with PBKDF2-derived key
- Standalone fragments are stored in the encrypted SQLCipher database (requires auth via `vault.load_all` or `db.hydrate_workspace`)
- `DatabaseSessionService` holds the encryption password in memory after first authentication
- Wire format matches the Python implementation for cross-language parity
## Notes
- Decrypted journal data in `journal/data` is cleared on graceful shutdown.
- Vault save/load commands remain unchanged.

84
REFACTORING_SUMMARY.md Normal file
View File

@ -0,0 +1,84 @@
# Backend Refactoring Summary
## Problem
`Entry.cs` was a 550-line god class that contained command dispatching, business logic, HTML sanitization, logging infrastructure, and 13 private payload record types. The two Python sidecar services (`PythonSidecarAiService` and `PythonSidecarSpeechService`) duplicated ~80 lines of identical process/JSON plumbing. Payload DTOs were hidden as private records inside `Entry.cs` instead of being in the `Dtos/` folder.
## What Changed
### 1. Slimmed `Entry.cs` to a thin dispatcher (~330 lines)
Removed all business logic, HTML processing, logging implementation, and private record types. `Entry` now only parses the incoming JSON command, routes to the correct service, and returns the `{ok, data}` / `{ok: false, error}` envelope.
### 2. Extracted `HtmlSanitizer` (new file)
`StripRichHtml` and `LooksLikeRichHtml` moved from `Entry.cs` to `Services/HtmlSanitizer.cs` as a static utility class.
### 3. Extracted `CommandLogger` (new file)
`LogStart`, `LogSuccess`, `LogFailure`, `EmitLog`, `ShouldLog`, and `LogLevelRank` moved from `Entry.cs` to `Services/CommandLogger.cs`. Entry now receives this as a dependency.
### 4. Extracted `IEntryFileService` + `EntryFileService` (new files)
`SaveEntry`, `LoadEntry`, `ListEntries`, and `ResolveTargetPath` moved out of `Entry.cs` into a proper service with an interface. This follows the same pattern as `IFragmentService` / `FragmentService`.
### 5. Added `IEntryFileRepository` + `DiskEntryFileRepository` (new files)
`EntryFileService` now delegates all filesystem I/O (read, write, append, list, exists) to an `IEntryFileRepository`, keeping only business logic (HTML sanitization, parsing, merging) in the service. This mirrors the Fragment module's repository pattern (`IFragmentRepository``FragmentService`). An in-memory implementation can be swapped in for testing.
### 6. Extracted `PythonSidecarClient` (new file)
The duplicated `SendAsync`, `LastJsonLine`, and `TryKill` methods were extracted from both `PythonSidecarAiService` and `PythonSidecarSpeechService` into a shared `Services/PythonSidecarClient.cs`. Both services now delegate to it.
### 7. Moved payload records to `Dtos/CommandDtos.cs` (new file)
The 16 private payload/result records that were inside `Entry.cs` are now in `Dtos/CommandDtos.cs`. Records used through public interfaces (`EntrySavePayload`, `EntryListItem`, `EntryLoadResult`, `EntrySaveResult`) are public; the rest remain internal.
### 8. Moved database result records to `Dtos/DatabaseDtos.cs` (new file)
`JournalDatabaseStatus` and `JournalDatabaseHydrationResult` moved from `IJournalDatabaseService.cs` to `Dtos/DatabaseDtos.cs` for consistency with the other DTO files.
### 9. Moved fragment storage to encrypted SQLCipher database
Standalone fragments were previously stored in an unencrypted JSON file (`fragments.json`), then briefly in an unencrypted SQLite database. For medical/sensitive use cases, fragments are now stored in the **existing encrypted SQLCipher database** (`journal_cache.db`) alongside entries, sections, and tags.
- Updated `fragments` table schema: `entry_id` is now nullable, added `guid TEXT UNIQUE` column. Standalone fragments use `guid` + `entry_id IS NULL`; entry-linked fragments use `entry_id` + `guid NULL`.
- `SqliteFragmentRepository` now depends on `IDatabaseSessionService` instead of managing its own database connection.
- Tags use the shared normalized `tags` + `fragment_tags` tables (join via integer IDs).
- Fragment CRUD requires the database to be unlocked first (via `vault.load_all` or `db.hydrate_workspace`).
### 10. Added `IDatabaseSessionService` + `DatabaseSessionService` (new files)
A singleton that stores the encryption password in memory after authentication. When `vault.load_all` or `db.hydrate_workspace` is called, the session stores the password and lazily opens/caches an encrypted SQLCipher connection. All encrypted database consumers (currently `SqliteFragmentRepository`) use this shared session.
### 11. Organized Services directory into domain modules
The flat `Services/` directory (28 files) was reorganized into 9 subdirectories with dedicated namespaces:
- `Services/Ai/``IAiService`, `DisabledAiService`, `PythonSidecarAiService`
- `Services/Config/``IJournalConfigService`, `JournalConfigService`
- `Services/Database/``IJournalDatabaseService`, `JournalDatabaseService`, `IDatabaseSessionService`, `DatabaseSessionService`
- `Services/Entries/``IEntryFileService`, `EntryFileService`, `IEntrySearchService`, `EntrySearchService`, `JournalParser`, `HtmlSanitizer`
- `Services/Fragments/``IFragmentService`, `FragmentService`
- `Services/Logging/``CommandLogger`, `LogRedactor`
- `Services/Sidecar/``PythonSidecarClient`, `SidecarCli`
- `Services/Speech/``ISpeechBridgeService`, `DisabledSpeechBridgeService`, `PythonSidecarSpeechService`
- `Services/Vault/``IVaultCryptoService`, `VaultCryptoService`, `IVaultStorageService`, `VaultStorageService`
Each subdirectory has its own namespace (e.g. `Journal.Core.Services.Ai`). All consumer files updated with explicit using statements.
## Files Created
- `Journal.Core/Services/Entries/HtmlSanitizer.cs`
- `Journal.Core/Services/Logging/CommandLogger.cs`
- `Journal.Core/Services/Entries/IEntryFileService.cs`
- `Journal.Core/Services/Entries/EntryFileService.cs`
- `Journal.Core/Services/Sidecar/PythonSidecarClient.cs`
- `Journal.Core/Repositories/IEntryFileRepository.cs`
- `Journal.Core/Repositories/DiskEntryFileRepository.cs`
- `Journal.Core/Repositories/SqliteFragmentRepository.cs`
- `Journal.Core/Dtos/CommandDtos.cs`
- `Journal.Core/Dtos/DatabaseDtos.cs`
- `Journal.Core/Services/Database/IDatabaseSessionService.cs`
- `Journal.Core/Services/Database/DatabaseSessionService.cs`
## Files Modified
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher, wired `IDatabaseSessionService`
- `Journal.Core/Services/Ai/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/Speech/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/Database/IJournalDatabaseService.cs` — result records moved to Dtos
- `Journal.Core/Services/Database/JournalDatabaseService.cs` — schema updated (nullable entry_id, guid column)
- `Journal.Core/ServiceCollectionExtensions.cs` — registers all new services, updated namespaces
- `Journal.SmokeTests/Program.cs` — updated with new dependencies and encrypted DB persistence test
- `Journal.Sidecar/App.cs` — updated namespace imports
## Verification
- All 4 projects build successfully
- 65/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)