Compare commits

..

No commits in common. "e2ede4b3e977cebb0e16a9989065c5c5d0de8d75" and "14b8e7a33954ef32d77d398ef97493d25420a65e" have entirely different histories.

45 changed files with 1153 additions and 1335 deletions

View File

@ -1,35 +0,0 @@
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

@ -1,18 +0,0 @@
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,46 +1,46 @@
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.Ai; using Journal.Core.Services;
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
IFragmentService fragments,
IEntrySearchService entrySearch,
IVaultStorageService vaultStorage,
IJournalDatabaseService database,
IDatabaseSessionService databaseSession,
IJournalConfigService config,
IAiService ai,
ISpeechBridgeService speech,
IEntryFileService entryFiles,
CommandLogger logger)
{ {
private readonly IFragmentService _fragments = fragments; private readonly IFragmentService _fragments;
private readonly IEntrySearchService _entrySearch = entrySearch; private readonly IEntrySearchService _entrySearch;
private readonly IVaultStorageService _vaultStorage = vaultStorage; private readonly IVaultStorageService _vaultStorage;
private readonly IJournalDatabaseService _database = database; private readonly IJournalDatabaseService _database;
private readonly IDatabaseSessionService _databaseSession = databaseSession; private readonly IJournalConfigService _config;
private readonly IJournalConfigService _config = config; private readonly IAiService _ai;
private readonly IAiService _ai = ai; private readonly ISpeechBridgeService _speech;
private readonly ISpeechBridgeService _speech = speech;
private readonly IEntryFileService _entryFiles = entryFiles;
private readonly CommandLogger _logger = logger;
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
public Entry(
IFragmentService fragments,
IEntrySearchService entrySearch,
IVaultStorageService vaultStorage,
IJournalDatabaseService database,
IJournalConfigService config,
IAiService ai,
ISpeechBridgeService speech)
{
_fragments = fragments;
_entrySearch = entrySearch;
_vaultStorage = vaultStorage;
_database = database;
_config = config;
_ai = ai;
_speech = speech;
}
public async Task RunAsync() public async Task RunAsync()
{ {
string? line; string? line;
@ -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();
CommandLogger.LogStart(action, correlationId, cmd.Payload); 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 = _fragments.GetAll(); result = await _fragments.GetAllAsync();
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 = _fragments.GetById(getId); result = await _fragments.GetByIdAsync(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 = _fragments.Create(createDto); result = await _fragments.CreateAsync(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 = _fragments.Update(updateId, updateDto); result = await _fragments.UpdateAsync(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 = _fragments.Remove(deleteId); result = await _fragments.RemoveAsync(deleteId);
break; break;
case "fragments.search": case "fragments.search":
result = _fragments.Search(cmd.Type, cmd.Tag); result = await _fragments.SearchAsync(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 = _entryFiles.ListEntries(listDataDirectory); result = 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 = _entryFiles.LoadEntry(loadEntryPayload.FilePath); result = 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 = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory); result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
break; break;
case "config.get": case "config.get":
result = _config.Current; result = _config.Current;
@ -211,7 +211,6 @@ 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);
@ -255,56 +254,124 @@ public class Entry(
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password)) if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory); result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
_databaseSession.SetPassword(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
break; break;
default: default:
CommandLogger.LogFailure(action, correlationId, "unknown_action"); LogFailure(action, correlationId, "unknown_action");
return Error($"Unknown action: {action}"); return Error($"Unknown action: {action}");
} }
} }
catch (JsonException) catch (JsonException)
{ {
CommandLogger.LogFailure(action, correlationId, "invalid_payload_json"); LogFailure(action, correlationId, "invalid_payload_json");
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
CommandLogger.LogFailure(action, correlationId, "validation", ex.Message); LogFailure(action, correlationId, "validation", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
CommandLogger.LogFailure(action, correlationId, "argument", ex.Message); LogFailure(action, correlationId, "argument", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (TimeoutException ex) catch (TimeoutException ex)
{ {
CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message); LogFailure(action, correlationId, "timeout", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message); LogFailure(action, correlationId, "invalid_operation", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch (FileNotFoundException ex) catch (FileNotFoundException ex)
{ {
CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message); LogFailure(action, correlationId, "not_found", ex.Message);
return Error(ex.Message); return Error(ex.Message);
} }
catch catch
{ {
CommandLogger.LogFailure(action, correlationId, "internal_error"); LogFailure(action, correlationId, "internal_error");
return Error("Internal error"); return Error("Internal error");
} }
CommandLogger.LogSuccess(action, correlationId); 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)
@ -328,4 +395,153 @@ 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

@ -1,33 +0,0 @@
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

@ -0,0 +1,228 @@
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

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

View File

@ -1,314 +0,0 @@
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,14 +1,6 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Journal.Core.Repositories; using Journal.Core.Repositories;
using Journal.Core.Services.Ai; using Journal.Core.Services;
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;
@ -16,8 +8,7 @@ public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddFragmentServices(this IServiceCollection services) public static IServiceCollection AddFragmentServices(this IServiceCollection services)
{ {
services.AddSingleton<IDatabaseSessionService, DatabaseSessionService>(); services.AddSingleton<IFragmentRepository, FileFragmentRepository>();
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>();
@ -56,9 +47,6 @@ 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,86 +0,0 @@
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,66 +0,0 @@
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

@ -1,10 +0,0 @@
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,12 +1,19 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services.Ai; namespace Journal.Core.Services;
public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService public sealed class DisabledAiService : IAiService
{ {
private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); private readonly string _provider;
private readonly string _message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim(); private readonly string _message;
private readonly bool _healthy = healthy; private readonly bool _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,11 +1,17 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services.Speech; namespace Journal.Core.Services;
public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService public sealed class DisabledSpeechBridgeService : ISpeechBridgeService
{ {
private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); private readonly string _provider;
private readonly string _message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim(); private readonly string _message;
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,80 +0,0 @@
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,46 +0,0 @@
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

@ -1,10 +0,0 @@
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,7 +1,7 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
using System.Globalization; using System.Globalization;
namespace Journal.Core.Services.Entries; namespace Journal.Core.Services;
public class EntrySearchService : IEntrySearchService public class EntrySearchService : IEntrySearchService
{ {

View File

@ -3,11 +3,13 @@ using Journal.Core.Dtos;
using Journal.Core.Models; using Journal.Core.Models;
using Journal.Core.Repositories; using Journal.Core.Repositories;
namespace Journal.Core.Services.Fragments; namespace Journal.Core.Services;
public class FragmentService(IFragmentRepository repo) : IFragmentService public class FragmentService : IFragmentService
{ {
private readonly IFragmentRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); private readonly IFragmentRepository _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,
@ -17,7 +19,7 @@ public class FragmentService(IFragmentRepository repo) : IFragmentService
f.Tags != null ? [.. f.Tags] : [] f.Tags != null ? [.. f.Tags] : []
); );
public FragmentDto Create(CreateFragmentDto dto) public async Task<FragmentDto> CreateAsync(CreateFragmentDto dto)
{ {
ArgumentNullException.ThrowIfNull(dto); ArgumentNullException.ThrowIfNull(dto);
@ -28,11 +30,11 @@ public class FragmentService(IFragmentRepository repo) : IFragmentService
if (dto.Tags != null) if (dto.Tags != null)
f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())]; f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())];
_repo.Add(f); await _repo.AddAsync(f);
return Map(f); return Map(f);
} }
public bool Update(Guid id, UpdateFragmentDto dto) public async Task<bool> UpdateAsync(Guid id, UpdateFragmentDto dto)
{ {
ArgumentNullException.ThrowIfNull(dto); ArgumentNullException.ThrowIfNull(dto);
if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type)) if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type))
@ -44,38 +46,38 @@ public class FragmentService(IFragmentRepository repo) : IFragmentService
var description = dto.Description?.Trim(); var description = dto.Description?.Trim();
var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList(); var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList();
return _repo.Update(id, type, description, tags, dto.Time); return await _repo.UpdateAsync(id, type, description, tags, dto.Time);
} }
public bool Remove(Guid id) => _repo.Remove(id); public Task<bool> RemoveAsync(Guid id) => _repo.RemoveAsync(id);
public List<FragmentDto> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) public async Task<List<FragmentDto>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
{ {
var items = _repo.Search(type, tag, timeAfter); var items = await _repo.SearchAsync(type, tag, timeAfter);
return [.. items.Select(Map)]; return [.. items.Select(Map)];
} }
public List<FragmentDto> GetByTag(string tag) public async Task<List<FragmentDto>> GetByTagAsync(string tag)
{ {
var items = _repo.GetByTag(tag); var items = await _repo.GetByTagAsync(tag);
return [.. items.Select(Map)]; return [.. items.Select(Map)];
} }
public List<FragmentDto> GetByType(string type) public async Task<List<FragmentDto>> GetByTypeAsync(string type)
{ {
var items = _repo.GetByType(type); var items = await _repo.GetByTypeAsync(type);
return [.. items.Select(Map)]; return [.. items.Select(Map)];
} }
public List<FragmentDto> GetAll() public async Task<List<FragmentDto>> GetAllAsync()
{ {
var items = _repo.GetAll(); var items = await _repo.GetAllAsync();
return [.. items.Select(Map)]; return [.. items.Select(Map)];
} }
public FragmentDto? GetById(Guid id) public async Task<FragmentDto?> GetByIdAsync(Guid id)
{ {
var f = _repo.GetById(id); var f = await _repo.GetByIdAsync(id);
return f is null ? null : Map(f); return f is null ? null : Map(f);
} }
} }

