J's Changes added.
This commit is contained in:
parent
e520133460
commit
8171744438
35
journal-master/journal/Journal.Core/Dtos/CommandDtos.cs
Normal file
35
journal-master/journal/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-master/journal/Journal.Core/Dtos/DatabaseDtos.cs
Normal file
18
journal-master/journal/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,7 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
@ -18,6 +16,8 @@ public class Entry
|
||||
private readonly IJournalConfigService _config;
|
||||
private readonly IAiService _ai;
|
||||
private readonly ISpeechBridgeService _speech;
|
||||
private readonly IEntryFileService _entryFiles;
|
||||
private readonly CommandLogger _logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
@ -30,7 +30,9 @@ public class Entry
|
||||
IJournalDatabaseService database,
|
||||
IJournalConfigService config,
|
||||
IAiService ai,
|
||||
ISpeechBridgeService speech)
|
||||
ISpeechBridgeService speech,
|
||||
IEntryFileService entryFiles,
|
||||
CommandLogger logger)
|
||||
{
|
||||
_fragments = fragments;
|
||||
_entrySearch = entrySearch;
|
||||
@ -39,6 +41,8 @@ public class Entry
|
||||
_config = config;
|
||||
_ai = ai;
|
||||
_speech = speech;
|
||||
_entryFiles = entryFiles;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunAsync()
|
||||
@ -73,7 +77,7 @@ public class Entry
|
||||
var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: cmd.CorrelationId.Trim();
|
||||
LogStart(action, correlationId, cmd.Payload);
|
||||
_logger.LogStart(action, correlationId, cmd.Payload);
|
||||
object? result;
|
||||
|
||||
try
|
||||
@ -131,19 +135,19 @@ public class Entry
|
||||
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
|
||||
? listPayload.DataDirectory
|
||||
: _config.Current.DataDirectory;
|
||||
result = ListEntries(listDataDirectory);
|
||||
result = _entryFiles.ListEntries(listDataDirectory);
|
||||
break;
|
||||
case "entries.load":
|
||||
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
|
||||
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
|
||||
return Error("Missing or invalid payload");
|
||||
result = LoadEntry(loadEntryPayload.FilePath);
|
||||
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
|
||||
break;
|
||||
case "entries.save":
|
||||
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
|
||||
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
|
||||
return Error("Missing or invalid payload");
|
||||
result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
||||
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
||||
break;
|
||||
case "config.get":
|
||||
result = _config.Current;
|
||||
@ -256,122 +260,53 @@ public class Entry
|
||||
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
|
||||
break;
|
||||
default:
|
||||
LogFailure(action, correlationId, "unknown_action");
|
||||
_logger.LogFailure(action, correlationId, "unknown_action");
|
||||
return Error($"Unknown action: {action}");
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
LogFailure(action, correlationId, "invalid_payload_json");
|
||||
_logger.LogFailure(action, correlationId, "invalid_payload_json");
|
||||
return Error("Missing or invalid payload");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "validation", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "validation", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "argument", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "argument", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "timeout", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "timeout", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "invalid_operation", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "invalid_operation", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "not_found", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "not_found", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
LogFailure(action, correlationId, "internal_error");
|
||||
_logger.LogFailure(action, correlationId, "internal_error");
|
||||
return Error("Internal error");
|
||||
}
|
||||
|
||||
LogSuccess(action, correlationId);
|
||||
_logger.LogSuccess(action, correlationId);
|
||||
return JsonSerializer.Serialize(new { ok = true, data = result });
|
||||
}
|
||||
|
||||
private static string Error(string 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)
|
||||
{
|
||||
if (payload is null)
|
||||
@ -395,153 +330,4 @@ public class Entry
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
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)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -47,6 +47,9 @@ public static class ServiceCollectionExtensions
|
||||
message: $"Python speech sidecar unavailable: {ex.Message}");
|
||||
}
|
||||
});
|
||||
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
|
||||
services.AddSingleton<IEntryFileService, EntryFileService>();
|
||||
services.AddSingleton<CommandLogger>();
|
||||
services.AddSingleton<SidecarCli>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class CommandLogger
|
||||
{
|
||||
public void LogStart(string action, string correlationId, JsonElement? payload)
|
||||
{
|
||||
var redactedPayload = LogRedactor.RedactPayload(payload);
|
||||
EmitLog("information", action, correlationId, "start", redactedPayload);
|
||||
}
|
||||
|
||||
public void LogSuccess(string action, string correlationId)
|
||||
{
|
||||
EmitLog("information", action, correlationId, "success");
|
||||
}
|
||||
|
||||
public 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
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Repositories;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class EntryFileService : IEntryFileService
|
||||
{
|
||||
private readonly IEntryFileRepository _repo;
|
||||
|
||||
public EntryFileService(IEntryFileRepository repo) =>
|
||||
_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)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IEntryFileService
|
||||
{
|
||||
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
|
||||
EntryLoadResult LoadEntry(string filePath);
|
||||
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IJournalDatabaseService
|
||||
@ -10,24 +12,3 @@ public interface IJournalDatabaseService
|
||||
JournalDatabaseStatus GetStatus(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,
|
||||
string CipherVersion,
|
||||
bool EncryptedAtRest);
|
||||
|
||||
public sealed record JournalDatabaseHydrationResult(
|
||||
string DatabasePath,
|
||||
string SchemaBootstrapPath,
|
||||
int EntryFilesProcessed,
|
||||
bool RuntimeReady,
|
||||
string Message,
|
||||
string CipherVersion,
|
||||
bool EncryptedAtRest);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Journal.Core.Dtos;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
@ -11,7 +12,6 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv");
|
||||
private static readonly object SqliteInitLock = new();
|
||||
private static bool _sqliteInitialized;
|
||||
private const string SqlitePlaintextHeader = "SQLite format 3";
|
||||
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
||||
["entries", "sections", "fragments", "tags", "fragment_tags"];
|
||||
|
||||
@ -127,9 +127,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
SchemaTables: tables,
|
||||
SchemaBootstrapPath: bootstrapPath,
|
||||
RuntimeReady: runtime.Ready,
|
||||
RuntimeMessage: runtime.Message,
|
||||
CipherVersion: runtime.CipherVersion,
|
||||
EncryptedAtRest: runtime.EncryptedAtRest);
|
||||
RuntimeMessage: runtime.Message);
|
||||
}
|
||||
|
||||
public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null)
|
||||
@ -139,15 +137,9 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
: dataDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
bool runtimeReady;
|
||||
string cipherVersion;
|
||||
using (var connection = OpenEncryptedConnection(password, directory))
|
||||
{
|
||||
using var connection = OpenEncryptedConnection(password, directory);
|
||||
CreateSchema(connection);
|
||||
runtimeReady = HasRequiredTables(connection);
|
||||
cipherVersion = QueryCipherVersion(connection);
|
||||
}
|
||||
var encryptedAtRest = IsDatabaseEncryptedAtRest(GetDatabasePath(directory));
|
||||
var runtimeReady = HasRequiredTables(connection);
|
||||
|
||||
var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length;
|
||||
var schemaPath = WriteSchemaBootstrap(directory);
|
||||
@ -159,9 +151,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
RuntimeReady: runtimeReady,
|
||||
Message: runtimeReady
|
||||
? "Workspace hydration completed with SQLCipher runtime schema validation."
|
||||
: "Workspace hydration completed, but required schema tables were not found.",
|
||||
CipherVersion: cipherVersion,
|
||||
EncryptedAtRest: encryptedAtRest);
|
||||
: "Workspace hydration completed, but required schema tables were not found.");
|
||||
}
|
||||
|
||||
private static void EnsureSqliteInitialized()
|
||||
@ -174,7 +164,6 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
if (_sqliteInitialized)
|
||||
return;
|
||||
|
||||
SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_e_sqlcipher());
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
_sqliteInitialized = true;
|
||||
}
|
||||
@ -188,8 +177,6 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
EnsureSqliteInitialized();
|
||||
|
||||
var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};Mode=ReadWriteCreate;Pooling=False");
|
||||
try
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using var keyCmd = connection.CreateCommand();
|
||||
@ -202,12 +189,6 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
connection.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateSchema(SqliteConnection connection)
|
||||
{
|
||||
@ -234,69 +215,20 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
|
||||
return RequiredSchemaTables.All(existing.Contains);
|
||||
}
|
||||
|
||||
private (bool Ready, string Message, string CipherVersion, bool EncryptedAtRest) ProbeRuntime(string password, string? dataDirectory)
|
||||
private (bool Ready, string Message) ProbeRuntime(string password, string? dataDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool hasRequiredTables;
|
||||
string cipherVersion;
|
||||
using (var connection = OpenEncryptedConnection(password, dataDirectory))
|
||||
{
|
||||
using var connection = OpenEncryptedConnection(password, dataDirectory);
|
||||
CreateSchema(connection);
|
||||
hasRequiredTables = HasRequiredTables(connection);
|
||||
cipherVersion = QueryCipherVersion(connection);
|
||||
}
|
||||
var encryptedAtRest = IsDatabaseEncryptedAtRest(GetDatabasePath(dataDirectory));
|
||||
var hasCipherVersion = !string.IsNullOrWhiteSpace(cipherVersion);
|
||||
var ready = hasRequiredTables && hasCipherVersion && encryptedAtRest;
|
||||
var ready = HasRequiredTables(connection);
|
||||
return ready
|
||||
? (true, "SQLCipher runtime is available, schema tables are present, and database file is encrypted at rest.", cipherVersion, encryptedAtRest)
|
||||
: (false, BuildRuntimeFailureMessage(hasRequiredTables, hasCipherVersion, encryptedAtRest), cipherVersion, encryptedAtRest);
|
||||
? (true, "SQLCipher runtime is available and schema tables are present.")
|
||||
: (false, "SQLCipher runtime opened, but required schema tables are missing.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"SQLCipher runtime check failed: {ex.Message}", "", false);
|
||||
return (false, $"SQLCipher runtime check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string QueryCipherVersion(SqliteConnection connection)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA cipher_version;";
|
||||
var value = cmd.ExecuteScalar();
|
||||
return value?.ToString()?.Trim() ?? "";
|
||||
}
|
||||
|
||||
private static bool IsDatabaseEncryptedAtRest(string databasePath)
|
||||
{
|
||||
if (!File.Exists(databasePath))
|
||||
return false;
|
||||
|
||||
var probe = new byte[16];
|
||||
using var stream = new FileStream(
|
||||
databasePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
var read = stream.Read(probe, 0, probe.Length);
|
||||
if (read <= 0)
|
||||
return false;
|
||||
|
||||
var header = Encoding.ASCII.GetString(probe, 0, read);
|
||||
return !header.StartsWith(SqlitePlaintextHeader, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string BuildRuntimeFailureMessage(bool hasRequiredTables, bool hasCipherVersion, bool encryptedAtRest)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
if (!hasRequiredTables)
|
||||
failures.Add("required schema tables are missing");
|
||||
if (!hasCipherVersion)
|
||||
failures.Add("PRAGMA cipher_version returned empty (SQLCipher runtime not confirmed)");
|
||||
if (!encryptedAtRest)
|
||||
failures.Add("database file appears plaintext at rest (SQLite header detected)");
|
||||
if (failures.Count == 0)
|
||||
failures.Add("unknown runtime validation failure");
|
||||
return "SQLCipher runtime opened, but validation failed: " + string.Join("; ", failures) + ".";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
@ -8,25 +6,20 @@ namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarAiService : IAiService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
private readonly PythonSidecarClient _client;
|
||||
|
||||
public PythonSidecarAiService(JournalConfig config)
|
||||
{
|
||||
_config = config;
|
||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
||||
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}");
|
||||
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 SendAsync("health", payload: new { }, cancellationToken);
|
||||
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");
|
||||
|
||||
@ -47,14 +40,14 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
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);
|
||||
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 SendAsync("summarize_all", new { entries }, cancellationToken);
|
||||
var data = await _client.SendAsync("summarize_all", new { entries }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
@ -63,7 +56,7 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
throw new ArgumentException("Prompt is required.", nameof(prompt));
|
||||
|
||||
var data = await SendAsync("chat", new { prompt }, cancellationToken);
|
||||
var data = await _client.SendAsync("chat", new { prompt }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
@ -72,7 +65,7 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
throw new ArgumentException("Content is required.", nameof(content));
|
||||
|
||||
var data = await SendAsync("embed", new { content }, cancellationToken);
|
||||
var data = await _client.SendAsync("embed", new { content }, cancellationToken);
|
||||
if (data is null || data.Value.ValueKind == JsonValueKind.Null)
|
||||
return [];
|
||||
|
||||
@ -89,102 +82,4 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
|
||||
public PythonSidecarClient(JournalConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public 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 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 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 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 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 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,4 +1,3 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
@ -7,25 +6,20 @@ namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
private readonly PythonSidecarClient _client;
|
||||
|
||||
public PythonSidecarSpeechService(JournalConfig config)
|
||||
{
|
||||
_config = config;
|
||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
||||
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}");
|
||||
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 SendAsync("speech.devices.list", new { }, cancellationToken);
|
||||
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.");
|
||||
|
||||
@ -60,7 +54,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var data = await SendAsync("speech.transcribe", new
|
||||
var data = await _client.SendAsync("speech.transcribe", new
|
||||
{
|
||||
audio_base64 = request.AudioBase64,
|
||||
engine = request.Engine,
|
||||
@ -83,102 +77,4 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
: 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);
|
||||
|
||||
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 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 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 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 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 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 timeout cleanup failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,6 @@ var tests = new List<(string Name, Func<Task> Run)>
|
||||
("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync),
|
||||
("Database schema parity tables are created", TestDatabaseSchemaParityAsync),
|
||||
("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync),
|
||||
("Entry db.status rejects wrong key for existing encrypted database", TestEntryDatabaseStatusWrongKeyFailsAsync),
|
||||
("Entry db.initialize_schema creates schema in data directory", TestEntryDatabaseInitializeSchemaAsync),
|
||||
("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync),
|
||||
("Config service exposes parity path, vault, AI, and speech settings", TestConfigServiceParityKeysAsync),
|
||||
@ -114,7 +113,9 @@ static Entry NewEntry() => new(
|
||||
new JournalDatabaseService(new JournalConfigService()),
|
||||
new JournalConfigService(),
|
||||
new DisabledAiService("none"),
|
||||
new DisabledSpeechBridgeService("none"));
|
||||
new DisabledSpeechBridgeService("none"),
|
||||
new EntryFileService(new DiskEntryFileRepository()),
|
||||
new CommandLogger());
|
||||
|
||||
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
|
||||
|
||||
@ -1325,59 +1326,6 @@ static async Task TestEntryDatabaseStatusAsync()
|
||||
Assert(schemaBootstrapPath.ValueKind == JsonValueKind.String && File.Exists(schemaBootstrapPath.GetString()), "Expected db.status to emit existing schema bootstrap file path.");
|
||||
Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in db.status payload.");
|
||||
Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected SQLCipher runtime-ready status in db.status payload.");
|
||||
Assert(data.TryGetProperty("CipherVersion", out var cipherVersion), "Expected CipherVersion in db.status payload.");
|
||||
Assert(cipherVersion.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(cipherVersion.GetString()), "Expected non-empty SQLCipher cipher version.");
|
||||
Assert(data.TryGetProperty("EncryptedAtRest", out var encryptedAtRest), "Expected EncryptedAtRest in db.status payload.");
|
||||
Assert(encryptedAtRest.ValueKind == JsonValueKind.True, "Expected EncryptedAtRest=true for SQLCipher-backed database.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task TestEntryDatabaseStatusWrongKeyFailsAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(root, "2026-02-20.md"), "one");
|
||||
var entry = NewEntry();
|
||||
|
||||
var hydrateRequest = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "db.hydrate_workspace",
|
||||
payload = new
|
||||
{
|
||||
password = "vault-pass-123",
|
||||
dataDirectory = root
|
||||
}
|
||||
});
|
||||
var hydrateResponse = await entry.HandleCommandAsync(hydrateRequest);
|
||||
using (var hydrateDoc = JsonDocument.Parse(hydrateResponse))
|
||||
{
|
||||
Assert(hydrateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected initial hydrate to succeed.");
|
||||
}
|
||||
|
||||
var wrongStatusRequest = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "db.status",
|
||||
payload = new
|
||||
{
|
||||
password = "wrong-password",
|
||||
dataDirectory = root
|
||||
}
|
||||
});
|
||||
|
||||
var wrongStatusResponse = await entry.HandleCommandAsync(wrongStatusRequest);
|
||||
using var wrongDoc = JsonDocument.Parse(wrongStatusResponse);
|
||||
Assert(wrongDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected db.status envelope to remain ok=true.");
|
||||
var data = wrongDoc.RootElement.GetProperty("data");
|
||||
Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in wrong-key db.status payload.");
|
||||
Assert(runtimeReady.ValueKind == JsonValueKind.False, "Expected RuntimeReady=false when using wrong key on existing encrypted database.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -1456,18 +1404,6 @@ static async Task TestEntryDatabaseHydrateWorkspaceAsync()
|
||||
Assert(filesProcessed.ValueKind == JsonValueKind.Number && filesProcessed.GetInt32() == 2, "Expected hydrate to count markdown files in workspace.");
|
||||
Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in hydrate payload.");
|
||||
Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected RuntimeReady=true when SQLCipher runtime hydration succeeds.");
|
||||
Assert(data.TryGetProperty("CipherVersion", out var cipherVersion), "Expected CipherVersion in hydrate payload.");
|
||||
Assert(cipherVersion.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(cipherVersion.GetString()), "Expected non-empty SQLCipher cipher version in hydrate payload.");
|
||||
Assert(data.TryGetProperty("EncryptedAtRest", out var encryptedAtRest), "Expected EncryptedAtRest in hydrate payload.");
|
||||
Assert(encryptedAtRest.ValueKind == JsonValueKind.True, "Expected EncryptedAtRest=true when SQLCipher runtime hydration succeeds.");
|
||||
|
||||
var dbPath = data.GetProperty("DatabasePath").GetString() ?? "";
|
||||
Assert(File.Exists(dbPath), "Expected hydrated database file to exist.");
|
||||
using var stream = File.OpenRead(dbPath);
|
||||
var headerBytes = new byte[16];
|
||||
var read = stream.Read(headerBytes, 0, headerBytes.Length);
|
||||
var header = read > 0 ? System.Text.Encoding.ASCII.GetString(headerBytes, 0, read) : "";
|
||||
Assert(!header.StartsWith("SQLite format 3", StringComparison.Ordinal), "Expected SQLCipher database header to be non-plaintext.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@ -1,268 +1,187 @@
|
||||
# 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
|
||||
|
||||
```
|
||||
backend/
|
||||
├── Journal.Core/ Class library — all business logic
|
||||
│ ├── Models/
|
||||
│ │ ├── Fragment.cs Domain model (validated, owns Guid ID)
|
||||
│ │ ├── Command.cs Stdin command shape for sidecar protocol
|
||||
│ │ ├── ParsedSection.cs Parsed section model for entry parity work
|
||||
│ │ ├── SectionTitles.cs Canonical section title list (Python parity)
|
||||
│ │ └── JournalEntry.cs Entry domain (`date/raw_content/sections/fragments` + merge + markdown reconstruction)
|
||||
│ ├── Dtos/
|
||||
│ │ └── FragmentDtos.cs Immutable records for API boundary
|
||||
│ │ ├── FragmentDto Read (what goes out)
|
||||
│ │ ├── CreateFragmentDto Create (what comes in)
|
||||
│ │ └── UpdateFragmentDto Update (partial, all fields optional)
|
||||
│ ├── Repositories/
|
||||
│ │ ├── IFragmentRepository.cs Interface (data access contract)
|
||||
│ │ ├── InMemoryFragmentRepository.cs In-memory implementation (tests/dev)
|
||||
│ │ └── FileFragmentRepository.cs File-backed implementation (default)
|
||||
│ ├── Services/
|
||||
│ │ ├── IFragmentService.cs Interface (business logic contract)
|
||||
│ │ ├── FragmentService.cs Validates, calls repo, maps to DTOs
|
||||
│ │ ├── IEntrySearchService.cs Entry search contract (content parity)
|
||||
│ │ ├── EntrySearchService.cs Searches decrypted `.md` entries by raw content query
|
||||
│ │ ├── IJournalConfigService.cs Config contract for path/vault/AI/speech settings parity
|
||||
│ │ ├── JournalConfigService.cs Env/default-backed config surface aligned with Python keys
|
||||
│ │ ├── IAiService.cs AI bridge contract (optional provider)
|
||||
│ │ ├── DisabledAiService.cs No-op AI provider for deterministic disabled mode
|
||||
│ │ ├── PythonSidecarAiService.cs Local Python sidecar adapter (stdin/stdout JSON)
|
||||
│ │ ├── SidecarCli.cs CLI runner (`vault` + `search`) used by Sidecar host
|
||||
│ │ ├── JournalParser.cs Date + section + checkbox + fragment parser slices (Phase 2)
|
||||
│ │ ├── IVaultCryptoService.cs Vault crypto contract
|
||||
│ │ ├── VaultCryptoService.cs AES-256-GCM + PBKDF2 compatibility layer
|
||||
│ │ ├── IVaultStorageService.cs Vault load/workflow contract
|
||||
│ │ └── VaultStorageService.cs Monthly naming + load/decrypt/extract workflow
|
||||
│ ├── Entry.cs Command dispatcher (stdin/stdout)
|
||||
│ ├── ServiceCollectionExtensions.cs DI registration helper
|
||||
│ └── Journal.Core.csproj
|
||||
│
|
||||
├── Journal.Sidecar/ Console app — Tauri sidecar bridge
|
||||
│ ├── App.cs Boots DI container, runs Entry.RunAsync()
|
||||
│ └── Journal.Sidecar.csproj References Journal.Core
|
||||
│
|
||||
├── Journal.Api/ Web API — HTTP endpoint wrapper (optional)
|
||||
│ ├── Program.cs
|
||||
│ └── Journal.Api.csproj
|
||||
│
|
||||
└── README.md
|
||||
- Python: `3.14`
|
||||
- Platforms: Windows and Linux (first-class), macOS (best effort)
|
||||
- Default profile: CPU
|
||||
- Optional profiles: GPU, optional NLP backend
|
||||
|
||||
## Dependency Profiles
|
||||
|
||||
- `requirements_base.txt`: shared Journal runtime dependencies
|
||||
- `requirements_cpu_only.txt`: base + CPU AI stack
|
||||
- `requirements_gpu.txt`: base + GPU AI stack
|
||||
- `requirements_nlp_optional.txt`: optional spaCy backend (auto-fallback if unavailable)
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Linux (CPU default)
|
||||
|
||||
```bash
|
||||
cd Project_Journal
|
||||
python3.14 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt
|
||||
```
|
||||
|
||||
## Architecture
|
||||
### Linux (GPU optional)
|
||||
|
||||
Each layer only knows about the one below it:
|
||||
|
||||
```
|
||||
Sidecar (stdin/stdout) ──┐
|
||||
├──► Services (business logic) ──► Repositories (data access)
|
||||
API (HTTP/JSON) ─────────┘
|
||||
```bash
|
||||
cd Project_Journal
|
||||
python3.14 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements_gpu.txt
|
||||
```
|
||||
|
||||
- **Models** — Domain objects with validation. The source of truth.
|
||||
- **DTOs** — Immutable records that cross the API boundary. Internal logic never leaks out.
|
||||
- **Repositories** — Where data lives. Current default is file-backed; can evolve to SQLite/EF Core without touching anything above.
|
||||
- **Services** — Business rules, validation, orchestration. Doesn't know about HTTP or stdin.
|
||||
- **Entry** — Transport adapter. Translates stdin/stdout JSON into service calls.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Journal.Core** — `Microsoft.Extensions.DependencyInjection.Abstractions` (interface-only, lightweight)
|
||||
- **Journal.Sidecar** — `Microsoft.Extensions.DependencyInjection` (full container implementation) + references `Journal.Core`
|
||||
- **Journal.Api** — `Microsoft.AspNetCore.OpenApi` + ASP.NET shared framework
|
||||
|
||||
## Building
|
||||
### Windows PowerShell (CPU default)
|
||||
|
||||
```powershell
|
||||
# Build everything (building Sidecar also rebuilds Core if changed)
|
||||
dotnet build backend\Journal.Sidecar\Journal.Sidecar.csproj
|
||||
|
||||
# Build just the library
|
||||
dotnet build backend\Journal.Core\Journal.Core.csproj
|
||||
|
||||
# Format code
|
||||
dotnet format backend\Journal.Core\Journal.Core.csproj
|
||||
cd Project_Journal
|
||||
py -3.14 -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements_cpu_only.txt
|
||||
```
|
||||
|
||||
## 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
|
||||
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:
|
||||
|
||||
```powershell
|
||||
dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 -p:PublishSingleFile=true
|
||||
```bash
|
||||
./installreqs.sh
|
||||
./installreqs.sh --gpu
|
||||
./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.
|
||||
When run with no command-line args, this protocol mode is used by default.
|
||||
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.
|
||||
|
||||
## 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
|
||||
# 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 + SQLCipher runtime 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:
|
||||
### Architecture
|
||||
|
||||
```
|
||||
vault.unlock → IVaultService (future)
|
||||
vault.lock
|
||||
entries.list → IEntryService (future)
|
||||
entries.create
|
||||
ai.health → IAiService (implemented bridge)
|
||||
ai.summarize_* → IAiService (implemented bridge)
|
||||
ai.chat → IAiService (implemented bridge)
|
||||
ai.embed → IAiService (implemented bridge)
|
||||
db.status → IJournalDatabaseService (implemented SQLCipher parity/runtime checks)
|
||||
search.query → ISearchService (future)
|
||||
Entry (thin command dispatcher)
|
||||
├── IFragmentService → FragmentService → IFragmentRepository
|
||||
├── IEntryFileService → EntryFileService → IEntryFileRepository
|
||||
├── IEntrySearchService → EntrySearchService
|
||||
├── IVaultStorageService → VaultStorageService → IVaultCryptoService
|
||||
├── IJournalDatabaseService → JournalDatabaseService (SQLCipher)
|
||||
├── IAiService → PythonSidecarAiService | DisabledAiService
|
||||
├── ISpeechBridgeService → PythonSidecarSpeechService | DisabledSpeechBridgeService
|
||||
├── CommandLogger
|
||||
└── IJournalConfigService → JournalConfigService
|
||||
```
|
||||
|
||||
`db.status` and `db.hydrate_workspace` now include:
|
||||
- `CipherVersion` (from `PRAGMA cipher_version`)
|
||||
- `EncryptedAtRest` (true when DB header is not plaintext SQLite)
|
||||
### Build & Run
|
||||
|
||||
To add a module:
|
||||
1. Create model, DTO, repository, and service in `Journal.Core/`
|
||||
2. Register the new service in `ServiceCollectionExtensions.cs`
|
||||
3. Inject the service into `Entry.cs` and add cases to the action switch
|
||||
4. No changes needed to `Command.cs` or `App.cs`
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
`ServiceCollectionExtensions.cs` wires everything up. Any host (sidecar, API, tests) calls:
|
||||
|
||||
```csharp
|
||||
services.AddFragmentServices();
|
||||
```bash
|
||||
cd backend
|
||||
dotnet build
|
||||
```
|
||||
|
||||
This registers:
|
||||
- `IFragmentRepository` → `FileFragmentRepository` (singleton — persisted fragment store)
|
||||
- `IFragmentService` → `FragmentService` (transient — fresh instance per request)
|
||||
Run the API server:
|
||||
|
||||
## 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
|
||||
- override: `JOURNAL_FRAGMENT_STORE_PATH` environment variable
|
||||
```bash
|
||||
dotnet run --project Journal.Sidecar
|
||||
```
|
||||
|
||||
## Legacy Vault Compatibility Note
|
||||
Sidecar CLI commands:
|
||||
|
||||
The legacy Python placeholder file `_init_vault.vault` is treated as obsolete.
|
||||
During vault load, the C# backend ignores this file for decryption and removes it.
|
||||
This preserves compatibility while migrating older vault directories forward.
|
||||
```bash
|
||||
dotnet run --project Journal.Sidecar -- vault load --password <value>
|
||||
dotnet run --project Journal.Sidecar -- vault save --password <value>
|
||||
dotnet run --project Journal.Sidecar -- search "your query" --tag stress --start-date 2026-02-01
|
||||
```
|
||||
|
||||
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
|
||||
- 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.
|
||||
|
||||
62
journal-master/journal/REFACTORING_SUMMARY.md
Normal file
62
journal-master/journal/REFACTORING_SUMMARY.md
Normal file
@ -0,0 +1,62 @@
|
||||
# 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.
|
||||
|
||||
## Files Created
|
||||
- `Journal.Core/Services/HtmlSanitizer.cs`
|
||||
- `Journal.Core/Services/CommandLogger.cs`
|
||||
- `Journal.Core/Services/IEntryFileService.cs`
|
||||
- `Journal.Core/Services/EntryFileService.cs`
|
||||
- `Journal.Core/Services/PythonSidecarClient.cs`
|
||||
- `Journal.Core/Repositories/IEntryFileRepository.cs`
|
||||
- `Journal.Core/Repositories/DiskEntryFileRepository.cs`
|
||||
- `Journal.Core/Dtos/CommandDtos.cs`
|
||||
- `Journal.Core/Dtos/DatabaseDtos.cs`
|
||||
|
||||
## Files Modified
|
||||
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher
|
||||
- `Journal.Core/Services/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
|
||||
- `Journal.Core/Services/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
|
||||
- `Journal.Core/Services/IJournalDatabaseService.cs` — result records moved to Dtos
|
||||
- `Journal.Core/Services/JournalDatabaseService.cs` — added Dtos using
|
||||
- `Journal.Core/ServiceCollectionExtensions.cs` — registers new services and repository
|
||||
- `Journal.SmokeTests/Program.cs` — updated NewEntry() with new dependencies
|
||||
|
||||
## What Was NOT Changed
|
||||
- **Fragment module** — already clean, untouched
|
||||
- **Config module** — singleton reader, no changes needed
|
||||
- **Vault module** — already well-separated (crypto/storage), untouched
|
||||
- **AI/Speech interfaces and disabled variants** — untouched (only the sidecar implementations were refactored)
|
||||
- **Search module** — stateless query service, no repository needed
|
||||
- **All test logic** — no assertions or test behavior changed
|
||||
|
||||
## Verification
|
||||
- All 4 projects build successfully
|
||||
- 70/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)
|
||||
Loading…
x
Reference in New Issue
Block a user