development #1
35
Journal.Core/Dtos/CommandDtos.cs
Normal file
35
Journal.Core/Dtos/CommandDtos.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
namespace Journal.Core.Dtos;
|
||||||
|
|
||||||
|
internal sealed record VaultInitializePayload(string Password, string VaultDirectory);
|
||||||
|
internal sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
|
||||||
|
internal sealed record ClearDataPayload(string DataDirectory);
|
||||||
|
internal sealed record EntryListPayload(string? DataDirectory = null);
|
||||||
|
internal sealed record EntryLoadPayload(string FilePath);
|
||||||
|
public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
|
||||||
|
public sealed record EntryListItem(string FileName, string FilePath);
|
||||||
|
public sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
|
||||||
|
public sealed record EntrySaveResult(string FilePath);
|
||||||
|
internal sealed record DatabasePayload(string Password, string? DataDirectory = null);
|
||||||
|
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
||||||
|
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
|
||||||
|
internal sealed record AiChatPayload(string Prompt);
|
||||||
|
internal sealed record AiEmbedPayload(string Content);
|
||||||
|
internal sealed record SpeechTranscribePayload(
|
||||||
|
string? AudioBase64 = null,
|
||||||
|
string? Audio_Base64 = null,
|
||||||
|
string? Engine = null,
|
||||||
|
string? WhisperModel = null,
|
||||||
|
string? Whisper_Model = null,
|
||||||
|
string? Text = null,
|
||||||
|
int? SimulateDelayMs = null,
|
||||||
|
int? Simulate_Delay_Ms = null);
|
||||||
|
internal sealed record SearchEntriesPayload(
|
||||||
|
string DataDirectory,
|
||||||
|
string? Query = null,
|
||||||
|
string? Section = null,
|
||||||
|
string? StartDate = null,
|
||||||
|
string? EndDate = null,
|
||||||
|
List<string>? Tags = null,
|
||||||
|
List<string>? Types = null,
|
||||||
|
List<string>? Checked = null,
|
||||||
|
List<string>? Unchecked = null);
|
||||||
18
Journal.Core/Dtos/DatabaseDtos.cs
Normal file
18
Journal.Core/Dtos/DatabaseDtos.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace Journal.Core.Dtos;
|
||||||
|
|
||||||
|
public sealed record JournalDatabaseStatus(
|
||||||
|
string DatabasePath,
|
||||||
|
int KeyLengthBytes,
|
||||||
|
int Iterations,
|
||||||
|
string KeyDerivation,
|
||||||
|
IReadOnlyList<string> SchemaTables,
|
||||||
|
string SchemaBootstrapPath,
|
||||||
|
bool RuntimeReady,
|
||||||
|
string RuntimeMessage);
|
||||||
|
|
||||||
|
public sealed record JournalDatabaseHydrationResult(
|
||||||
|
string DatabasePath,
|
||||||
|
string SchemaBootstrapPath,
|
||||||
|
int EntryFilesProcessed,
|
||||||
|
bool RuntimeReady,
|
||||||
|
string Message);
|
||||||
@ -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;
|
using Journal.Core.Services.Ai;
|
||||||
|
using Journal.Core.Services.Config;
|
||||||
|
using Journal.Core.Services.Database;
|
||||||
|
using Journal.Core.Services.Entries;
|
||||||
|
using Journal.Core.Services.Fragments;
|
||||||
|
using Journal.Core.Services.Logging;
|
||||||
|
using Journal.Core.Services.Speech;
|
||||||
|
using Journal.Core.Services.Vault;
|
||||||
|
|
||||||
namespace Journal.Core;
|
namespace Journal.Core;
|
||||||
|
|
||||||
public class Entry
|
public class Entry(
|
||||||
|
IFragmentService fragments,
|
||||||
|
IEntrySearchService entrySearch,
|
||||||
|
IVaultStorageService vaultStorage,
|
||||||
|
IJournalDatabaseService database,
|
||||||
|
IDatabaseSessionService databaseSession,
|
||||||
|
IJournalConfigService config,
|
||||||
|
IAiService ai,
|
||||||
|
ISpeechBridgeService speech,
|
||||||
|
IEntryFileService entryFiles,
|
||||||
|
CommandLogger logger)
|
||||||
{
|
{
|
||||||
private readonly IFragmentService _fragments;
|
private readonly IFragmentService _fragments = fragments;
|
||||||
private readonly IEntrySearchService _entrySearch;
|
private readonly IEntrySearchService _entrySearch = entrySearch;
|
||||||
private readonly IVaultStorageService _vaultStorage;
|
private readonly IVaultStorageService _vaultStorage = vaultStorage;
|
||||||
private readonly IJournalDatabaseService _database;
|
private readonly IJournalDatabaseService _database = database;
|
||||||
private readonly IJournalConfigService _config;
|
private readonly IDatabaseSessionService _databaseSession = databaseSession;
|
||||||
private readonly IAiService _ai;
|
private readonly IJournalConfigService _config = config;
|
||||||
private readonly ISpeechBridgeService _speech;
|
private readonly IAiService _ai = ai;
|
||||||
|
private readonly ISpeechBridgeService _speech = speech;
|
||||||
|
private readonly IEntryFileService _entryFiles = entryFiles;
|
||||||
|
private readonly CommandLogger _logger = logger;
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
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();
|
||||||
LogStart(action, correlationId, cmd.Payload);
|
CommandLogger.LogStart(action, correlationId, cmd.Payload);
|
||||||
object? result;
|
object? result;
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -81,18 +81,18 @@ public class Entry
|
|||||||
switch (action)
|
switch (action)
|
||||||
{
|
{
|
||||||
case "fragments.list":
|
case "fragments.list":
|
||||||
result = await _fragments.GetAllAsync();
|
result = _fragments.GetAll();
|
||||||
break;
|
break;
|
||||||
case "fragments.get":
|
case "fragments.get":
|
||||||
if (!Guid.TryParse(cmd.Id, out var getId))
|
if (!Guid.TryParse(cmd.Id, out var getId))
|
||||||
return Error("Invalid or missing id");
|
return Error("Invalid or missing id");
|
||||||
result = await _fragments.GetByIdAsync(getId);
|
result = _fragments.GetById(getId);
|
||||||
break;
|
break;
|
||||||
case "fragments.create":
|
case "fragments.create":
|
||||||
var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload);
|
var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload);
|
||||||
if (createDto is null)
|
if (createDto is null)
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = await _fragments.CreateAsync(createDto);
|
result = _fragments.Create(createDto);
|
||||||
break;
|
break;
|
||||||
case "fragments.update":
|
case "fragments.update":
|
||||||
if (!Guid.TryParse(cmd.Id, out var updateId))
|
if (!Guid.TryParse(cmd.Id, out var updateId))
|
||||||
@ -100,15 +100,15 @@ public class Entry
|
|||||||
var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload);
|
var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload);
|
||||||
if (updateDto is null)
|
if (updateDto is null)
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = await _fragments.UpdateAsync(updateId, updateDto);
|
result = _fragments.Update(updateId, updateDto);
|
||||||
break;
|
break;
|
||||||
case "fragments.delete":
|
case "fragments.delete":
|
||||||
if (!Guid.TryParse(cmd.Id, out var deleteId))
|
if (!Guid.TryParse(cmd.Id, out var deleteId))
|
||||||
return Error("Invalid or missing id");
|
return Error("Invalid or missing id");
|
||||||
result = await _fragments.RemoveAsync(deleteId);
|
result = _fragments.Remove(deleteId);
|
||||||
break;
|
break;
|
||||||
case "fragments.search":
|
case "fragments.search":
|
||||||
result = await _fragments.SearchAsync(cmd.Type, cmd.Tag);
|
result = _fragments.Search(cmd.Type, cmd.Tag);
|
||||||
break;
|
break;
|
||||||
case "search.entries":
|
case "search.entries":
|
||||||
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
|
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
|
||||||
@ -131,19 +131,19 @@ public class Entry
|
|||||||
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
|
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
|
||||||
? listPayload.DataDirectory
|
? listPayload.DataDirectory
|
||||||
: _config.Current.DataDirectory;
|
: _config.Current.DataDirectory;
|
||||||
result = ListEntries(listDataDirectory);
|
result = _entryFiles.ListEntries(listDataDirectory);
|
||||||
break;
|
break;
|
||||||
case "entries.load":
|
case "entries.load":
|
||||||
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
|
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
|
||||||
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
|
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = LoadEntry(loadEntryPayload.FilePath);
|
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
|
||||||
break;
|
break;
|
||||||
case "entries.save":
|
case "entries.save":
|
||||||
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
|
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
|
||||||
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
|
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
||||||
break;
|
break;
|
||||||
case "config.get":
|
case "config.get":
|
||||||
result = _config.Current;
|
result = _config.Current;
|
||||||
@ -211,6 +211,7 @@ public class Entry
|
|||||||
if (loadPayload is null)
|
if (loadPayload is null)
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
|
result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
|
||||||
|
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory);
|
||||||
break;
|
break;
|
||||||
case "vault.save_current_month":
|
case "vault.save_current_month":
|
||||||
var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload);
|
var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload);
|
||||||
@ -254,124 +255,56 @@ public class Entry
|
|||||||
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
|
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
|
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
|
||||||
|
_databaseSession.SetPassword(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
LogFailure(action, correlationId, "unknown_action");
|
CommandLogger.LogFailure(action, correlationId, "unknown_action");
|
||||||
return Error($"Unknown action: {action}");
|
return Error($"Unknown action: {action}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
LogFailure(action, correlationId, "invalid_payload_json");
|
CommandLogger.LogFailure(action, correlationId, "invalid_payload_json");
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
LogFailure(action, correlationId, "validation", ex.Message);
|
CommandLogger.LogFailure(action, correlationId, "validation", ex.Message);
|
||||||
return Error(ex.Message);
|
return Error(ex.Message);
|
||||||
}
|
}
|
||||||
catch (ArgumentException ex)
|
catch (ArgumentException ex)
|
||||||
{
|
{
|
||||||
LogFailure(action, correlationId, "argument", ex.Message);
|
CommandLogger.LogFailure(action, correlationId, "argument", ex.Message);
|
||||||
return Error(ex.Message);
|
return Error(ex.Message);
|
||||||
}
|
}
|
||||||
catch (TimeoutException ex)
|
catch (TimeoutException ex)
|
||||||
{
|
{
|
||||||
LogFailure(action, correlationId, "timeout", ex.Message);
|
CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message);
|
||||||
return Error(ex.Message);
|
return Error(ex.Message);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
LogFailure(action, correlationId, "invalid_operation", ex.Message);
|
CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message);
|
||||||
return Error(ex.Message);
|
return Error(ex.Message);
|
||||||
}
|
}
|
||||||
catch (FileNotFoundException ex)
|
catch (FileNotFoundException ex)
|
||||||
{
|
{
|
||||||
LogFailure(action, correlationId, "not_found", ex.Message);
|
CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message);
|
||||||
return Error(ex.Message);
|
return Error(ex.Message);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
LogFailure(action, correlationId, "internal_error");
|
CommandLogger.LogFailure(action, correlationId, "internal_error");
|
||||||
return Error("Internal error");
|
return Error("Internal error");
|
||||||
}
|
}
|
||||||
|
|
||||||
LogSuccess(action, correlationId);
|
CommandLogger.LogSuccess(action, correlationId);
|
||||||
return JsonSerializer.Serialize(new { ok = true, data = result });
|
return JsonSerializer.Serialize(new { ok = true, data = result });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Error(string message)
|
private static string Error(string message)
|
||||||
=> JsonSerializer.Serialize(new { ok = false, error = message });
|
=> JsonSerializer.Serialize(new { ok = false, error = message });
|
||||||
|
|
||||||
private void LogStart(string action, string correlationId, JsonElement? payload)
|
|
||||||
{
|
|
||||||
var redactedPayload = LogRedactor.RedactPayload(payload);
|
|
||||||
EmitLog("information", action, correlationId, "start", redactedPayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LogSuccess(string action, string correlationId)
|
|
||||||
{
|
|
||||||
EmitLog("information", action, correlationId, "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LogFailure(string action, string correlationId, string errorType, string? message = null)
|
|
||||||
{
|
|
||||||
var details = string.IsNullOrWhiteSpace(message)
|
|
||||||
? ""
|
|
||||||
: (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)");
|
|
||||||
EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EmitLog(
|
|
||||||
string level,
|
|
||||||
string action,
|
|
||||||
string correlationId,
|
|
||||||
string outcome,
|
|
||||||
object? payload = null,
|
|
||||||
string? errorType = null,
|
|
||||||
string? details = null)
|
|
||||||
{
|
|
||||||
if (!ShouldLog(level))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var envelope = new
|
|
||||||
{
|
|
||||||
timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
|
||||||
level,
|
|
||||||
component = nameof(Entry),
|
|
||||||
action,
|
|
||||||
correlation_id = correlationId,
|
|
||||||
outcome,
|
|
||||||
error_type = errorType,
|
|
||||||
details,
|
|
||||||
payload
|
|
||||||
};
|
|
||||||
Console.Error.WriteLine(JsonSerializer.Serialize(envelope));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ShouldLog(string level)
|
|
||||||
{
|
|
||||||
var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning")
|
|
||||||
.Trim()
|
|
||||||
.ToLowerInvariant();
|
|
||||||
var configuredRank = LogLevelRank(configured);
|
|
||||||
var incomingRank = LogLevelRank(level);
|
|
||||||
return incomingRank >= configuredRank;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int LogLevelRank(string level) => level switch
|
|
||||||
{
|
|
||||||
"trace" => 0,
|
|
||||||
"debug" => 1,
|
|
||||||
"information" => 2,
|
|
||||||
"info" => 2,
|
|
||||||
"warning" => 3,
|
|
||||||
"warn" => 3,
|
|
||||||
"error" => 4,
|
|
||||||
"critical" => 5,
|
|
||||||
_ => 3
|
|
||||||
};
|
|
||||||
|
|
||||||
private static T? DeserializePayload<T>(JsonElement? payload)
|
private static T? DeserializePayload<T>(JsonElement? payload)
|
||||||
{
|
{
|
||||||
if (payload is null)
|
if (payload is null)
|
||||||
@ -395,153 +328,4 @@ public class Entry
|
|||||||
|
|
||||||
throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time.");
|
throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dataDirectory))
|
|
||||||
return [];
|
|
||||||
|
|
||||||
return Directory.GetFiles(dataDirectory, "*.md")
|
|
||||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
|
||||||
.Select(path => new EntryListItem(
|
|
||||||
FileName: Path.GetFileName(path),
|
|
||||||
FilePath: Path.GetFullPath(path)))
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static EntryLoadResult LoadEntry(string filePath)
|
|
||||||
{
|
|
||||||
var normalizedPath = Path.GetFullPath(filePath);
|
|
||||||
if (!File.Exists(normalizedPath))
|
|
||||||
throw new FileNotFoundException($"Entry file not found: {normalizedPath}");
|
|
||||||
|
|
||||||
var rawContent = StripRichHtml(File.ReadAllText(normalizedPath));
|
|
||||||
var fileStem = Path.GetFileNameWithoutExtension(normalizedPath);
|
|
||||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
|
||||||
|
|
||||||
return new EntryLoadResult(
|
|
||||||
Date: entry.Date,
|
|
||||||
FileName: Path.GetFileName(normalizedPath),
|
|
||||||
FilePath: normalizedPath,
|
|
||||||
RawContent: entry.RawContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
|
||||||
{
|
|
||||||
var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory);
|
|
||||||
var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
|
|
||||||
var sanitizedContent = StripRichHtml(payload.Content ?? "");
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
|
||||||
|
|
||||||
if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
File.WriteAllText(targetPath, sanitizedContent);
|
|
||||||
return new EntrySaveResult(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
File.AppendAllText(targetPath, "\n\n" + sanitizedContent.Trim());
|
|
||||||
return new EntrySaveResult(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
string finalContent;
|
|
||||||
if (File.Exists(targetPath))
|
|
||||||
{
|
|
||||||
var existingContent = File.ReadAllText(targetPath);
|
|
||||||
var fileStem = Path.GetFileNameWithoutExtension(targetPath);
|
|
||||||
var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem);
|
|
||||||
var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem);
|
|
||||||
existingEntry.MergeWith(newEntryData);
|
|
||||||
finalContent = existingEntry.ToMarkdown();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
finalContent = sanitizedContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
File.WriteAllText(targetPath, finalContent);
|
|
||||||
return new EntrySaveResult(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveTargetPath(string? filePath, string defaultDataDirectory)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(filePath))
|
|
||||||
return Path.GetFullPath(filePath);
|
|
||||||
|
|
||||||
return Path.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool LooksLikeRichHtml(string content)
|
|
||||||
{
|
|
||||||
var lowered = content.ToLowerInvariant();
|
|
||||||
string[] markers =
|
|
||||||
[
|
|
||||||
"<p", "</p>", "<div", "<span", "<table", "<tr", "<td", "<li", "<ul", "<ol",
|
|
||||||
"style=", "font-family:", "-webkit-text-stroke"
|
|
||||||
];
|
|
||||||
if (markers.Any(marker => lowered.Contains(marker, StringComparison.Ordinal)))
|
|
||||||
return true;
|
|
||||||
return Regex.Matches(lowered, "</?[a-z][^>]*>").Count >= 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string StripRichHtml(string content)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
|
||||||
return content;
|
|
||||||
if (!LooksLikeRichHtml(content))
|
|
||||||
return content;
|
|
||||||
|
|
||||||
var text = content.Replace("\r\n", "\n").Replace("\r", "\n");
|
|
||||||
text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
|
||||||
text = Regex.Replace(text, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
|
|
||||||
text = Regex.Replace(text, "</(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
|
|
||||||
text = Regex.Replace(text, "<li\\b[^>]*>", "\n- ", RegexOptions.IgnoreCase);
|
|
||||||
text = Regex.Replace(text, "</li>", "\n", RegexOptions.IgnoreCase);
|
|
||||||
text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
|
|
||||||
text = Regex.Replace(text, "</(td|th)>", " ", RegexOptions.IgnoreCase);
|
|
||||||
text = Regex.Replace(text, "<hr\\b[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
|
|
||||||
text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline);
|
|
||||||
text = WebUtility.HtmlDecode(text)
|
|
||||||
.Replace('\u00a0', ' ')
|
|
||||||
.Replace("\u200b", "", StringComparison.Ordinal);
|
|
||||||
text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd()));
|
|
||||||
text = Regex.Replace(text, "[ \\t]{2,}", " ");
|
|
||||||
text = Regex.Replace(text, "\n{3,}", "\n\n").Trim();
|
|
||||||
return string.IsNullOrEmpty(text) ? content : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record VaultInitializePayload(string Password, string VaultDirectory);
|
|
||||||
private sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
|
|
||||||
private sealed record ClearDataPayload(string DataDirectory);
|
|
||||||
private sealed record EntryListPayload(string? DataDirectory = null);
|
|
||||||
private sealed record EntryLoadPayload(string FilePath);
|
|
||||||
private sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
|
|
||||||
private sealed record EntryListItem(string FileName, string FilePath);
|
|
||||||
private sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
|
|
||||||
private sealed record EntrySaveResult(string FilePath);
|
|
||||||
private sealed record DatabasePayload(string Password, string? DataDirectory = null);
|
|
||||||
private sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
|
||||||
private sealed record AiSummarizeAllPayload(List<string>? Entries);
|
|
||||||
private sealed record AiChatPayload(string Prompt);
|
|
||||||
private sealed record AiEmbedPayload(string Content);
|
|
||||||
private sealed record SpeechTranscribePayload(
|
|
||||||
string? AudioBase64 = null,
|
|
||||||
string? Audio_Base64 = null,
|
|
||||||
string? Engine = null,
|
|
||||||
string? WhisperModel = null,
|
|
||||||
string? Whisper_Model = null,
|
|
||||||
string? Text = null,
|
|
||||||
int? SimulateDelayMs = null,
|
|
||||||
int? Simulate_Delay_Ms = null);
|
|
||||||
private sealed record SearchEntriesPayload(
|
|
||||||
string DataDirectory,
|
|
||||||
string? Query = null,
|
|
||||||
string? Section = null,
|
|
||||||
string? StartDate = null,
|
|
||||||
string? EndDate = null,
|
|
||||||
List<string>? Tags = null,
|
|
||||||
List<string>? Types = null,
|
|
||||||
List<string>? Checked = null,
|
|
||||||
List<string>? Unchecked = null);
|
|
||||||
}
|
}
|
||||||
|
|||||||
33
Journal.Core/Repositories/DiskEntryFileRepository.cs
Normal file
33
Journal.Core/Repositories/DiskEntryFileRepository.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
namespace Journal.Core.Repositories;
|
||||||
|
|
||||||
|
public sealed class DiskEntryFileRepository : IEntryFileRepository
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> ListMarkdownFiles(string dataDirectory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dataDirectory))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return [.. Directory.GetFiles(dataDirectory, "*.md").OrderBy(Path.GetFileName, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ReadFile(string filePath) => File.ReadAllText(filePath);
|
||||||
|
|
||||||
|
public void WriteFile(string filePath, string content) => File.WriteAllText(filePath, content);
|
||||||
|
|
||||||
|
public void AppendFile(string filePath, string content) => File.AppendAllText(filePath, content);
|
||||||
|
|
||||||
|
public bool FileExists(string filePath) => File.Exists(filePath);
|
||||||
|
|
||||||
|
public string GetFullPath(string filePath) => Path.GetFullPath(filePath);
|
||||||
|
|
||||||
|
public string GetFileName(string filePath) => Path.GetFileName(filePath);
|
||||||
|
|
||||||
|
public string GetFileNameWithoutExtension(string filePath) => Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
|
||||||
|
public void EnsureDirectory(string path)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,228 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Journal.Core.Models;
|
|
||||||
|
|
||||||
namespace Journal.Core.Repositories;
|
|
||||||
|
|
||||||
public class FileFragmentRepository : IFragmentRepository
|
|
||||||
{
|
|
||||||
private readonly Lock _lock = new();
|
|
||||||
private readonly string _storagePath;
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
|
||||||
{
|
|
||||||
WriteIndented = true
|
|
||||||
};
|
|
||||||
private readonly List<Fragment> _store;
|
|
||||||
|
|
||||||
public FileFragmentRepository() : this(storagePath: null)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public FileFragmentRepository(string? storagePath)
|
|
||||||
{
|
|
||||||
_storagePath = ResolveStoragePath(storagePath);
|
|
||||||
_store = LoadStore(_storagePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<Fragment>> GetAllAsync()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return Task.FromResult(_store.ToList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<Fragment?> GetByIdAsync(Guid id)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return Task.FromResult(_store.FirstOrDefault(f => f.Id == id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddAsync(Fragment fragment)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(fragment);
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
Normalize(fragment);
|
|
||||||
_store.Add(fragment);
|
|
||||||
SaveStoreLocked();
|
|
||||||
}
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> RemoveAsync(Guid id)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var item = _store.FirstOrDefault(f => f.Id == id);
|
|
||||||
if (item is null)
|
|
||||||
return Task.FromResult(false);
|
|
||||||
|
|
||||||
var removed = _store.Remove(item);
|
|
||||||
if (removed)
|
|
||||||
SaveStoreLocked();
|
|
||||||
return Task.FromResult(removed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> UpdateAsync(
|
|
||||||
Guid id,
|
|
||||||
string? type = null,
|
|
||||||
string? description = null,
|
|
||||||
IEnumerable<string>? tags = null,
|
|
||||||
DateTimeOffset? time = null)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var item = _store.FirstOrDefault(f => f.Id == id);
|
|
||||||
if (item is null)
|
|
||||||
return Task.FromResult(false);
|
|
||||||
|
|
||||||
if (type != null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(type))
|
|
||||||
throw new ArgumentException("Type cannot be empty", nameof(type));
|
|
||||||
item.Type = type.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description != null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(description))
|
|
||||||
throw new ArgumentException("Description cannot be empty", nameof(description));
|
|
||||||
item.Description = description.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags != null)
|
|
||||||
{
|
|
||||||
item.Tags = [..
|
|
||||||
tags.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
||||||
.Select(t => t.Trim())];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (time.HasValue)
|
|
||||||
item.Time = time.Value;
|
|
||||||
|
|
||||||
SaveStoreLocked();
|
|
||||||
return Task.FromResult(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<Fragment>> GetByTagAsync(string tag)
|
|
||||||
{
|
|
||||||
var q = tag?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(q))
|
|
||||||
return Task.FromResult(new List<Fragment>());
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var items = _store
|
|
||||||
.Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.ToList();
|
|
||||||
return Task.FromResult(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<Fragment>> GetByTypeAsync(string type)
|
|
||||||
{
|
|
||||||
var q = type?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(q))
|
|
||||||
return Task.FromResult(new List<Fragment>());
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var items = _store
|
|
||||||
.Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
|
||||||
return Task.FromResult(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
|
|
||||||
{
|
|
||||||
var qType = type?.Trim();
|
|
||||||
var qTag = tag?.Trim();
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
IEnumerable<Fragment> results = _store;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(qType))
|
|
||||||
results = results.Where(f => f.Type.Contains(qType, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (!string.IsNullOrWhiteSpace(qTag))
|
|
||||||
results = results.Where(f => f.Tags.Any(t => t.Contains(qTag, StringComparison.OrdinalIgnoreCase)));
|
|
||||||
if (timeAfter.HasValue)
|
|
||||||
results = results.Where(f => f.Time > timeAfter.Value);
|
|
||||||
|
|
||||||
return Task.FromResult(results.ToList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveStoragePath(string? storagePath)
|
|
||||||
{
|
|
||||||
var configured = storagePath;
|
|
||||||
if (string.IsNullOrWhiteSpace(configured))
|
|
||||||
configured = Environment.GetEnvironmentVariable("JOURNAL_FRAGMENT_STORE_PATH");
|
|
||||||
if (string.IsNullOrWhiteSpace(configured))
|
|
||||||
configured = Path.Combine(Environment.CurrentDirectory, ".journal-sidecar", "fragments.json");
|
|
||||||
|
|
||||||
return Path.GetFullPath(configured);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Fragment> LoadStore(string path)
|
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(path);
|
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
|
||||||
return [];
|
|
||||||
|
|
||||||
var json = File.ReadAllText(path);
|
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
|
||||||
return [];
|
|
||||||
|
|
||||||
var docs = JsonSerializer.Deserialize<List<FragmentDocument>>(json, _jsonOptions) ?? [];
|
|
||||||
return docs.Select(d => new Fragment(d.Id, d.Type, d.Description, d.Time, d.Tags)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveStoreLocked()
|
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(_storagePath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
|
|
||||||
var docs = _store.Select(f => new FragmentDocument
|
|
||||||
{
|
|
||||||
Id = f.Id,
|
|
||||||
Type = f.Type,
|
|
||||||
Description = f.Description,
|
|
||||||
Time = f.Time,
|
|
||||||
Tags = [.. f.Tags]
|
|
||||||
}).ToList();
|
|
||||||
var json = JsonSerializer.Serialize(docs, _jsonOptions);
|
|
||||||
|
|
||||||
var tempPath = _storagePath + ".tmp";
|
|
||||||
File.WriteAllText(tempPath, json);
|
|
||||||
File.Copy(tempPath, _storagePath, overwrite: true);
|
|
||||||
File.Delete(tempPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Normalize(Fragment fragment)
|
|
||||||
{
|
|
||||||
fragment.Type = fragment.Type.Trim();
|
|
||||||
fragment.Description = fragment.Description.Trim();
|
|
||||||
fragment.Tags = [..
|
|
||||||
fragment.Tags.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
||||||
.Select(t => t.Trim())];
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class FragmentDocument
|
|
||||||
{
|
|
||||||
public Guid Id { get; init; }
|
|
||||||
public string Type { get; init; } = "";
|
|
||||||
public string Description { get; init; } = "";
|
|
||||||
public DateTimeOffset Time { get; init; }
|
|
||||||
public List<string> Tags { get; init; } = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
Journal.Core/Repositories/IEntryFileRepository.cs
Normal file
14
Journal.Core/Repositories/IEntryFileRepository.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace Journal.Core.Repositories;
|
||||||
|
|
||||||
|
public interface IEntryFileRepository
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> ListMarkdownFiles(string dataDirectory);
|
||||||
|
string ReadFile(string filePath);
|
||||||
|
void WriteFile(string filePath, string content);
|
||||||
|
void AppendFile(string filePath, string content);
|
||||||
|
bool FileExists(string filePath);
|
||||||
|
string GetFullPath(string filePath);
|
||||||
|
string GetFileName(string filePath);
|
||||||
|
string GetFileNameWithoutExtension(string filePath);
|
||||||
|
void EnsureDirectory(string path);
|
||||||
|
}
|
||||||
@ -4,12 +4,12 @@ namespace Journal.Core.Repositories;
|
|||||||
|
|
||||||
public interface IFragmentRepository
|
public interface IFragmentRepository
|
||||||
{
|
{
|
||||||
Task<List<Fragment>> GetAllAsync();
|
List<Fragment> GetAll();
|
||||||
Task<Fragment?> GetByIdAsync(Guid id);
|
Fragment? GetById(Guid id);
|
||||||
Task AddAsync(Fragment fragment);
|
void Add(Fragment fragment);
|
||||||
Task<bool> RemoveAsync(Guid id);
|
bool Remove(Guid id);
|
||||||
Task<bool> UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null);
|
bool Update(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null);
|
||||||
Task<List<Fragment>> GetByTagAsync(string tag);
|
List<Fragment> GetByTag(string tag);
|
||||||
Task<List<Fragment>> GetByTypeAsync(string type);
|
List<Fragment> GetByType(string type);
|
||||||
Task<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
|
List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,23 +7,23 @@ public class InMemoryFragmentRepository : IFragmentRepository
|
|||||||
private readonly List<Fragment> _store = [];
|
private readonly List<Fragment> _store = [];
|
||||||
private readonly Lock _lock = new();
|
private readonly Lock _lock = new();
|
||||||
|
|
||||||
public Task<List<Fragment>> GetAllAsync()
|
public List<Fragment> GetAll()
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
return Task.FromResult(_store.ToList());
|
return _store.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Fragment?> GetByIdAsync(Guid id)
|
public Fragment? GetById(Guid id)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
return Task.FromResult(_store.FirstOrDefault(f => f.Id == id));
|
return _store.FirstOrDefault(f => f.Id == id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task AddAsync(Fragment fragment)
|
public void Add(Fragment fragment)
|
||||||
{
|
{
|
||||||
if (fragment is null) throw new ArgumentNullException(nameof(fragment));
|
if (fragment is null) throw new ArgumentNullException(nameof(fragment));
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@ -39,25 +39,24 @@ public class InMemoryFragmentRepository : IFragmentRepository
|
|||||||
|
|
||||||
_store.Add(fragment);
|
_store.Add(fragment);
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> RemoveAsync(Guid id)
|
public bool Remove(Guid id)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var item = _store.FirstOrDefault(f => f.Id == id);
|
var item = _store.FirstOrDefault(f => f.Id == id);
|
||||||
if (item is null) return Task.FromResult(false);
|
if (item is null) return false;
|
||||||
return Task.FromResult(_store.Remove(item));
|
return _store.Remove(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null)
|
public bool Update(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var item = _store.FirstOrDefault(f => f.Id == id);
|
var item = _store.FirstOrDefault(f => f.Id == id);
|
||||||
if (item is null) return Task.FromResult(false);
|
if (item is null) return false;
|
||||||
|
|
||||||
if (type != null)
|
if (type != null)
|
||||||
{
|
{
|
||||||
@ -81,31 +80,31 @@ public class InMemoryFragmentRepository : IFragmentRepository
|
|||||||
if (time.HasValue)
|
if (time.HasValue)
|
||||||
item.Time = time.Value;
|
item.Time = time.Value;
|
||||||
|
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<Fragment>> GetByTagAsync(string tag)
|
public List<Fragment> GetByTag(string tag)
|
||||||
{
|
{
|
||||||
var q = tag?.Trim();
|
var q = tag?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List<Fragment>());
|
if (string.IsNullOrWhiteSpace(q)) return [];
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
return Task.FromResult(_store.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(q, StringComparison.OrdinalIgnoreCase)) == true).ToList());
|
return _store.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(q, StringComparison.OrdinalIgnoreCase)) == true).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<Fragment>> GetByTypeAsync(string type)
|
public List<Fragment> GetByType(string type)
|
||||||
{
|
{
|
||||||
var q = type?.Trim();
|
var q = type?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List<Fragment>());
|
if (string.IsNullOrWhiteSpace(q)) return [];
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
return Task.FromResult(_store.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(q, StringComparison.OrdinalIgnoreCase)).ToList());
|
return _store.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
|
public List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
|
||||||
{
|
{
|
||||||
var results = _store.AsEnumerable();
|
var results = _store.AsEnumerable();
|
||||||
var qType = type?.Trim();
|
var qType = type?.Trim();
|
||||||
@ -120,7 +119,7 @@ public class InMemoryFragmentRepository : IFragmentRepository
|
|||||||
if (timeAfter.HasValue)
|
if (timeAfter.HasValue)
|
||||||
results = results.Where(f => f.Time > timeAfter.Value);
|
results = results.Where(f => f.Time > timeAfter.Value);
|
||||||
|
|
||||||
return Task.FromResult(results.ToList());
|
return results.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
314
Journal.Core/Repositories/SqliteFragmentRepository.cs
Normal file
314
Journal.Core/Repositories/SqliteFragmentRepository.cs
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
using Journal.Core.Models;
|
||||||
|
using Journal.Core.Services.Database;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Journal.Core.Repositories;
|
||||||
|
|
||||||
|
public sealed class SqliteFragmentRepository(IDatabaseSessionService session) : IFragmentRepository
|
||||||
|
{
|
||||||
|
private readonly IDatabaseSessionService _session = session;
|
||||||
|
|
||||||
|
public List<Fragment> GetAll()
|
||||||
|
{
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
return ReadAllFragments(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fragment? GetById(Guid id)
|
||||||
|
{
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
return ReadFragment(conn, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(Fragment fragment)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fragment);
|
||||||
|
Normalize(fragment);
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
InsertFragment(conn, fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Remove(Guid id)
|
||||||
|
{
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
return DeleteFragment(conn, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Update(
|
||||||
|
Guid id,
|
||||||
|
string? type = null,
|
||||||
|
string? description = null,
|
||||||
|
IEnumerable<string>? tags = null,
|
||||||
|
DateTimeOffset? time = null)
|
||||||
|
{
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
var existing = ReadFragment(conn, id);
|
||||||
|
if (existing is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (type != null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(type))
|
||||||
|
throw new ArgumentException("Type cannot be empty", nameof(type));
|
||||||
|
existing.Type = type.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description != null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
throw new ArgumentException("Description cannot be empty", nameof(description));
|
||||||
|
existing.Description = description.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags != null)
|
||||||
|
{
|
||||||
|
existing.Tags = [..
|
||||||
|
tags.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
|
.Select(t => t.Trim())];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time.HasValue)
|
||||||
|
existing.Time = time.Value;
|
||||||
|
|
||||||
|
UpdateFragmentRow(conn, existing);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Fragment> GetByTag(string tag)
|
||||||
|
{
|
||||||
|
var q = tag?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
var all = ReadAllFragments(conn);
|
||||||
|
return all
|
||||||
|
.Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Fragment> GetByType(string type)
|
||||||
|
{
|
||||||
|
var q = type?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
var all = ReadAllFragments(conn);
|
||||||
|
return all
|
||||||
|
.Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
|
||||||
|
{
|
||||||
|
var conn = _session.GetConnection();
|
||||||
|
IEnumerable<Fragment> results = ReadAllFragments(conn);
|
||||||
|
|
||||||
|
var qType = type?.Trim();
|
||||||
|
var qTag = tag?.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(qType))
|
||||||
|
results = results.Where(f => f.Type.Contains(qType, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (!string.IsNullOrWhiteSpace(qTag))
|
||||||
|
results = results.Where(f => f.Tags.Any(t => t.Contains(qTag, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
if (timeAfter.HasValue)
|
||||||
|
results = results.Where(f => f.Time > timeAfter.Value);
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void InsertFragment(SqliteConnection conn, Fragment f)
|
||||||
|
{
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO fragments (guid, entry_id, type, description, time)
|
||||||
|
VALUES (@guid, NULL, @type, @description, @time);
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
|
||||||
|
cmd.Parameters.AddWithValue("@type", f.Type);
|
||||||
|
cmd.Parameters.AddWithValue("@description", f.Description);
|
||||||
|
cmd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
var fragmentRowId = GetFragmentRowId(conn, f.Id);
|
||||||
|
if (fragmentRowId.HasValue)
|
||||||
|
InsertTags(conn, fragmentRowId.Value, f.Tags);
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateFragmentRow(SqliteConnection conn, Fragment f)
|
||||||
|
{
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
|
using var upd = conn.CreateCommand();
|
||||||
|
upd.CommandText = """
|
||||||
|
UPDATE fragments SET type = @type, description = @description, time = @time
|
||||||
|
WHERE guid = @guid AND entry_id IS NULL;
|
||||||
|
""";
|
||||||
|
upd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
|
||||||
|
upd.Parameters.AddWithValue("@type", f.Type);
|
||||||
|
upd.Parameters.AddWithValue("@description", f.Description);
|
||||||
|
upd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
|
||||||
|
upd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
var fragmentRowId = GetFragmentRowId(conn, f.Id);
|
||||||
|
if (fragmentRowId.HasValue)
|
||||||
|
{
|
||||||
|
using var del = conn.CreateCommand();
|
||||||
|
del.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
|
||||||
|
del.Parameters.AddWithValue("@id", fragmentRowId.Value);
|
||||||
|
del.ExecuteNonQuery();
|
||||||
|
|
||||||
|
InsertTags(conn, fragmentRowId.Value, f.Tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DeleteFragment(SqliteConnection conn, Guid id)
|
||||||
|
{
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
|
var fragmentRowId = GetFragmentRowId(conn, id);
|
||||||
|
if (fragmentRowId.HasValue)
|
||||||
|
{
|
||||||
|
using var delTags = conn.CreateCommand();
|
||||||
|
delTags.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
|
||||||
|
delTags.Parameters.AddWithValue("@id", fragmentRowId.Value);
|
||||||
|
delTags.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var delFrag = conn.CreateCommand();
|
||||||
|
delFrag.CommandText = "DELETE FROM fragments WHERE guid = @guid AND entry_id IS NULL;";
|
||||||
|
delFrag.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||||
|
var rows = delFrag.ExecuteNonQuery();
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fragment? ReadFragment(SqliteConnection conn, Guid id)
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, guid, type, description, time
|
||||||
|
FROM fragments WHERE guid = @guid AND entry_id IS NULL;
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
if (!reader.Read())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var fragment = MapRow(reader);
|
||||||
|
fragment.Tags = ReadTags(conn, reader.GetInt64(0));
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Fragment> ReadAllFragments(SqliteConnection conn)
|
||||||
|
{
|
||||||
|
var fragments = new List<Fragment>();
|
||||||
|
var rowIds = new List<long>();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, guid, type, description, time
|
||||||
|
FROM fragments WHERE guid IS NOT NULL AND entry_id IS NULL
|
||||||
|
ORDER BY time;
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
fragments.Add(MapRow(reader));
|
||||||
|
rowIds.Add(reader.GetInt64(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < fragments.Count; i++)
|
||||||
|
fragments[i].Tags = ReadTags(conn, rowIds[i]);
|
||||||
|
|
||||||
|
return fragments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ReadTags(SqliteConnection conn, long fragmentRowId)
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT t.name FROM tags t
|
||||||
|
INNER JOIN fragment_tags ft ON ft.tag_id = t.id
|
||||||
|
WHERE ft.fragment_id = @id
|
||||||
|
ORDER BY t.name;
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("@id", fragmentRowId);
|
||||||
|
|
||||||
|
var tags = new List<string>();
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
tags.Add(reader.GetString(0));
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long? GetFragmentRowId(SqliteConnection conn, Guid guid)
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT id FROM fragments WHERE guid = @guid;";
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToString("D"));
|
||||||
|
var result = cmd.ExecuteScalar();
|
||||||
|
return result is long id ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InsertTags(SqliteConnection conn, long fragmentRowId, List<string> tags)
|
||||||
|
{
|
||||||
|
if (tags.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
// Upsert into tags table
|
||||||
|
using var upsert = conn.CreateCommand();
|
||||||
|
upsert.CommandText = "INSERT OR IGNORE INTO tags (name) VALUES (@name);";
|
||||||
|
upsert.Parameters.AddWithValue("@name", tag);
|
||||||
|
upsert.ExecuteNonQuery();
|
||||||
|
|
||||||
|
// Get tag id
|
||||||
|
using var getTagId = conn.CreateCommand();
|
||||||
|
getTagId.CommandText = "SELECT id FROM tags WHERE name = @name;";
|
||||||
|
getTagId.Parameters.AddWithValue("@name", tag);
|
||||||
|
var tagId = (long)getTagId.ExecuteScalar()!;
|
||||||
|
|
||||||
|
// Link fragment to tag
|
||||||
|
using var link = conn.CreateCommand();
|
||||||
|
link.CommandText = "INSERT OR IGNORE INTO fragment_tags (fragment_id, tag_id) VALUES (@fid, @tid);";
|
||||||
|
link.Parameters.AddWithValue("@fid", fragmentRowId);
|
||||||
|
link.Parameters.AddWithValue("@tid", tagId);
|
||||||
|
link.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fragment MapRow(SqliteDataReader reader)
|
||||||
|
{
|
||||||
|
// columns: id (int), guid (text), type (text), description (text), time (text)
|
||||||
|
var guid = Guid.Parse(reader.GetString(1));
|
||||||
|
var type = reader.GetString(2);
|
||||||
|
var description = reader.IsDBNull(3) ? "" : reader.GetString(3);
|
||||||
|
var time = reader.IsDBNull(4)
|
||||||
|
? DateTimeOffset.MinValue
|
||||||
|
: DateTimeOffset.Parse(reader.GetString(4));
|
||||||
|
return new Fragment(guid, type, description, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Normalize(Fragment fragment)
|
||||||
|
{
|
||||||
|
fragment.Type = fragment.Type.Trim();
|
||||||
|
fragment.Description = fragment.Description.Trim();
|
||||||
|
fragment.Tags = [..
|
||||||
|
fragment.Tags.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
|
.Select(t => t.Trim())];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,14 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Journal.Core.Repositories;
|
using Journal.Core.Repositories;
|
||||||
using Journal.Core.Services;
|
using Journal.Core.Services.Ai;
|
||||||
|
using Journal.Core.Services.Config;
|
||||||
|
using Journal.Core.Services.Database;
|
||||||
|
using Journal.Core.Services.Entries;
|
||||||
|
using Journal.Core.Services.Fragments;
|
||||||
|
using Journal.Core.Services.Logging;
|
||||||
|
using Journal.Core.Services.Sidecar;
|
||||||
|
using Journal.Core.Services.Speech;
|
||||||
|
using Journal.Core.Services.Vault;
|
||||||
|
|
||||||
namespace Journal.Core;
|
namespace Journal.Core;
|
||||||
|
|
||||||
@ -8,7 +16,8 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddFragmentServices(this IServiceCollection services)
|
public static IServiceCollection AddFragmentServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IFragmentRepository, FileFragmentRepository>();
|
services.AddSingleton<IDatabaseSessionService, DatabaseSessionService>();
|
||||||
|
services.AddSingleton<IFragmentRepository, SqliteFragmentRepository>();
|
||||||
services.AddSingleton<IJournalConfigService, JournalConfigService>();
|
services.AddSingleton<IJournalConfigService, JournalConfigService>();
|
||||||
services.AddTransient<IFragmentService, FragmentService>();
|
services.AddTransient<IFragmentService, FragmentService>();
|
||||||
services.AddTransient<IEntrySearchService, EntrySearchService>();
|
services.AddTransient<IEntrySearchService, EntrySearchService>();
|
||||||
@ -47,6 +56,9 @@ public static class ServiceCollectionExtensions
|
|||||||
message: $"Python speech sidecar unavailable: {ex.Message}");
|
message: $"Python speech sidecar unavailable: {ex.Message}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
|
||||||
|
services.AddSingleton<IEntryFileService, EntryFileService>();
|
||||||
|
services.AddSingleton<CommandLogger>();
|
||||||
services.AddSingleton<SidecarCli>();
|
services.AddSingleton<SidecarCli>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,12 @@
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Ai;
|
||||||
|
|
||||||
public sealed class DisabledAiService : IAiService
|
public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService
|
||||||
{
|
{
|
||||||
private readonly string _provider;
|
private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
|
||||||
private readonly string _message;
|
private readonly string _message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim();
|
||||||
private readonly bool _healthy;
|
private readonly bool _healthy = healthy;
|
||||||
|
|
||||||
public DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true)
|
|
||||||
{
|
|
||||||
_provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
|
|
||||||
_message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim();
|
|
||||||
_healthy = healthy;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default) =>
|
public Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message));
|
Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message));
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Ai;
|
||||||
|
|
||||||
public interface IAiService
|
public interface IAiService
|
||||||
{
|
{
|
||||||
86
Journal.Core/Services/Ai/PythonSidecarAiService.cs
Normal file
86
Journal.Core/Services/Ai/PythonSidecarAiService.cs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Journal.Core.Dtos;
|
||||||
|
using Journal.Core.Models;
|
||||||
|
using Journal.Core.Services.Sidecar;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Ai;
|
||||||
|
|
||||||
|
public sealed class PythonSidecarAiService : IAiService
|
||||||
|
{
|
||||||
|
private readonly PythonSidecarClient _client;
|
||||||
|
|
||||||
|
public PythonSidecarAiService(JournalConfig config)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config.PythonAiSidecarPath))
|
||||||
|
throw new ArgumentException("Python AI sidecar path is required.");
|
||||||
|
if (!File.Exists(config.PythonAiSidecarPath))
|
||||||
|
throw new FileNotFoundException($"Python AI sidecar not found: {config.PythonAiSidecarPath}");
|
||||||
|
_client = new PythonSidecarClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var data = await _client.SendAsync("health", payload: new { }, cancellationToken);
|
||||||
|
if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object)
|
||||||
|
return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok");
|
||||||
|
|
||||||
|
var provider = payload.TryGetProperty("provider", out var providerNode)
|
||||||
|
? providerNode.GetString() ?? "python-sidecar"
|
||||||
|
: "python-sidecar";
|
||||||
|
var message = payload.TryGetProperty("message", out var messageNode)
|
||||||
|
? messageNode.GetString() ?? "ok"
|
||||||
|
: "ok";
|
||||||
|
var healthy = !payload.TryGetProperty("healthy", out var healthyNode) ||
|
||||||
|
healthyNode.ValueKind is JsonValueKind.True ||
|
||||||
|
(healthyNode.ValueKind is not JsonValueKind.False);
|
||||||
|
return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
throw new ArgumentException("Entry content is required.", nameof(content));
|
||||||
|
|
||||||
|
var data = await _client.SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken);
|
||||||
|
return data?.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
entries ??= [];
|
||||||
|
var data = await _client.SendAsync("summarize_all", new { entries }, cancellationToken);
|
||||||
|
return data?.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(prompt))
|
||||||
|
throw new ArgumentException("Prompt is required.", nameof(prompt));
|
||||||
|
|
||||||
|
var data = await _client.SendAsync("chat", new { prompt }, cancellationToken);
|
||||||
|
return data?.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
throw new ArgumentException("Content is required.", nameof(content));
|
||||||
|
|
||||||
|
var data = await _client.SendAsync("embed", new { content }, cancellationToken);
|
||||||
|
if (data is null || data.Value.ValueKind == JsonValueKind.Null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (data.Value.ValueKind != JsonValueKind.Array)
|
||||||
|
throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array.");
|
||||||
|
|
||||||
|
var values = new List<double>();
|
||||||
|
foreach (var item in data.Value.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind != JsonValueKind.Number)
|
||||||
|
throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value.");
|
||||||
|
values.Add(item.GetDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using Journal.Core.Models;
|
using Journal.Core.Models;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Config;
|
||||||
|
|
||||||
public interface IJournalConfigService
|
public interface IJournalConfigService
|
||||||
{
|
{
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using Journal.Core.Models;
|
using Journal.Core.Models;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Config;
|
||||||
|
|
||||||
public sealed class JournalConfigService : IJournalConfigService
|
public sealed class JournalConfigService : IJournalConfigService
|
||||||
{
|
{
|
||||||
66
Journal.Core/Services/Database/DatabaseSessionService.cs
Normal file
66
Journal.Core/Services/Database/DatabaseSessionService.cs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Database;
|
||||||
|
|
||||||
|
public sealed class DatabaseSessionService(IJournalDatabaseService database) : IDatabaseSessionService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IJournalDatabaseService _database = database;
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
private string? _password;
|
||||||
|
private string? _dataDirectory;
|
||||||
|
private SqliteConnection? _connection;
|
||||||
|
|
||||||
|
public bool IsUnlocked
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_lock) { return _password is not null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPassword(string password, string? dataDirectory = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
// If password or directory changed, close the old connection
|
||||||
|
if (_connection is not null &&
|
||||||
|
(_password != password || _dataDirectory != dataDirectory))
|
||||||
|
{
|
||||||
|
_connection.Dispose();
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_password = password;
|
||||||
|
_dataDirectory = dataDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqliteConnection GetConnection()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_password is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Database is locked. Authenticate first (e.g. vault.load_all or db.hydrate_workspace).");
|
||||||
|
|
||||||
|
if (_connection is not null)
|
||||||
|
return _connection;
|
||||||
|
|
||||||
|
_connection = _database.OpenEncryptedConnection(_password, _dataDirectory);
|
||||||
|
_database.EnsureSchema(_connection);
|
||||||
|
return _connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_connection?.Dispose();
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Journal.Core/Services/Database/IDatabaseSessionService.cs
Normal file
10
Journal.Core/Services/Database/IDatabaseSessionService.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Database;
|
||||||
|
|
||||||
|
public interface IDatabaseSessionService
|
||||||
|
{
|
||||||
|
bool IsUnlocked { get; }
|
||||||
|
void SetPassword(string password, string? dataDirectory = null);
|
||||||
|
SqliteConnection GetConnection();
|
||||||
|
}
|
||||||
@ -1,4 +1,7 @@
|
|||||||
namespace Journal.Core.Services;
|
using Journal.Core.Dtos;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Database;
|
||||||
|
|
||||||
public interface IJournalDatabaseService
|
public interface IJournalDatabaseService
|
||||||
{
|
{
|
||||||
@ -6,24 +9,9 @@ public interface IJournalDatabaseService
|
|||||||
byte[] DeriveDatabaseKey(string password);
|
byte[] DeriveDatabaseKey(string password);
|
||||||
string BuildPragmaKeyStatement(string password);
|
string BuildPragmaKeyStatement(string password);
|
||||||
IReadOnlyDictionary<string, string> GetSchemaStatements();
|
IReadOnlyDictionary<string, string> GetSchemaStatements();
|
||||||
|
SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null);
|
||||||
|
void EnsureSchema(SqliteConnection connection);
|
||||||
string WriteSchemaBootstrap(string? dataDirectory = null);
|
string WriteSchemaBootstrap(string? dataDirectory = null);
|
||||||
JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null);
|
JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null);
|
||||||
JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null);
|
JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record JournalDatabaseStatus(
|
|
||||||
string DatabasePath,
|
|
||||||
int KeyLengthBytes,
|
|
||||||
int Iterations,
|
|
||||||
string KeyDerivation,
|
|
||||||
IReadOnlyList<string> SchemaTables,
|
|
||||||
string SchemaBootstrapPath,
|
|
||||||
bool RuntimeReady,
|
|
||||||
string RuntimeMessage);
|
|
||||||
|
|
||||||
public sealed record JournalDatabaseHydrationResult(
|
|
||||||
string DatabasePath,
|
|
||||||
string SchemaBootstrapPath,
|
|
||||||
int EntryFilesProcessed,
|
|
||||||
bool RuntimeReady,
|
|
||||||
string Message);
|
|
||||||
@ -1,25 +1,22 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Journal.Core.Dtos;
|
||||||
|
using Journal.Core.Services.Config;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Database;
|
||||||
|
|
||||||
public sealed class JournalDatabaseService : IJournalDatabaseService
|
public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService
|
||||||
{
|
{
|
||||||
public const int KeySize = 32;
|
public const int KeySize = 32;
|
||||||
public const int Iterations = 600_000;
|
public const int Iterations = 600_000;
|
||||||
private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv");
|
private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv");
|
||||||
private static readonly object SqliteInitLock = new();
|
private static readonly Lock SqliteInitLock = new();
|
||||||
private static bool _sqliteInitialized;
|
private static bool _sqliteInitialized;
|
||||||
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
||||||
["entries", "sections", "fragments", "tags", "fragment_tags"];
|
["entries", "sections", "fragments", "tags", "fragment_tags"];
|
||||||
|
|
||||||
private readonly IJournalConfigService _config;
|
private readonly IJournalConfigService _config = config;
|
||||||
|
|
||||||
public JournalDatabaseService(IJournalConfigService config)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetDatabasePath(string? dataDirectory = null)
|
public string GetDatabasePath(string? dataDirectory = null)
|
||||||
{
|
{
|
||||||
@ -72,7 +69,8 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
|||||||
["fragments"] = """
|
["fragments"] = """
|
||||||
CREATE TABLE IF NOT EXISTS fragments (
|
CREATE TABLE IF NOT EXISTS fragments (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
entry_id INTEGER NOT NULL,
|
guid TEXT UNIQUE,
|
||||||
|
entry_id INTEGER,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
time TEXT,
|
time TEXT,
|
||||||
@ -137,7 +135,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
|||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
using var connection = OpenEncryptedConnection(password, directory);
|
using var connection = OpenEncryptedConnection(password, directory);
|
||||||
CreateSchema(connection);
|
EnsureSchema(connection);
|
||||||
var runtimeReady = HasRequiredTables(connection);
|
var runtimeReady = HasRequiredTables(connection);
|
||||||
|
|
||||||
var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length;
|
var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length;
|
||||||
@ -168,7 +166,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null)
|
public SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
||||||
@ -189,7 +187,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
|||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CreateSchema(SqliteConnection connection)
|
public void EnsureSchema(SqliteConnection connection)
|
||||||
{
|
{
|
||||||
foreach (var statement in GetSchemaStatements().Values)
|
foreach (var statement in GetSchemaStatements().Values)
|
||||||
{
|
{
|
||||||
@ -219,7 +217,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var connection = OpenEncryptedConnection(password, dataDirectory);
|
using var connection = OpenEncryptedConnection(password, dataDirectory);
|
||||||
CreateSchema(connection);
|
EnsureSchema(connection);
|
||||||
var ready = HasRequiredTables(connection);
|
var ready = HasRequiredTables(connection);
|
||||||
return ready
|
return ready
|
||||||
? (true, "SQLCipher runtime is available and schema tables are present.")
|
? (true, "SQLCipher runtime is available and schema tables are present.")
|
||||||
80
Journal.Core/Services/Entries/EntryFileService.cs
Normal file
80
Journal.Core/Services/Entries/EntryFileService.cs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
using Journal.Core.Dtos;
|
||||||
|
using Journal.Core.Repositories;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
|
public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService
|
||||||
|
{
|
||||||
|
private readonly IEntryFileRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||||
|
|
||||||
|
public IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
|
||||||
|
{
|
||||||
|
return [.. _repo.ListMarkdownFiles(dataDirectory)
|
||||||
|
.Select(path => new EntryListItem(
|
||||||
|
FileName: _repo.GetFileName(path),
|
||||||
|
FilePath: _repo.GetFullPath(path)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public EntryLoadResult LoadEntry(string filePath)
|
||||||
|
{
|
||||||
|
var normalizedPath = _repo.GetFullPath(filePath);
|
||||||
|
if (!_repo.FileExists(normalizedPath))
|
||||||
|
throw new FileNotFoundException($"Entry file not found: {normalizedPath}");
|
||||||
|
|
||||||
|
var rawContent = HtmlSanitizer.StripRichHtml(_repo.ReadFile(normalizedPath));
|
||||||
|
var fileStem = _repo.GetFileNameWithoutExtension(normalizedPath);
|
||||||
|
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||||
|
|
||||||
|
return new EntryLoadResult(
|
||||||
|
Date: entry.Date,
|
||||||
|
FileName: _repo.GetFileName(normalizedPath),
|
||||||
|
FilePath: normalizedPath,
|
||||||
|
RawContent: entry.RawContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
||||||
|
{
|
||||||
|
var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory);
|
||||||
|
var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
|
||||||
|
var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? "");
|
||||||
|
_repo.EnsureDirectory(targetPath);
|
||||||
|
|
||||||
|
if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_repo.WriteFile(targetPath, sanitizedContent);
|
||||||
|
return new EntrySaveResult(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_repo.AppendFile(targetPath, "\n\n" + sanitizedContent.Trim());
|
||||||
|
return new EntrySaveResult(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string finalContent;
|
||||||
|
if (_repo.FileExists(targetPath))
|
||||||
|
{
|
||||||
|
var existingContent = _repo.ReadFile(targetPath);
|
||||||
|
var fileStem = _repo.GetFileNameWithoutExtension(targetPath);
|
||||||
|
var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem);
|
||||||
|
var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem);
|
||||||
|
existingEntry.MergeWith(newEntryData);
|
||||||
|
finalContent = existingEntry.ToMarkdown();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
finalContent = sanitizedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
_repo.WriteFile(targetPath, finalContent);
|
||||||
|
return new EntrySaveResult(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveTargetPath(string? filePath, string defaultDataDirectory)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(filePath))
|
||||||
|
return _repo.GetFullPath(filePath);
|
||||||
|
|
||||||
|
return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
public class EntrySearchService : IEntrySearchService
|
public class EntrySearchService : IEntrySearchService
|
||||||
{
|
{
|
||||||
46
Journal.Core/Services/Entries/HtmlSanitizer.cs
Normal file
46
Journal.Core/Services/Entries/HtmlSanitizer.cs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
|
public static class HtmlSanitizer
|
||||||
|
{
|
||||||
|
public static string StripRichHtml(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
return content;
|
||||||
|
if (!LooksLikeRichHtml(content))
|
||||||
|
return content;
|
||||||
|
|
||||||
|
var text = content.Replace("\r\n", "\n").Replace("\r", "\n");
|
||||||
|
text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
|
text = Regex.Replace(text, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "</(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "<li\\b[^>]*>", "\n- ", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "</li>", "\n", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "</(td|th)>", " ", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "<hr\\b[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline);
|
||||||
|
text = WebUtility.HtmlDecode(text)
|
||||||
|
.Replace('\u00a0', ' ')
|
||||||
|
.Replace("\u200b", "", StringComparison.Ordinal);
|
||||||
|
text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd()));
|
||||||
|
text = Regex.Replace(text, "[ \\t]{2,}", " ");
|
||||||
|
text = Regex.Replace(text, "\n{3,}", "\n\n").Trim();
|
||||||
|
return string.IsNullOrEmpty(text) ? content : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool LooksLikeRichHtml(string content)
|
||||||
|
{
|
||||||
|
var lowered = content.ToLowerInvariant();
|
||||||
|
string[] markers =
|
||||||
|
[
|
||||||
|
"<p", "</p>", "<div", "<span", "<table", "<tr", "<td", "<li", "<ul", "<ol",
|
||||||
|
"style=", "font-family:", "-webkit-text-stroke"
|
||||||
|
];
|
||||||
|
if (markers.Any(marker => lowered.Contains(marker, StringComparison.Ordinal)))
|
||||||
|
return true;
|
||||||
|
return Regex.Matches(lowered, "</?[a-z][^>]*>").Count >= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Journal.Core/Services/Entries/IEntryFileService.cs
Normal file
10
Journal.Core/Services/Entries/IEntryFileService.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using Journal.Core.Dtos;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
|
public interface IEntryFileService
|
||||||
|
{
|
||||||
|
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
|
||||||
|
EntryLoadResult LoadEntry(string filePath);
|
||||||
|
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
public interface IEntrySearchService
|
public interface IEntrySearchService
|
||||||
{
|
{
|
||||||
@ -1,7 +1,7 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Journal.Core.Models;
|
using Journal.Core.Models;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
public static partial class JournalParser
|
public static partial class JournalParser
|
||||||
{
|
{
|
||||||
@ -3,13 +3,11 @@ using Journal.Core.Dtos;
|
|||||||
using Journal.Core.Models;
|
using Journal.Core.Models;
|
||||||
using Journal.Core.Repositories;
|
using Journal.Core.Repositories;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Fragments;
|
||||||
|
|
||||||
public class FragmentService : IFragmentService
|
public class FragmentService(IFragmentRepository repo) : IFragmentService
|
||||||
{
|
{
|
||||||
private readonly IFragmentRepository _repo;
|
private readonly IFragmentRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||||
|
|
||||||
public FragmentService(IFragmentRepository repo) => _repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
|
||||||
|
|
||||||
private static FragmentDto Map(Fragment f) => new(
|
private static FragmentDto Map(Fragment f) => new(
|
||||||
f.Id,
|
f.Id,
|
||||||
@ -19,7 +17,7 @@ public class FragmentService : IFragmentService
|
|||||||
f.Tags != null ? [.. f.Tags] : []
|
f.Tags != null ? [.. f.Tags] : []
|
||||||
);
|
);
|
||||||
|
|
||||||
public async Task<FragmentDto> CreateAsync(CreateFragmentDto dto)
|
public FragmentDto Create(CreateFragmentDto dto)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dto);
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
@ -30,11 +28,11 @@ public class FragmentService : IFragmentService
|
|||||||
if (dto.Tags != null)
|
if (dto.Tags != null)
|
||||||
f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())];
|
f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())];
|
||||||
|
|
||||||
await _repo.AddAsync(f);
|
_repo.Add(f);
|
||||||
return Map(f);
|
return Map(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> UpdateAsync(Guid id, UpdateFragmentDto dto)
|
public bool Update(Guid id, UpdateFragmentDto dto)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dto);
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type))
|
if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type))
|
||||||
@ -46,38 +44,38 @@ public class FragmentService : IFragmentService
|
|||||||
var description = dto.Description?.Trim();
|
var description = dto.Description?.Trim();
|
||||||
var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList();
|
var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList();
|
||||||
|
|
||||||
return await _repo.UpdateAsync(id, type, description, tags, dto.Time);
|
return _repo.Update(id, type, description, tags, dto.Time);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> RemoveAsync(Guid id) => _repo.RemoveAsync(id);
|
public bool Remove(Guid id) => _repo.Remove(id);
|
||||||
|
|
||||||
public async Task<List<FragmentDto>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
|
public List<FragmentDto> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
|
||||||
{
|
{
|
||||||
var items = await _repo.SearchAsync(type, tag, timeAfter);
|
var items = _repo.Search(type, tag, timeAfter);
|
||||||
return [.. items.Select(Map)];
|
return [.. items.Select(Map)];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<FragmentDto>> GetByTagAsync(string tag)
|
public List<FragmentDto> GetByTag(string tag)
|
||||||
{
|
{
|
||||||
var items = await _repo.GetByTagAsync(tag);
|
var items = _repo.GetByTag(tag);
|
||||||
return [.. items.Select(Map)];
|
return [.. items.Select(Map)];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<FragmentDto>> GetByTypeAsync(string type)
|
public List<FragmentDto> GetByType(string type)
|
||||||
{
|
{
|
||||||
var items = await _repo.GetByTypeAsync(type);
|
var items = _repo.GetByType(type);
|
||||||
return [.. items.Select(Map)];
|
return [.. items.Select(Map)];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<FragmentDto>> GetAllAsync()
|
public List<FragmentDto> GetAll()
|
||||||
{
|
{
|
||||||
var items = await _repo.GetAllAsync();
|
var items = _repo.GetAll();
|
||||||
return [.. items.Select(Map)];
|
return [.. items.Select(Map)];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FragmentDto?> GetByIdAsync(Guid id)
|
public FragmentDto? GetById(Guid id)
|
||||||
{
|
{
|
||||||
var f = await _repo.GetByIdAsync(id);
|
var f = _repo.GetById(id);
|
||||||
return f is null ? null : Map(f);
|
return f is null ? null : Map(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
15
Journal.Core/Services/Fragments/IFragmentService.cs
Normal file
15
Journal.Core/Services/Fragments/IFragmentService.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using Journal.Core.Dtos;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Fragments;
|
||||||
|
|
||||||
|
public interface IFragmentService
|
||||||
|
{
|
||||||
|
FragmentDto Create(CreateFragmentDto dto);
|
||||||
|
bool Update(Guid id, UpdateFragmentDto dto);
|
||||||
|
bool Remove(Guid id);
|
||||||
|
List<FragmentDto> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
|
||||||
|
List<FragmentDto> GetByTag(string tag);
|
||||||
|
List<FragmentDto> GetByType(string type);
|
||||||
|
List<FragmentDto> GetAll();
|
||||||
|
FragmentDto? GetById(Guid id);
|
||||||
|
}
|
||||||
@ -1,15 +0,0 @@
|
|||||||
using Journal.Core.Dtos;
|
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
|
||||||
|
|
||||||
public interface IFragmentService
|
|
||||||
{
|
|
||||||
Task<FragmentDto> CreateAsync(CreateFragmentDto dto);
|
|
||||||
Task<bool> UpdateAsync(Guid id, UpdateFragmentDto dto);
|
|
||||||
Task<bool> RemoveAsync(Guid id);
|
|
||||||
Task<List<FragmentDto>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
|
|
||||||
Task<List<FragmentDto>> GetByTagAsync(string tag);
|
|
||||||
Task<List<FragmentDto>> GetByTypeAsync(string type);
|
|
||||||
Task<List<FragmentDto>> GetAllAsync();
|
|
||||||
Task<FragmentDto?> GetByIdAsync(Guid id);
|
|
||||||
}
|
|
||||||
73
Journal.Core/Services/Logging/CommandLogger.cs
Normal file
73
Journal.Core/Services/Logging/CommandLogger.cs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Logging;
|
||||||
|
|
||||||
|
public sealed class CommandLogger
|
||||||
|
{
|
||||||
|
public static void LogStart(string action, string correlationId, JsonElement? payload)
|
||||||
|
{
|
||||||
|
var redactedPayload = LogRedactor.RedactPayload(payload);
|
||||||
|
EmitLog("information", action, correlationId, "start", redactedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LogSuccess(string action, string correlationId) => EmitLog("information", action, correlationId, "success");
|
||||||
|
|
||||||
|
public static void LogFailure(string action, string correlationId, string errorType, string? message = null)
|
||||||
|
{
|
||||||
|
var details = string.IsNullOrWhiteSpace(message)
|
||||||
|
? ""
|
||||||
|
: (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)");
|
||||||
|
EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EmitLog(
|
||||||
|
string level,
|
||||||
|
string action,
|
||||||
|
string correlationId,
|
||||||
|
string outcome,
|
||||||
|
object? payload = null,
|
||||||
|
string? errorType = null,
|
||||||
|
string? details = null)
|
||||||
|
{
|
||||||
|
if (!ShouldLog(level))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var envelope = new
|
||||||
|
{
|
||||||
|
timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||||
|
level,
|
||||||
|
component = "Entry",
|
||||||
|
action,
|
||||||
|
correlation_id = correlationId,
|
||||||
|
outcome,
|
||||||
|
error_type = errorType,
|
||||||
|
details,
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
Console.Error.WriteLine(JsonSerializer.Serialize(envelope));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldLog(string level)
|
||||||
|
{
|
||||||
|
var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning")
|
||||||
|
.Trim()
|
||||||
|
.ToLowerInvariant();
|
||||||
|
var configuredRank = LogLevelRank(configured);
|
||||||
|
var incomingRank = LogLevelRank(level);
|
||||||
|
return incomingRank >= configuredRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LogLevelRank(string level) => level switch
|
||||||
|
{
|
||||||
|
"trace" => 0,
|
||||||
|
"debug" => 1,
|
||||||
|
"information" => 2,
|
||||||
|
"info" => 2,
|
||||||
|
"warning" => 3,
|
||||||
|
"warn" => 3,
|
||||||
|
"error" => 4,
|
||||||
|
"critical" => 5,
|
||||||
|
_ => 3
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Logging;
|
||||||
|
|
||||||
public static class LogRedactor
|
public static class LogRedactor
|
||||||
{
|
{
|
||||||
@ -1,190 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Journal.Core.Dtos;
|
|
||||||
using Journal.Core.Models;
|
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
|
||||||
|
|
||||||
public sealed class PythonSidecarAiService : IAiService
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly JournalConfig _config;
|
|
||||||
|
|
||||||
public PythonSidecarAiService(JournalConfig config)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
|
||||||
throw new ArgumentException("Python AI sidecar path is required.");
|
|
||||||
if (!File.Exists(_config.PythonAiSidecarPath))
|
|
||||||
throw new FileNotFoundException($"Python AI sidecar not found: {_config.PythonAiSidecarPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var data = await SendAsync("health", payload: new { }, cancellationToken);
|
|
||||||
if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object)
|
|
||||||
return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok");
|
|
||||||
|
|
||||||
var provider = payload.TryGetProperty("provider", out var providerNode)
|
|
||||||
? providerNode.GetString() ?? "python-sidecar"
|
|
||||||
: "python-sidecar";
|
|
||||||
var message = payload.TryGetProperty("message", out var messageNode)
|
|
||||||
? messageNode.GetString() ?? "ok"
|
|
||||||
: "ok";
|
|
||||||
var healthy = !payload.TryGetProperty("healthy", out var healthyNode) ||
|
|
||||||
healthyNode.ValueKind is JsonValueKind.True ||
|
|
||||||
(healthyNode.ValueKind is JsonValueKind.False ? false : true);
|
|
||||||
return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
|
||||||
throw new ArgumentException("Entry content is required.", nameof(content));
|
|
||||||
|
|
||||||
var data = await SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken);
|
|
||||||
return data?.GetString() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
entries ??= [];
|
|
||||||
var data = await SendAsync("summarize_all", new { entries }, cancellationToken);
|
|
||||||
return data?.GetString() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(prompt))
|
|
||||||
throw new ArgumentException("Prompt is required.", nameof(prompt));
|
|
||||||
|
|
||||||
var data = await SendAsync("chat", new { prompt }, cancellationToken);
|
|
||||||
return data?.GetString() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
|
||||||
throw new ArgumentException("Content is required.", nameof(content));
|
|
||||||
|
|
||||||
var data = await SendAsync("embed", new { content }, cancellationToken);
|
|
||||||
if (data is null || data.Value.ValueKind == JsonValueKind.Null)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
if (data.Value.ValueKind != JsonValueKind.Array)
|
|
||||||
throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array.");
|
|
||||||
|
|
||||||
var values = new List<double>();
|
|
||||||
foreach (var item in data.Value.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (item.ValueKind != JsonValueKind.Number)
|
|
||||||
throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value.");
|
|
||||||
values.Add(item.GetDouble());
|
|
||||||
}
|
|
||||||
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions);
|
|
||||||
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = _config.PythonExecutable,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardInput = true,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
WorkingDirectory = _config.ProjectRoot
|
|
||||||
};
|
|
||||||
process.StartInfo.ArgumentList.Add(_config.PythonAiSidecarPath);
|
|
||||||
|
|
||||||
if (!process.Start())
|
|
||||||
throw new InvalidOperationException("Failed to start Python AI sidecar process.");
|
|
||||||
|
|
||||||
await process.StandardInput.WriteLineAsync(request);
|
|
||||||
process.StandardInput.Close();
|
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
||||||
timeoutCts.CancelAfter(_config.AiSidecarTimeoutMs);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await process.WaitForExitAsync(timeoutCts.Token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
TryKill(process);
|
|
||||||
throw new TimeoutException($"Python AI sidecar timed out after {_config.AiSidecarTimeoutMs} ms.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
|
||||||
var stderr = await process.StandardError.ReadToEndAsync();
|
|
||||||
var line = LastJsonLine(stdout);
|
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
|
||||||
throw new InvalidOperationException($"Python AI sidecar returned no JSON response. stderr: {stderr}".Trim());
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
doc = JsonDocument.Parse(line);
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Invalid JSON from Python AI sidecar: {line}", ex);
|
|
||||||
}
|
|
||||||
using (doc)
|
|
||||||
{
|
|
||||||
var root = doc.RootElement;
|
|
||||||
if (!root.TryGetProperty("ok", out var okNode) || okNode.ValueKind != JsonValueKind.True && okNode.ValueKind != JsonValueKind.False)
|
|
||||||
throw new InvalidOperationException("Python AI sidecar response missing boolean 'ok' field.");
|
|
||||||
|
|
||||||
if (!okNode.GetBoolean())
|
|
||||||
{
|
|
||||||
var error = root.TryGetProperty("error", out var errorNode)
|
|
||||||
? errorNode.GetString() ?? "Unknown sidecar error."
|
|
||||||
: "Unknown sidecar error.";
|
|
||||||
throw new InvalidOperationException(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!root.TryGetProperty("data", out var dataNode))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return dataNode.Clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string LastJsonLine(string text)
|
|
||||||
{
|
|
||||||
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
for (var i = lines.Length - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
var line = lines[i].Trim();
|
|
||||||
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TryKill(Process process)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!process.HasExited)
|
|
||||||
process.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore cleanup errors while handling timeout/failure path.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,90 +1,19 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Journal.Core.Dtos;
|
|
||||||
using Journal.Core.Models;
|
using Journal.Core.Models;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Sidecar;
|
||||||
|
|
||||||
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
public sealed class PythonSidecarClient(JournalConfig config)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly JournalConfig _config;
|
private readonly JournalConfig _config = config;
|
||||||
|
|
||||||
public PythonSidecarSpeechService(JournalConfig config)
|
public async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
|
||||||
throw new ArgumentException("Python sidecar path is required.");
|
|
||||||
if (!File.Exists(_config.PythonAiSidecarPath))
|
|
||||||
throw new FileNotFoundException($"Python sidecar not found: {_config.PythonAiSidecarPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var data = await SendAsync("speech.devices.list", new { }, cancellationToken);
|
|
||||||
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
|
||||||
return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar.");
|
|
||||||
|
|
||||||
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
|
||||||
? warningNode.GetString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var devices = new List<SpeechDeviceDto>();
|
|
||||||
if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
foreach (var device in devicesNode.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (device.ValueKind != JsonValueKind.Object)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number
|
|
||||||
? indexNode.GetInt32()
|
|
||||||
: -1;
|
|
||||||
var name = device.TryGetProperty("name", out var nameNode)
|
|
||||||
? nameNode.GetString() ?? ""
|
|
||||||
: "";
|
|
||||||
devices.Add(new SpeechDeviceDto(index, name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SpeechDevicesResultDto(devices, warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SpeechTranscribeResultDto> TranscribeAsync(
|
|
||||||
SpeechTranscribeRequestDto request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
|
||||||
|
|
||||||
var data = await SendAsync("speech.transcribe", new
|
|
||||||
{
|
|
||||||
audio_base64 = request.AudioBase64,
|
|
||||||
engine = request.Engine,
|
|
||||||
whisper_model = request.WhisperModel,
|
|
||||||
text = request.Text,
|
|
||||||
simulate_delay_ms = request.SimulateDelayMs
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
|
||||||
throw new InvalidOperationException("Python sidecar speech response must be a JSON object.");
|
|
||||||
|
|
||||||
var text = data.Value.TryGetProperty("text", out var textNode)
|
|
||||||
? textNode.GetString() ?? ""
|
|
||||||
: "";
|
|
||||||
var engine = data.Value.TryGetProperty("engine", out var engineNode)
|
|
||||||
? engineNode.GetString() ?? (request.Engine ?? "whisper")
|
|
||||||
: (request.Engine ?? "whisper");
|
|
||||||
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
|
||||||
? warningNode.GetString()
|
|
||||||
: null;
|
|
||||||
return new SpeechTranscribeResultDto(text, engine, warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions);
|
var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions);
|
||||||
|
|
||||||
@ -166,6 +95,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
|||||||
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
|
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +108,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Ignore timeout cleanup failures.
|
// Ignore cleanup errors while handling timeout/failure path.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,20 +1,16 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
|
using Journal.Core.Services.Config;
|
||||||
|
using Journal.Core.Services.Entries;
|
||||||
|
using Journal.Core.Services.Vault;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Sidecar;
|
||||||
|
|
||||||
public sealed class SidecarCli
|
public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
|
||||||
{
|
{
|
||||||
private readonly IVaultStorageService _vaultStorage;
|
private readonly IVaultStorageService _vaultStorage = vaultStorage;
|
||||||
private readonly IEntrySearchService _entrySearch;
|
private readonly IEntrySearchService _entrySearch = entrySearch;
|
||||||
private readonly IJournalConfigService _config;
|
private readonly IJournalConfigService _config = config;
|
||||||
|
|
||||||
public SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
|
|
||||||
{
|
|
||||||
_vaultStorage = vaultStorage;
|
|
||||||
_entrySearch = entrySearch;
|
|
||||||
_config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> RunAsync(string[] args, Entry entry)
|
public async Task<int> RunAsync(string[] args, Entry entry)
|
||||||
{
|
{
|
||||||
@ -34,9 +30,9 @@ public sealed class SidecarCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase))
|
||||||
return RunVaultCommand(args.Skip(1).ToArray());
|
return RunVaultCommand([.. args.Skip(1)]);
|
||||||
if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase))
|
||||||
return RunSearchCommand(args.Skip(1).ToArray());
|
return RunSearchCommand([.. args.Skip(1)]);
|
||||||
|
|
||||||
Console.Error.WriteLine($"Unknown command: {args[0]}");
|
Console.Error.WriteLine($"Unknown command: {args[0]}");
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
@ -60,7 +56,7 @@ public sealed class SidecarCli
|
|||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryParseVaultOptions(args.Skip(1).ToArray(), out var options, out var parseError))
|
if (!TryParseVaultOptions([.. args.Skip(1)], out var options, out var parseError))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(parseError);
|
Console.Error.WriteLine(parseError);
|
||||||
PrintVaultUsage();
|
PrintVaultUsage();
|
||||||
@ -1,17 +1,11 @@
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Speech;
|
||||||
|
|
||||||
public sealed class DisabledSpeechBridgeService : ISpeechBridgeService
|
public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService
|
||||||
{
|
{
|
||||||
private readonly string _provider;
|
private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
|
||||||
private readonly string _message;
|
private readonly string _message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim();
|
||||||
|
|
||||||
public DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.")
|
|
||||||
{
|
|
||||||
_provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
|
|
||||||
_message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
|
public Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Speech;
|
||||||
|
|
||||||
public interface ISpeechBridgeService
|
public interface ISpeechBridgeService
|
||||||
{
|
{
|
||||||
81
Journal.Core/Services/Speech/PythonSidecarSpeechService.cs
Normal file
81
Journal.Core/Services/Speech/PythonSidecarSpeechService.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Journal.Core.Dtos;
|
||||||
|
using Journal.Core.Models;
|
||||||
|
using Journal.Core.Services.Sidecar;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Speech;
|
||||||
|
|
||||||
|
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||||
|
{
|
||||||
|
private readonly PythonSidecarClient _client;
|
||||||
|
|
||||||
|
public PythonSidecarSpeechService(JournalConfig config)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config.PythonAiSidecarPath))
|
||||||
|
throw new ArgumentException("Python sidecar path is required.");
|
||||||
|
if (!File.Exists(config.PythonAiSidecarPath))
|
||||||
|
throw new FileNotFoundException($"Python sidecar not found: {config.PythonAiSidecarPath}");
|
||||||
|
_client = new PythonSidecarClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var data = await _client.SendAsync("speech.devices.list", new { }, cancellationToken);
|
||||||
|
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
||||||
|
return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar.");
|
||||||
|
|
||||||
|
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
||||||
|
? warningNode.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var devices = new List<SpeechDeviceDto>();
|
||||||
|
if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var device in devicesNode.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (device.ValueKind != JsonValueKind.Object)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number
|
||||||
|
? indexNode.GetInt32()
|
||||||
|
: -1;
|
||||||
|
var name = device.TryGetProperty("name", out var nameNode)
|
||||||
|
? nameNode.GetString() ?? ""
|
||||||
|
: "";
|
||||||
|
devices.Add(new SpeechDeviceDto(index, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpeechDevicesResultDto(devices, warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SpeechTranscribeResultDto> TranscribeAsync(
|
||||||
|
SpeechTranscribeRequestDto request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var data = await _client.SendAsync("speech.transcribe", new
|
||||||
|
{
|
||||||
|
audio_base64 = request.AudioBase64,
|
||||||
|
engine = request.Engine,
|
||||||
|
whisper_model = request.WhisperModel,
|
||||||
|
text = request.Text,
|
||||||
|
simulate_delay_ms = request.SimulateDelayMs
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
||||||
|
throw new InvalidOperationException("Python sidecar speech response must be a JSON object.");
|
||||||
|
|
||||||
|
var text = data.Value.TryGetProperty("text", out var textNode)
|
||||||
|
? textNode.GetString() ?? ""
|
||||||
|
: "";
|
||||||
|
var engine = data.Value.TryGetProperty("engine", out var engineNode)
|
||||||
|
? engineNode.GetString() ?? (request.Engine ?? "whisper")
|
||||||
|
: (request.Engine ?? "whisper");
|
||||||
|
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
||||||
|
? warningNode.GetString()
|
||||||
|
: null;
|
||||||
|
return new SpeechTranscribeResultDto(text, engine, warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Vault;
|
||||||
|
|
||||||
public interface IVaultCryptoService
|
public interface IVaultCryptoService
|
||||||
{
|
{
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Vault;
|
||||||
|
|
||||||
public interface IVaultStorageService
|
public interface IVaultStorageService
|
||||||
{
|
{
|
||||||
@ -1,7 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Vault;
|
||||||
|
|
||||||
public class VaultCryptoService : IVaultCryptoService
|
public class VaultCryptoService : IVaultCryptoService
|
||||||
{
|
{
|
||||||
@ -2,18 +2,15 @@ using System.IO.Compression;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
|
||||||
|
|
||||||
namespace Journal.Core.Services;
|
namespace Journal.Core.Services.Vault;
|
||||||
|
|
||||||
public class VaultStorageService : IVaultStorageService
|
public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService
|
||||||
{
|
{
|
||||||
private readonly IVaultCryptoService _crypto;
|
private readonly IVaultCryptoService _crypto = crypto;
|
||||||
private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal);
|
||||||
private readonly object _vaultIoLock = new();
|
private readonly object _vaultIoLock = new();
|
||||||
|
|
||||||
public VaultStorageService(IVaultCryptoService crypto) => _crypto = crypto;
|
|
||||||
|
|
||||||
public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault";
|
public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault";
|
||||||
|
|
||||||
public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory)
|
public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Journal.Core;
|
using Journal.Core;
|
||||||
using Journal.Core.Services;
|
using Journal.Core.Services.Sidecar;
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddFragmentServices();
|
services.AddFragmentServices();
|
||||||
|
|||||||
@ -6,7 +6,15 @@ using Journal.Core;
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
using Journal.Core.Models;
|
using Journal.Core.Models;
|
||||||
using Journal.Core.Repositories;
|
using Journal.Core.Repositories;
|
||||||
using Journal.Core.Services;
|
using Journal.Core.Services.Ai;
|
||||||
|
using Journal.Core.Services.Config;
|
||||||
|
using Journal.Core.Services.Database;
|
||||||
|
using Journal.Core.Services.Entries;
|
||||||
|
using Journal.Core.Services.Fragments;
|
||||||
|
using Journal.Core.Services.Logging;
|
||||||
|
using Journal.Core.Services.Speech;
|
||||||
|
using Journal.Core.Services.Sidecar;
|
||||||
|
using Journal.Core.Services.Vault;
|
||||||
|
|
||||||
var tests = new List<(string Name, Func<Task> Run)>
|
var tests = new List<(string Name, Func<Task> Run)>
|
||||||
{
|
{
|
||||||
@ -106,73 +114,95 @@ static FragmentService NewService()
|
|||||||
return new FragmentService(repo);
|
return new FragmentService(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Entry NewEntry() => new(
|
static Entry NewEntry()
|
||||||
NewService(),
|
{
|
||||||
new EntrySearchService(),
|
var dbService = new JournalDatabaseService(new JournalConfigService());
|
||||||
new VaultStorageService(new VaultCryptoService()),
|
var session = new DatabaseSessionService(dbService);
|
||||||
new JournalDatabaseService(new JournalConfigService()),
|
return new Entry(
|
||||||
new JournalConfigService(),
|
NewService(),
|
||||||
new DisabledAiService("none"),
|
new EntrySearchService(),
|
||||||
new DisabledSpeechBridgeService("none"));
|
new VaultStorageService(new VaultCryptoService()),
|
||||||
|
dbService,
|
||||||
|
session,
|
||||||
|
new JournalConfigService(),
|
||||||
|
new DisabledAiService("none"),
|
||||||
|
new DisabledSpeechBridgeService("none"),
|
||||||
|
new EntryFileService(new DiskEntryFileRepository()),
|
||||||
|
new CommandLogger());
|
||||||
|
}
|
||||||
|
|
||||||
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
|
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
|
||||||
|
|
||||||
static async Task TestCreateTrimsAsync()
|
static Task TestCreateTrimsAsync()
|
||||||
{
|
{
|
||||||
var service = NewService();
|
var service = NewService();
|
||||||
var created = await service.CreateAsync(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "]));
|
var created = service.Create(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "]));
|
||||||
|
|
||||||
Assert(created.Type == "!TRIGGER", "Type should be trimmed.");
|
Assert(created.Type == "!TRIGGER", "Type should be trimmed.");
|
||||||
Assert(created.Description == "stomach drop", "Description should be trimmed.");
|
Assert(created.Description == "stomach drop", "Description should be trimmed.");
|
||||||
Assert(created.Tags.Count == 2, "Expected two normalized tags.");
|
Assert(created.Tags.Count == 2, "Expected two normalized tags.");
|
||||||
Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved.");
|
Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved.");
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task TestUpdateAcceptsTypeAsync()
|
static Task TestUpdateAcceptsTypeAsync()
|
||||||
{
|
{
|
||||||
var service = NewService();
|
var service = NewService();
|
||||||
var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "one"));
|
var created = service.Create(new CreateFragmentDto("!TRIGGER", "one"));
|
||||||
var ok = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "]));
|
var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "]));
|
||||||
|
|
||||||
Assert(ok, "Expected update to succeed.");
|
Assert(ok, "Expected update to succeed.");
|
||||||
var updated = await service.GetByIdAsync(created.Id);
|
var updated = service.GetById(created.Id);
|
||||||
Assert(updated is not null, "Updated fragment should exist.");
|
Assert(updated is not null, "Updated fragment should exist.");
|
||||||
Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored.");
|
Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored.");
|
||||||
Assert(updated.Description == "two", "Updated description should be trimmed and stored.");
|
Assert(updated.Description == "two", "Updated description should be trimmed and stored.");
|
||||||
Assert(updated.Tags.Count == 1 && updated.Tags[0] == "memory", "Updated tags should be normalized.");
|
Assert(updated.Tags.Count == 1 && updated.Tags[0] == "memory", "Updated tags should be normalized.");
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task TestUpdateRejectsWhitespaceTypeAsync()
|
static Task TestUpdateRejectsWhitespaceTypeAsync()
|
||||||
{
|
{
|
||||||
var service = NewService();
|
var service = NewService();
|
||||||
var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "desc"));
|
var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc"));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_ = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " "));
|
_ = service.Update(created.Id, new UpdateFragmentDto(Type: " "));
|
||||||
}
|
}
|
||||||
catch (ValidationException)
|
catch (ValidationException)
|
||||||
{
|
{
|
||||||
return;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException("Expected ValidationException for whitespace type update.");
|
throw new InvalidOperationException("Expected ValidationException for whitespace type update.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task TestFileRepositoryPersistsAsync()
|
static Task TestFileRepositoryPersistsAsync()
|
||||||
{
|
{
|
||||||
var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N"));
|
var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N"));
|
||||||
var storePath = Path.Combine(tempRoot, "fragments.json");
|
var dataDir = Path.Combine(tempRoot, "data");
|
||||||
|
Directory.CreateDirectory(dataDir);
|
||||||
|
const string password = "smoke-test-password";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IFragmentRepository repo1 = new FileFragmentRepository(storePath);
|
// Set up encrypted DB session
|
||||||
var service1 = new FragmentService(repo1);
|
var configService = new JournalConfigService();
|
||||||
var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
|
var dbService = new JournalDatabaseService(configService);
|
||||||
|
|
||||||
IFragmentRepository repo2 = new FileFragmentRepository(storePath);
|
// First session: create a fragment
|
||||||
|
using var session1 = new DatabaseSessionService(dbService);
|
||||||
|
session1.SetPassword(password, dataDir);
|
||||||
|
var repo1 = new SqliteFragmentRepository(session1);
|
||||||
|
var service1 = new FragmentService(repo1);
|
||||||
|
var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
|
||||||
|
|
||||||
|
// Second session: verify persistence
|
||||||
|
using var session2 = new DatabaseSessionService(dbService);
|
||||||
|
session2.SetPassword(password, dataDir);
|
||||||
|
var repo2 = new SqliteFragmentRepository(session2);
|
||||||
var service2 = new FragmentService(repo2);
|
var service2 = new FragmentService(repo2);
|
||||||
var loaded = await service2.GetByIdAsync(created.Id);
|
var loaded = service2.GetById(created.Id);
|
||||||
|
|
||||||
Assert(loaded is not null, "Expected fragment to persist across repository instances.");
|
Assert(loaded is not null, "Expected fragment to persist across repository instances.");
|
||||||
Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch.");
|
Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch.");
|
||||||
@ -183,6 +213,8 @@ static async Task TestFileRepositoryPersistsAsync()
|
|||||||
if (Directory.Exists(tempRoot))
|
if (Directory.Exists(tempRoot))
|
||||||
Directory.Delete(tempRoot, recursive: true);
|
Directory.Delete(tempRoot, recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Task TestJournalEntryModelAsync()
|
static Task TestJournalEntryModelAsync()
|
||||||
|
|||||||
@ -2,4 +2,5 @@
|
|||||||
<Project Path="Journal.Api/Journal.Api.csproj" />
|
<Project Path="Journal.Api/Journal.Api.csproj" />
|
||||||
<Project Path="Journal.Core/Journal.Core.csproj" />
|
<Project Path="Journal.Core/Journal.Core.csproj" />
|
||||||
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
|
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
|
||||||
|
<Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
380
README.md
380
README.md
@ -1,264 +1,192 @@
|
|||||||
# Journal Backend (.NET)
|
# Project_Journal
|
||||||
|
|
||||||
A .NET 10 backend for the Project Journal app. Provides core journal functionality as a class library with a sidecar console app for Tauri integration and an optional HTTP API.
|
A structured journaling system with encrypted monthly vaults, desktop UI, CLI tools, and optional AI-assisted analysis.
|
||||||
|
|
||||||
## Project Structure
|
## Support Matrix
|
||||||
|
|
||||||
```
|
- Python: `3.14`
|
||||||
backend/
|
- Platforms: Windows and Linux (first-class), macOS (best effort)
|
||||||
├── Journal.Core/ Class library — all business logic
|
- Default profile: CPU
|
||||||
│ ├── Models/
|
- Optional profiles: GPU, optional NLP backend
|
||||||
│ │ ├── Fragment.cs Domain model (validated, owns Guid ID)
|
|
||||||
│ │ ├── Command.cs Stdin command shape for sidecar protocol
|
## Dependency Profiles
|
||||||
│ │ ├── ParsedSection.cs Parsed section model for entry parity work
|
|
||||||
│ │ ├── SectionTitles.cs Canonical section title list (Python parity)
|
- `requirements_base.txt`: shared Journal runtime dependencies
|
||||||
│ │ └── JournalEntry.cs Entry domain (`date/raw_content/sections/fragments` + merge + markdown reconstruction)
|
- `requirements_cpu_only.txt`: base + CPU AI stack
|
||||||
│ ├── Dtos/
|
- `requirements_gpu.txt`: base + GPU AI stack
|
||||||
│ │ └── FragmentDtos.cs Immutable records for API boundary
|
- `requirements_nlp_optional.txt`: optional spaCy backend (auto-fallback if unavailable)
|
||||||
│ │ ├── FragmentDto Read (what goes out)
|
|
||||||
│ │ ├── CreateFragmentDto Create (what comes in)
|
## Quickstart
|
||||||
│ │ └── UpdateFragmentDto Update (partial, all fields optional)
|
|
||||||
│ ├── Repositories/
|
### Linux (CPU default)
|
||||||
│ │ ├── IFragmentRepository.cs Interface (data access contract)
|
|
||||||
│ │ ├── InMemoryFragmentRepository.cs In-memory implementation (tests/dev)
|
```bash
|
||||||
│ │ └── FileFragmentRepository.cs File-backed implementation (default)
|
cd Project_Journal
|
||||||
│ ├── Services/
|
python3.14 -m venv .venv
|
||||||
│ │ ├── IFragmentService.cs Interface (business logic contract)
|
source .venv/bin/activate
|
||||||
│ │ ├── FragmentService.cs Validates, calls repo, maps to DTOs
|
python -m pip install --upgrade pip
|
||||||
│ │ ├── IEntrySearchService.cs Entry search contract (content parity)
|
python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt
|
||||||
│ │ ├── EntrySearchService.cs Searches decrypted `.md` entries by raw content query
|
|
||||||
│ │ ├── IJournalConfigService.cs Config contract for path/vault/AI/speech settings parity
|
|
||||||
│ │ ├── JournalConfigService.cs Env/default-backed config surface aligned with Python keys
|
|
||||||
│ │ ├── IAiService.cs AI bridge contract (optional provider)
|
|
||||||
│ │ ├── DisabledAiService.cs No-op AI provider for deterministic disabled mode
|
|
||||||
│ │ ├── PythonSidecarAiService.cs Local Python sidecar adapter (stdin/stdout JSON)
|
|
||||||
│ │ ├── SidecarCli.cs CLI runner (`vault` + `search`) used by Sidecar host
|
|
||||||
│ │ ├── JournalParser.cs Date + section + checkbox + fragment parser slices (Phase 2)
|
|
||||||
│ │ ├── IVaultCryptoService.cs Vault crypto contract
|
|
||||||
│ │ ├── VaultCryptoService.cs AES-256-GCM + PBKDF2 compatibility layer
|
|
||||||
│ │ ├── IVaultStorageService.cs Vault load/workflow contract
|
|
||||||
│ │ └── VaultStorageService.cs Monthly naming + load/decrypt/extract workflow
|
|
||||||
│ ├── Entry.cs Command dispatcher (stdin/stdout)
|
|
||||||
│ ├── ServiceCollectionExtensions.cs DI registration helper
|
|
||||||
│ └── Journal.Core.csproj
|
|
||||||
│
|
|
||||||
├── Journal.Sidecar/ Console app — Tauri sidecar bridge
|
|
||||||
│ ├── App.cs Boots DI container, runs Entry.RunAsync()
|
|
||||||
│ └── Journal.Sidecar.csproj References Journal.Core
|
|
||||||
│
|
|
||||||
├── Journal.Api/ Web API — HTTP endpoint wrapper (optional)
|
|
||||||
│ ├── Program.cs
|
|
||||||
│ └── Journal.Api.csproj
|
|
||||||
│
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
### Linux (GPU optional)
|
||||||
|
|
||||||
Each layer only knows about the one below it:
|
```bash
|
||||||
|
cd Project_Journal
|
||||||
```
|
python3.14 -m venv .venv
|
||||||
Sidecar (stdin/stdout) ──┐
|
source .venv/bin/activate
|
||||||
├──► Services (business logic) ──► Repositories (data access)
|
python -m pip install --upgrade pip
|
||||||
API (HTTP/JSON) ─────────┘
|
python -m pip install -r requirements_gpu.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Models** — Domain objects with validation. The source of truth.
|
### Windows PowerShell (CPU default)
|
||||||
- **DTOs** — Immutable records that cross the API boundary. Internal logic never leaks out.
|
|
||||||
- **Repositories** — Where data lives. Current default is file-backed; can evolve to SQLite/EF Core without touching anything above.
|
|
||||||
- **Services** — Business rules, validation, orchestration. Doesn't know about HTTP or stdin.
|
|
||||||
- **Entry** — Transport adapter. Translates stdin/stdout JSON into service calls.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **Journal.Core** — `Microsoft.Extensions.DependencyInjection.Abstractions` (interface-only, lightweight)
|
|
||||||
- **Journal.Sidecar** — `Microsoft.Extensions.DependencyInjection` (full container implementation) + references `Journal.Core`
|
|
||||||
- **Journal.Api** — `Microsoft.AspNetCore.OpenApi` + ASP.NET shared framework
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Build everything (building Sidecar also rebuilds Core if changed)
|
cd Project_Journal
|
||||||
dotnet build backend\Journal.Sidecar\Journal.Sidecar.csproj
|
py -3.14 -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
# Build just the library
|
python -m pip install --upgrade pip
|
||||||
dotnet build backend\Journal.Core\Journal.Core.csproj
|
python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt
|
||||||
|
|
||||||
# Format code
|
|
||||||
dotnet format backend\Journal.Core\Journal.Core.csproj
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Publishing
|
On Windows + Python 3.14, `pywebview` is intentionally skipped due upstream
|
||||||
|
`pythonnet` build compatibility. `run_desktop.py` will auto-fallback to opening
|
||||||
|
the app in your system browser.
|
||||||
|
|
||||||
Publish as a single-file self-contained executable (no .NET runtime install needed):
|
### Optional NLP backend (spaCy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -r requirements_nlp_optional.txt
|
||||||
|
python -m spacy download en_core_web_sm
|
||||||
|
```
|
||||||
|
|
||||||
|
If spaCy is missing or unsupported, Journal now auto-falls back to built-in NLP heuristics.
|
||||||
|
On current Python 3.14 environments, this optional install may be skipped due upstream spaCy compatibility.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Desktop App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ./journal/run_desktop.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m journal.cli.main --help
|
||||||
|
python -m journal.cli.main vault load
|
||||||
|
python -m journal.cli.main search "your query"
|
||||||
|
```
|
||||||
|
|
||||||
|
## NLP Backend Control
|
||||||
|
|
||||||
|
Set `JOURNAL_NLP_BACKEND` to choose behavior:
|
||||||
|
|
||||||
|
- `auto` (default): use spaCy when available, else fallback
|
||||||
|
- `spacy`: require spaCy backend and fail clearly if unavailable
|
||||||
|
- `fallback`: always use fallback heuristics
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JOURNAL_NLP_BACKEND=fallback
|
||||||
|
python ./journal/run_desktop.py
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
|
$env:JOURNAL_NLP_BACKEND = "spacy"
|
||||||
|
python .\journal\run_desktop.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `backend\Journal.Sidecar\bin\Release\net10.0\win-x64\publish\Journal.Sidecar.exe` (~70MB, everything bundled)
|
## Installer Script
|
||||||
|
|
||||||
To exclude debug symbols: add `-p:DebugType=none`
|
Use the Linux helper script:
|
||||||
|
|
||||||
For a smaller build that requires .NET 10 on the target machine:
|
```bash
|
||||||
|
./installreqs.sh
|
||||||
```powershell
|
./installreqs.sh --gpu
|
||||||
dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 -p:PublishSingleFile=true
|
./installreqs.sh --with-nlp
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sidecar Protocol
|
## C# Backend
|
||||||
|
|
||||||
The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, one JSON line out.
|
The `backend/` directory contains a .NET 10 implementation that provides the same journal functionality as the Python layer, with encrypted vault support and an identical JSON command protocol.
|
||||||
When run with no command-line args, this protocol mode is used by default.
|
|
||||||
|
|
||||||
## Sidecar CLI
|
### Projects
|
||||||
|
|
||||||
`Journal.Sidecar` also supports direct vault and search CLI commands:
|
- **Journal.Core** — shared library: domain models, services, repositories, DTOs
|
||||||
|
- **Journal.Api** — minimal ASP.NET Core web API (`/api/command` POST endpoint)
|
||||||
|
- **Journal.Sidecar** — console app (stdin/stdout JSON protocol or CLI with `vault` and `search` subcommands)
|
||||||
|
- **Journal.SmokeTests** — 70+ integration tests (no test framework dependency)
|
||||||
|
|
||||||
```powershell
|
### Architecture
|
||||||
# Load vaults into decrypted data workspace
|
|
||||||
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault load
|
|
||||||
|
|
||||||
# Save (rebuild) monthly vaults from decrypted markdown files
|
|
||||||
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault save
|
|
||||||
|
|
||||||
# Search entries (query + filters)
|
|
||||||
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- search "common text" --tag stress --type !TRIGGER --start-date 2026-02-01 --end-date 2026-02-28 --section Summary --checked "med taken"
|
|
||||||
```
|
|
||||||
|
|
||||||
Password prompt behavior:
|
|
||||||
- If `--password` is omitted, CLI prompts with `Vault password:` (hidden input in terminal mode).
|
|
||||||
- For automation/non-interactive use, pass `--password <value>`.
|
|
||||||
|
|
||||||
Optional path overrides:
|
|
||||||
- `--vault-dir <path>`
|
|
||||||
- `--data-dir <path>`
|
|
||||||
- Env fallback: `JOURNAL_VAULT_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_APP_DIR`
|
|
||||||
|
|
||||||
Search CLI flags:
|
|
||||||
- positional `query` (optional)
|
|
||||||
- `--tag` / `-t` (repeatable)
|
|
||||||
- `--type` / `-y` (repeatable)
|
|
||||||
- `--start-date` / `-s` (`yyyy-MM-dd`)
|
|
||||||
- `--end-date` / `-e` (`yyyy-MM-dd`)
|
|
||||||
- `--section` / `-sec`
|
|
||||||
- `--checked` / `-chk` (repeatable)
|
|
||||||
- `--unchecked` / `-uchk` (repeatable)
|
|
||||||
- `--data-dir <path>` (optional override)
|
|
||||||
|
|
||||||
## Config Keys (Parity Surface)
|
|
||||||
|
|
||||||
`JournalConfigService` exposes and normalizes key settings expected from Python config:
|
|
||||||
|
|
||||||
- Paths: `JOURNAL_PROJECT_ROOT`, `JOURNAL_APP_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_VAULT_DIR`, `JOURNAL_LOG_DIR`, `JOURNAL_PID_FILE`, `JOURNAL_SERVER_CONTROL_FILE`
|
|
||||||
- Vault format: `JOURNAL_MONTHLY_VAULT_FORMAT` (default `%Y-%m.vault`)
|
|
||||||
- AI endpoints/models: `CLOUDAI_API_KEY`, `CLOUDAI_API_URL`, `LLAMA_CPP_URL`, `LLAMA_CPP_MODEL`, `LLAMA_CPP_TIMEOUT`, `EMBEDDING_API_URL`, `EMBEDDING_MODEL_NAME`, `MODEL_CONTEXT_TOKENS`, `CHUNK_TOKEN_BUDGET`
|
|
||||||
- AI bridge mode: `JOURNAL_AI_PROVIDER` (`none` or `python-sidecar`), `JOURNAL_PYTHON_EXE`, `JOURNAL_AI_SIDECAR_PATH`, `JOURNAL_AI_TIMEOUT_MS`
|
|
||||||
- Speech/NLP: `MICROPHONE_DEVICE_INDEX`, `SPEECH_RECOGNITION_ENGINE`, `WHISPER_MODEL_SIZE`, `JOURNAL_NLP_BACKEND`
|
|
||||||
|
|
||||||
### Command Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "fragments.create",
|
|
||||||
"id": null,
|
|
||||||
"type": null,
|
|
||||||
"tag": null,
|
|
||||||
"payload": { "type": "!TRIGGER", "description": "stomach drop" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fields:**
|
|
||||||
- `action` — The operation to perform (e.g. `fragments.list`, `fragments.create`)
|
|
||||||
- `id` — Target entity ID (for get/update/delete)
|
|
||||||
- `type` / `tag` — Filter parameters (for search)
|
|
||||||
- `payload` — Request body, deserialized into the appropriate DTO per action
|
|
||||||
|
|
||||||
### Available Actions
|
|
||||||
|
|
||||||
| Action | Description | Requires |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| `fragments.list` | List all fragments | — |
|
|
||||||
| `fragments.get` | Get fragment by ID | `id` |
|
|
||||||
| `fragments.create` | Create a new fragment | `payload` (CreateFragmentDto) |
|
|
||||||
| `fragments.update` | Update a fragment | `id`, `payload` (UpdateFragmentDto) |
|
|
||||||
| `fragments.delete` | Delete a fragment | `id` |
|
|
||||||
| `fragments.search` | Search by type/tag | `type` and/or `tag` |
|
|
||||||
| `entries.list` | List decrypted markdown entries in a data directory | optional `payload.dataDirectory` |
|
|
||||||
| `entries.load` | Load one entry file and return parsed metadata + raw content | `payload.filePath` |
|
|
||||||
| `entries.save` | Save/merge entry content to file (fragment append or full merge path) | `payload.content`, optional `payload.filePath`, `payload.mode` |
|
|
||||||
| `db.status` | Return DB key/schema compatibility status snapshot | `payload.password`, optional `payload.dataDirectory` |
|
|
||||||
| `db.initialize_schema` | Write SQL schema bootstrap (`journal_schema.sql`) for parity tables | optional `payload.dataDirectory` |
|
|
||||||
| `db.hydrate_workspace` | Perform C# DB hydration step for decrypted workspace (schema bootstrap + metadata) | `payload.password`, optional `payload.dataDirectory` |
|
|
||||||
| `config.get` | Return current backend config snapshot | — |
|
|
||||||
| `ai.health` | Return AI bridge health/provider status | — |
|
|
||||||
| `ai.summarize_entry` | Summarize one entry through AI provider | `payload.content`, optional `payload.fileStem` |
|
|
||||||
| `ai.summarize_all` | Summarize a set of entries through AI provider | `payload.entries[]` |
|
|
||||||
| `ai.chat` | Send chat prompt through AI provider bridge | `payload.prompt` |
|
|
||||||
| `ai.embed` | Generate embedding vector through AI provider bridge | `payload.content` |
|
|
||||||
| `search.entries` | Search decrypted entry content with optional parity filters | `payload.dataDirectory`, optional `payload.query`, `payload.section`, `payload.startDate`, `payload.endDate`, `payload.tags[]`, `payload.types[]`, `payload.checked[]`, `payload.unchecked[]` |
|
|
||||||
| `vault.initialize` | Ensure vault directory exists | `payload.password`, `payload.vaultDirectory` |
|
|
||||||
| `vault.load_all` | Load/decrypt all monthly vaults into data directory | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` |
|
|
||||||
| `vault.save_current_month` | Save only current month vault (optimized path) | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory`, optional `payload.nowUtc` |
|
|
||||||
| `vault.rebuild_all` | Rebuild all monthly vaults from decrypted `.md` data | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` |
|
|
||||||
| `vault.clear_data_directory` | Clear decrypted data directory and recreate it | `payload.dataDirectory` |
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
|
|
||||||
Success:
|
|
||||||
```json
|
|
||||||
{ "ok": true, "data": { "id": "abc-123", "type": "!TRIGGER", "description": "...", "time": "...", "tags": [] } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Error:
|
|
||||||
```json
|
|
||||||
{ "ok": false, "error": "Description is required" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extending with New Modules
|
|
||||||
|
|
||||||
The `Command` class is generic — new modules use the same dot-notation pattern:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
vault.unlock → IVaultService (future)
|
Entry (thin command dispatcher)
|
||||||
vault.lock
|
├── Fragments/ IFragmentService → FragmentService → IFragmentRepository (SQLCipher)
|
||||||
entries.list → IEntryService (future)
|
├── Entries/ IEntryFileService, IEntrySearchService, JournalParser, HtmlSanitizer
|
||||||
entries.create
|
├── Vault/ IVaultStorageService → VaultStorageService → IVaultCryptoService
|
||||||
ai.health → IAiService (implemented bridge)
|
├── Database/ IJournalDatabaseService (SQLCipher schema/key derivation)
|
||||||
ai.summarize_* → IAiService (implemented bridge)
|
│ IDatabaseSessionService (encrypted connection lifecycle after auth)
|
||||||
ai.chat → IAiService (implemented bridge)
|
├── Ai/ IAiService → PythonSidecarAiService | DisabledAiService
|
||||||
ai.embed → IAiService (implemented bridge)
|
├── Speech/ ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService
|
||||||
db.status → IJournalDatabaseService (in-progress DB parity)
|
├── Sidecar/ PythonSidecarClient (shared Python process I/O), SidecarCli
|
||||||
search.query → ISearchService (future)
|
├── Logging/ CommandLogger, LogRedactor
|
||||||
|
└── Config/ IJournalConfigService → JournalConfigService
|
||||||
```
|
```
|
||||||
|
|
||||||
To add a module:
|
Services are organized under `Journal.Core/Services/` in domain-specific subdirectories, each with its own namespace (e.g. `Journal.Core.Services.Ai`).
|
||||||
1. Create model, DTO, repository, and service in `Journal.Core/`
|
|
||||||
2. Register the new service in `ServiceCollectionExtensions.cs`
|
|
||||||
3. Inject the service into `Entry.cs` and add cases to the action switch
|
|
||||||
4. No changes needed to `Command.cs` or `App.cs`
|
|
||||||
|
|
||||||
## Dependency Injection
|
### Build & Run
|
||||||
|
|
||||||
`ServiceCollectionExtensions.cs` wires everything up. Any host (sidecar, API, tests) calls:
|
```bash
|
||||||
|
cd backend
|
||||||
```csharp
|
dotnet build
|
||||||
services.AddFragmentServices();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This registers:
|
Run the API server:
|
||||||
- `IFragmentRepository` → `FileFragmentRepository` (singleton — persisted fragment store)
|
|
||||||
- `IFragmentService` → `FragmentService` (transient — fresh instance per request)
|
|
||||||
|
|
||||||
## Fragment Store Location
|
```bash
|
||||||
|
dotnet run --project Journal.Api
|
||||||
|
```
|
||||||
|
|
||||||
`FileFragmentRepository` persists data to:
|
Run the sidecar (stdin/stdout mode):
|
||||||
|
|
||||||
- default: `.journal-sidecar/fragments.json` under current working directory
|
```bash
|
||||||
- override: `JOURNAL_FRAGMENT_STORE_PATH` environment variable
|
dotnet run --project Journal.Sidecar
|
||||||
|
```
|
||||||
|
|
||||||
## Legacy Vault Compatibility Note
|
Sidecar CLI commands:
|
||||||
|
|
||||||
The legacy Python placeholder file `_init_vault.vault` is treated as obsolete.
|
```bash
|
||||||
During vault load, the C# backend ignores this file for decryption and removes it.
|
dotnet run --project Journal.Sidecar -- vault load --password <value>
|
||||||
This preserves compatibility while migrating older vault directories forward.
|
dotnet run --project Journal.Sidecar -- vault save --password <value>
|
||||||
|
dotnet run --project Journal.Sidecar -- search "your query" --tag stress --start-date 2026-02-01
|
||||||
|
```
|
||||||
|
|
||||||
|
Run smoke tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project Journal.SmokeTests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `JOURNAL_PROJECT_ROOT` — override project root detection
|
||||||
|
- `JOURNAL_DATA_DIR` / `JOURNAL_VAULT_DIR` — override data/vault paths
|
||||||
|
- `JOURNAL_AI_PROVIDER` — `none` (default) or `python-sidecar`
|
||||||
|
- `JOURNAL_PYTHON_EXE` — Python executable path (default: `python`)
|
||||||
|
- `JOURNAL_LOG_LEVEL` — `trace`, `debug`, `information`, `warning` (default), `error`, `critical`
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
- Vault: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iterations)
|
||||||
|
- Database: SQLCipher with PBKDF2-derived key
|
||||||
|
- Standalone fragments are stored in the encrypted SQLCipher database (requires auth via `vault.load_all` or `db.hydrate_workspace`)
|
||||||
|
- `DatabaseSessionService` holds the encryption password in memory after first authentication
|
||||||
|
- Wire format matches the Python implementation for cross-language parity
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Decrypted journal data in `journal/data` is cleared on graceful shutdown.
|
||||||
|
- Vault save/load commands remain unchanged.
|
||||||
|
|||||||
84
REFACTORING_SUMMARY.md
Normal file
84
REFACTORING_SUMMARY.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Backend Refactoring Summary
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
`Entry.cs` was a 550-line god class that contained command dispatching, business logic, HTML sanitization, logging infrastructure, and 13 private payload record types. The two Python sidecar services (`PythonSidecarAiService` and `PythonSidecarSpeechService`) duplicated ~80 lines of identical process/JSON plumbing. Payload DTOs were hidden as private records inside `Entry.cs` instead of being in the `Dtos/` folder.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
### 1. Slimmed `Entry.cs` to a thin dispatcher (~330 lines)
|
||||||
|
Removed all business logic, HTML processing, logging implementation, and private record types. `Entry` now only parses the incoming JSON command, routes to the correct service, and returns the `{ok, data}` / `{ok: false, error}` envelope.
|
||||||
|
|
||||||
|
### 2. Extracted `HtmlSanitizer` (new file)
|
||||||
|
`StripRichHtml` and `LooksLikeRichHtml` moved from `Entry.cs` to `Services/HtmlSanitizer.cs` as a static utility class.
|
||||||
|
|
||||||
|
### 3. Extracted `CommandLogger` (new file)
|
||||||
|
`LogStart`, `LogSuccess`, `LogFailure`, `EmitLog`, `ShouldLog`, and `LogLevelRank` moved from `Entry.cs` to `Services/CommandLogger.cs`. Entry now receives this as a dependency.
|
||||||
|
|
||||||
|
### 4. Extracted `IEntryFileService` + `EntryFileService` (new files)
|
||||||
|
`SaveEntry`, `LoadEntry`, `ListEntries`, and `ResolveTargetPath` moved out of `Entry.cs` into a proper service with an interface. This follows the same pattern as `IFragmentService` / `FragmentService`.
|
||||||
|
|
||||||
|
### 5. Added `IEntryFileRepository` + `DiskEntryFileRepository` (new files)
|
||||||
|
`EntryFileService` now delegates all filesystem I/O (read, write, append, list, exists) to an `IEntryFileRepository`, keeping only business logic (HTML sanitization, parsing, merging) in the service. This mirrors the Fragment module's repository pattern (`IFragmentRepository` → `FragmentService`). An in-memory implementation can be swapped in for testing.
|
||||||
|
|
||||||
|
### 6. Extracted `PythonSidecarClient` (new file)
|
||||||
|
The duplicated `SendAsync`, `LastJsonLine`, and `TryKill` methods were extracted from both `PythonSidecarAiService` and `PythonSidecarSpeechService` into a shared `Services/PythonSidecarClient.cs`. Both services now delegate to it.
|
||||||
|
|
||||||
|
### 7. Moved payload records to `Dtos/CommandDtos.cs` (new file)
|
||||||
|
The 16 private payload/result records that were inside `Entry.cs` are now in `Dtos/CommandDtos.cs`. Records used through public interfaces (`EntrySavePayload`, `EntryListItem`, `EntryLoadResult`, `EntrySaveResult`) are public; the rest remain internal.
|
||||||
|
|
||||||
|
### 8. Moved database result records to `Dtos/DatabaseDtos.cs` (new file)
|
||||||
|
`JournalDatabaseStatus` and `JournalDatabaseHydrationResult` moved from `IJournalDatabaseService.cs` to `Dtos/DatabaseDtos.cs` for consistency with the other DTO files.
|
||||||
|
|
||||||
|
### 9. Moved fragment storage to encrypted SQLCipher database
|
||||||
|
Standalone fragments were previously stored in an unencrypted JSON file (`fragments.json`), then briefly in an unencrypted SQLite database. For medical/sensitive use cases, fragments are now stored in the **existing encrypted SQLCipher database** (`journal_cache.db`) alongside entries, sections, and tags.
|
||||||
|
|
||||||
|
- Updated `fragments` table schema: `entry_id` is now nullable, added `guid TEXT UNIQUE` column. Standalone fragments use `guid` + `entry_id IS NULL`; entry-linked fragments use `entry_id` + `guid NULL`.
|
||||||
|
- `SqliteFragmentRepository` now depends on `IDatabaseSessionService` instead of managing its own database connection.
|
||||||
|
- Tags use the shared normalized `tags` + `fragment_tags` tables (join via integer IDs).
|
||||||
|
- Fragment CRUD requires the database to be unlocked first (via `vault.load_all` or `db.hydrate_workspace`).
|
||||||
|
|
||||||
|
### 10. Added `IDatabaseSessionService` + `DatabaseSessionService` (new files)
|
||||||
|
A singleton that stores the encryption password in memory after authentication. When `vault.load_all` or `db.hydrate_workspace` is called, the session stores the password and lazily opens/caches an encrypted SQLCipher connection. All encrypted database consumers (currently `SqliteFragmentRepository`) use this shared session.
|
||||||
|
|
||||||
|
### 11. Organized Services directory into domain modules
|
||||||
|
The flat `Services/` directory (28 files) was reorganized into 9 subdirectories with dedicated namespaces:
|
||||||
|
|
||||||
|
- `Services/Ai/` — `IAiService`, `DisabledAiService`, `PythonSidecarAiService`
|
||||||
|
- `Services/Config/` — `IJournalConfigService`, `JournalConfigService`
|
||||||
|
- `Services/Database/` — `IJournalDatabaseService`, `JournalDatabaseService`, `IDatabaseSessionService`, `DatabaseSessionService`
|
||||||
|
- `Services/Entries/` — `IEntryFileService`, `EntryFileService`, `IEntrySearchService`, `EntrySearchService`, `JournalParser`, `HtmlSanitizer`
|
||||||
|
- `Services/Fragments/` — `IFragmentService`, `FragmentService`
|
||||||
|
- `Services/Logging/` — `CommandLogger`, `LogRedactor`
|
||||||
|
- `Services/Sidecar/` — `PythonSidecarClient`, `SidecarCli`
|
||||||
|
- `Services/Speech/` — `ISpeechBridgeService`, `DisabledSpeechBridgeService`, `PythonSidecarSpeechService`
|
||||||
|
- `Services/Vault/` — `IVaultCryptoService`, `VaultCryptoService`, `IVaultStorageService`, `VaultStorageService`
|
||||||
|
|
||||||
|
Each subdirectory has its own namespace (e.g. `Journal.Core.Services.Ai`). All consumer files updated with explicit using statements.
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
- `Journal.Core/Services/Entries/HtmlSanitizer.cs`
|
||||||
|
- `Journal.Core/Services/Logging/CommandLogger.cs`
|
||||||
|
- `Journal.Core/Services/Entries/IEntryFileService.cs`
|
||||||
|
- `Journal.Core/Services/Entries/EntryFileService.cs`
|
||||||
|
- `Journal.Core/Services/Sidecar/PythonSidecarClient.cs`
|
||||||
|
- `Journal.Core/Repositories/IEntryFileRepository.cs`
|
||||||
|
- `Journal.Core/Repositories/DiskEntryFileRepository.cs`
|
||||||
|
- `Journal.Core/Repositories/SqliteFragmentRepository.cs`
|
||||||
|
- `Journal.Core/Dtos/CommandDtos.cs`
|
||||||
|
- `Journal.Core/Dtos/DatabaseDtos.cs`
|
||||||
|
- `Journal.Core/Services/Database/IDatabaseSessionService.cs`
|
||||||
|
- `Journal.Core/Services/Database/DatabaseSessionService.cs`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher, wired `IDatabaseSessionService`
|
||||||
|
- `Journal.Core/Services/Ai/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
|
||||||
|
- `Journal.Core/Services/Speech/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
|
||||||
|
- `Journal.Core/Services/Database/IJournalDatabaseService.cs` — result records moved to Dtos
|
||||||
|
- `Journal.Core/Services/Database/JournalDatabaseService.cs` — schema updated (nullable entry_id, guid column)
|
||||||
|
- `Journal.Core/ServiceCollectionExtensions.cs` — registers all new services, updated namespaces
|
||||||
|
- `Journal.SmokeTests/Program.cs` — updated with new dependencies and encrypted DB persistence test
|
||||||
|
- `Journal.Sidecar/App.cs` — updated namespace imports
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- All 4 projects build successfully
|
||||||
|
- 65/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)
|
||||||
Loading…
x
Reference in New Issue
Block a user