View File

@ -1,15 +0,0 @@
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,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services.Ai; namespace Journal.Core.Services;
public interface IAiService public interface IAiService
{ {

View File

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

View File

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

View File

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

View File

@ -1,7 +1,4 @@
using Journal.Core.Dtos; namespace Journal.Core.Services;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services.Database;
public interface IJournalDatabaseService public interface IJournalDatabaseService
{ {
@ -9,9 +6,24 @@ 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,6 +1,6 @@
using Journal.Core.Dtos; using Journal.Core.Dtos;
namespace Journal.Core.Services.Speech; namespace Journal.Core.Services;
public interface ISpeechBridgeService public interface ISpeechBridgeService
{ {

View File

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

View File

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

View File

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

View File

@ -1,22 +1,25 @@
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.Database; namespace Journal.Core.Services;
public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService public sealed class JournalDatabaseService : 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 Lock SqliteInitLock = new(); private static readonly object 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 = config; private readonly IJournalConfigService _config;
public JournalDatabaseService(IJournalConfigService config)
{
_config = config;
}
public string GetDatabasePath(string? dataDirectory = null) public string GetDatabasePath(string? dataDirectory = null)
{ {
@ -69,8 +72,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
["fragments"] = """ ["fragments"] = """
CREATE TABLE IF NOT EXISTS fragments ( CREATE TABLE IF NOT EXISTS fragments (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE, entry_id INTEGER NOT NULL,
entry_id INTEGER,
type TEXT NOT NULL, type TEXT NOT NULL,
description TEXT, description TEXT,
time TEXT, time TEXT,
@ -135,7 +137,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
using var connection = OpenEncryptedConnection(password, directory); using var connection = OpenEncryptedConnection(password, directory);
EnsureSchema(connection); CreateSchema(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;
@ -166,7 +168,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
} }
} }
public SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null) private 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));
@ -187,7 +189,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
return connection; return connection;
} }
public void EnsureSchema(SqliteConnection connection) private void CreateSchema(SqliteConnection connection)
{ {
foreach (var statement in GetSchemaStatements().Values) foreach (var statement in GetSchemaStatements().Values)
{ {
@ -217,7 +219,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
try try
{ {
using var connection = OpenEncryptedConnection(password, dataDirectory); using var connection = OpenEncryptedConnection(password, dataDirectory);
EnsureSchema(connection); CreateSchema(connection);
var ready = HasRequiredTables(connection); var ready = HasRequiredTables(connection);
return ready return ready
? (true, "SQLCipher runtime is available and schema tables are present.") ? (true, "SQLCipher runtime is available and schema tables are present.")

View File

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

View File

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

View File

@ -1,73 +0,0 @@
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

@ -0,0 +1,190 @@
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,19 +1,90 @@
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.Sidecar; namespace Journal.Core.Services;
public sealed class PythonSidecarClient(JournalConfig config) public sealed class PythonSidecarSpeechService : ISpeechBridgeService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private readonly JournalConfig _config = config; private readonly JournalConfig _config;
public async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken) public PythonSidecarSpeechService(JournalConfig config)
{
_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);
@ -95,7 +166,6 @@ public sealed class PythonSidecarClient(JournalConfig config)
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
return line; return line;
} }
return ""; return "";
} }
@ -108,7 +178,7 @@ public sealed class PythonSidecarClient(JournalConfig config)
} }
catch catch
{ {
// Ignore cleanup errors while handling timeout/failure path. // Ignore timeout cleanup failures.
} }
} }
} }

View File

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

View File

@ -1,81 +0,0 @@
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,7 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
namespace Journal.Core.Services.Vault; namespace Journal.Core.Services;
public class VaultCryptoService : IVaultCryptoService public class VaultCryptoService : IVaultCryptoService
{ {

View File

@ -2,15 +2,18 @@ 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.Vault; namespace Journal.Core.Services;
public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService public class VaultStorageService : IVaultStorageService
{ {
private readonly IVaultCryptoService _crypto = crypto; private readonly IVaultCryptoService _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.Sidecar; using Journal.Core.Services;
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddFragmentServices(); services.AddFragmentServices();

View File

@ -6,15 +6,7 @@ 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.Ai; using Journal.Core.Services;
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)>
{ {
@ -114,95 +106,73 @@ static FragmentService NewService()
return new FragmentService(repo); return new FragmentService(repo);
} }
static Entry NewEntry() static Entry NewEntry() => new(
{
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()),
dbService, new JournalDatabaseService(new JournalConfigService()),
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 Task TestCreateTrimsAsync() static async Task TestCreateTrimsAsync()
{ {
var service = NewService(); var service = NewService();
var created = service.Create(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "])); var created = await service.CreateAsync(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 Task TestUpdateAcceptsTypeAsync() static async Task TestUpdateAcceptsTypeAsync()
{ {
var service = NewService(); var service = NewService();
var created = service.Create(new CreateFragmentDto("!TRIGGER", "one")); var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "one"));
var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); var ok = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "]));
Assert(ok, "Expected update to succeed."); Assert(ok, "Expected update to succeed.");
var updated = service.GetById(created.Id); var updated = await service.GetByIdAsync(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 Task TestUpdateRejectsWhitespaceTypeAsync() static async Task TestUpdateRejectsWhitespaceTypeAsync()
{ {
var service = NewService(); var service = NewService();
var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc")); var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "desc"));
try try
{ {
_ = service.Update(created.Id, new UpdateFragmentDto(Type: " ")); _ = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " "));
} }
catch (ValidationException) catch (ValidationException)
{ {
return Task.CompletedTask; return;
} }
throw new InvalidOperationException("Expected ValidationException for whitespace type update."); throw new InvalidOperationException("Expected ValidationException for whitespace type update.");
} }
static Task TestFileRepositoryPersistsAsync() static async Task TestFileRepositoryPersistsAsync()
{ {
var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")); var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(tempRoot, "data"); var storePath = Path.Combine(tempRoot, "fragments.json");
Directory.CreateDirectory(dataDir);
const string password = "smoke-test-password";
try try
{ {
// Set up encrypted DB session IFragmentRepository repo1 = new FileFragmentRepository(storePath);
var configService = new JournalConfigService();
var dbService = new JournalDatabaseService(configService);
// First session: create a fragment
using var session1 = new DatabaseSessionService(dbService);
session1.SetPassword(password, dataDir);
var repo1 = new SqliteFragmentRepository(session1);
var service1 = new FragmentService(repo1); var service1 = new FragmentService(repo1);
var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
// Second session: verify persistence IFragmentRepository repo2 = new FileFragmentRepository(storePath);
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 = service2.GetById(created.Id); var loaded = await service2.GetByIdAsync(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.");
@ -213,8 +183,6 @@ static 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,5 +2,4 @@
<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>

360
README.md
View File

@ -1,192 +1,264 @@
# Project_Journal # Journal Backend (.NET)
A structured journaling system with encrypted monthly vaults, desktop UI, CLI tools, and optional AI-assisted analysis. 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.
## Support Matrix ## Project Structure
- Python: `3.14` ```
- Platforms: Windows and Linux (first-class), macOS (best effort) backend/
- Default profile: CPU ├── Journal.Core/ Class library — all business logic
- Optional profiles: GPU, optional NLP backend │ ├── Models/
│ │ ├── Fragment.cs Domain model (validated, owns Guid ID)
## Dependency Profiles │ │ ├── Command.cs Stdin command shape for sidecar protocol
│ │ ├── ParsedSection.cs Parsed section model for entry parity work
- `requirements_base.txt`: shared Journal runtime dependencies │ │ ├── SectionTitles.cs Canonical section title list (Python parity)
- `requirements_cpu_only.txt`: base + CPU AI stack │ │ └── JournalEntry.cs Entry domain (`date/raw_content/sections/fragments` + merge + markdown reconstruction)
- `requirements_gpu.txt`: base + GPU AI stack │ ├── Dtos/
- `requirements_nlp_optional.txt`: optional spaCy backend (auto-fallback if unavailable) │ │ └── FragmentDtos.cs Immutable records for API boundary
│ │ ├── FragmentDto Read (what goes out)
## Quickstart │ │ ├── CreateFragmentDto Create (what comes in)
│ │ └── UpdateFragmentDto Update (partial, all fields optional)
### Linux (CPU default) │ ├── Repositories/
│ │ ├── IFragmentRepository.cs Interface (data access contract)
```bash │ │ ├── InMemoryFragmentRepository.cs In-memory implementation (tests/dev)
cd Project_Journal │ │ └── FileFragmentRepository.cs File-backed implementation (default)
python3.14 -m venv .venv │ ├── Services/
source .venv/bin/activate │ │ ├── IFragmentService.cs Interface (business logic contract)
python -m pip install --upgrade pip │ │ ├── FragmentService.cs Validates, calls repo, maps to DTOs
python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt │ │ ├── IEntrySearchService.cs Entry search contract (content parity)
│ │ ├── 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
``` ```
### Linux (GPU optional) ## Architecture
```bash Each layer only knows about the one below it:
cd Project_Journal
python3.14 -m venv .venv ```
source .venv/bin/activate Sidecar (stdin/stdout) ──┐
python -m pip install --upgrade pip ├──► Services (business logic) ──► Repositories (data access)
python -m pip install -r requirements_gpu.txt API (HTTP/JSON) ─────────┘
``` ```
### Windows PowerShell (CPU default) - **Models** — Domain objects with validation. The source of truth.
- **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
cd Project_Journal # Build everything (building Sidecar also rebuilds Core if changed)
py -3.14 -m venv .venv dotnet build backend\Journal.Sidecar\Journal.Sidecar.csproj
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip # Build just the library
python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt dotnet build backend\Journal.Core\Journal.Core.csproj
# Format code
dotnet format backend\Journal.Core\Journal.Core.csproj
``` ```
On Windows + Python 3.14, `pywebview` is intentionally skipped due upstream ## Publishing
`pythonnet` build compatibility. `run_desktop.py` will auto-fallback to opening
the app in your system browser.
### Optional NLP backend (spaCy) Publish as a single-file self-contained executable (no .NET runtime install needed):
```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
$env:JOURNAL_NLP_BACKEND = "spacy" dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
python .\journal\run_desktop.py
``` ```
## Installer Script Output: `backend\Journal.Sidecar\bin\Release\net10.0\win-x64\publish\Journal.Sidecar.exe` (~70MB, everything bundled)
Use the Linux helper script: To exclude debug symbols: add `-p:DebugType=none`
```bash For a smaller build that requires .NET 10 on the target machine:
./installreqs.sh
./installreqs.sh --gpu ```powershell
./installreqs.sh --with-nlp dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 -p:PublishSingleFile=true
``` ```
## C# Backend ## Sidecar Protocol
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. The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, one JSON line out.
When run with no command-line args, this protocol mode is used by default.
### Projects ## Sidecar CLI
- **Journal.Core** — shared library: domain models, services, repositories, DTOs `Journal.Sidecar` also supports direct vault and search CLI commands:
- **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)
### Architecture ```powershell
# Load vaults into decrypted data workspace
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault load
``` # Save (rebuild) monthly vaults from decrypted markdown files
Entry (thin command dispatcher) dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault save
├── Fragments/ IFragmentService → FragmentService → IFragmentRepository (SQLCipher)
├── Entries/ IEntryFileService, IEntrySearchService, JournalParser, HtmlSanitizer # Search entries (query + filters)
├── Vault/ IVaultStorageService → VaultStorageService → IVaultCryptoService 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"
├── Database/ IJournalDatabaseService (SQLCipher schema/key derivation)
│ IDatabaseSessionService (encrypted connection lifecycle after auth)
├── Ai/ IAiService → PythonSidecarAiService | DisabledAiService
├── Speech/ ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService
├── Sidecar/ PythonSidecarClient (shared Python process I/O), SidecarCli
├── Logging/ CommandLogger, LogRedactor
└── Config/ IJournalConfigService → JournalConfigService
``` ```
Services are organized under `Journal.Core/Services/` in domain-specific subdirectories, each with its own namespace (e.g. `Journal.Core.Services.Ai`). 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>`.
### Build & Run Optional path overrides:
- `--vault-dir <path>`
- `--data-dir <path>`
- Env fallback: `JOURNAL_VAULT_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_APP_DIR`
```bash Search CLI flags:
cd backend - positional `query` (optional)
dotnet build - `--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" }
}
``` ```
Run the API server: **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
```bash ### Available Actions
dotnet run --project Journal.Api
| 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": [] } }
``` ```
Run the sidecar (stdin/stdout mode): Error:
```json
```bash { "ok": false, "error": "Description is required" }
dotnet run --project Journal.Sidecar
``` ```
Sidecar CLI commands: ## Extending with New Modules
```bash The `Command` class is generic — new modules use the same dot-notation pattern:
dotnet run --project Journal.Sidecar -- vault load --password <value>
dotnet run --project Journal.Sidecar -- vault save --password <value> ```
dotnet run --project Journal.Sidecar -- search "your query" --tag stress --start-date 2026-02-01 vault.unlock → IVaultService (future)
vault.lock
entries.list → IEntryService (future)
entries.create
ai.health → IAiService (implemented bridge)
ai.summarize_* → IAiService (implemented bridge)
ai.chat → IAiService (implemented bridge)
ai.embed → IAiService (implemented bridge)
db.status → IJournalDatabaseService (in-progress DB parity)
search.query → ISearchService (future)
``` ```
Run smoke tests: To add a module:
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`
```bash ## Dependency Injection
dotnet run --project Journal.SmokeTests
`ServiceCollectionExtensions.cs` wires everything up. Any host (sidecar, API, tests) calls:
```csharp
services.AddFragmentServices();
``` ```
### Environment Variables This registers:
- `IFragmentRepository``FileFragmentRepository` (singleton — persisted fragment store)
- `IFragmentService``FragmentService` (transient — fresh instance per request)
- `JOURNAL_PROJECT_ROOT` — override project root detection ## Fragment Store Location
- `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 `FileFragmentRepository` persists data to:
- Vault: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iterations) - default: `.journal-sidecar/fragments.json` under current working directory
- Database: SQLCipher with PBKDF2-derived key - override: `JOURNAL_FRAGMENT_STORE_PATH` environment variable
- 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 ## Legacy Vault Compatibility Note
- Decrypted journal data in `journal/data` is cleared on graceful shutdown. The legacy Python placeholder file `_init_vault.vault` is treated as obsolete.
- Vault save/load commands remain unchanged. During vault load, the C# backend ignores this file for decryption and removes it.
This preserves compatibility while migrating older vault directories forward.

View File

@ -1,84 +0,0 @@
# 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)