From 14b8e7a33954ef32d77d398ef97493d25420a65e Mon Sep 17 00:00:00 2001 From: stan44 Date: Mon, 23 Feb 2026 19:57:18 -0600 Subject: [PATCH] massive backend migration to c# --- .gitignore | 3 + Journal.Api/Journal.Api.csproj | 4 + Journal.Api/Journal.Api.http | 20 +- Journal.Api/Program.cs | 46 +- Journal.Core/Dtos/AiDtos.cs | 7 + Journal.Core/Dtos/EntrySearchDtos.cs | 17 + Journal.Core/Dtos/SpeechDtos.cs | 21 + Journal.Core/Entry.cs | 539 ++++- Journal.Core/Journal.Core.csproj | 2 + Journal.Core/Models/Command.cs | 1 + Journal.Core/Models/Fragment.cs | 27 +- Journal.Core/Models/JournalConfig.cs | 29 + Journal.Core/Models/JournalEntry.cs | 98 + Journal.Core/Models/ParsedSection.cs | 21 + Journal.Core/Models/SectionTitles.cs | 20 + .../Repositories/FileFragmentRepository.cs | 228 ++ Journal.Core/ServiceCollectionExtensions.cs | 40 +- Journal.Core/Services/DisabledAiService.cs | 32 + .../Services/DisabledSpeechBridgeService.cs | 32 + Journal.Core/Services/EntrySearchService.cs | 108 + Journal.Core/Services/FragmentService.cs | 8 +- Journal.Core/Services/IAiService.cs | 12 + Journal.Core/Services/IEntrySearchService.cs | 8 + .../Services/IJournalConfigService.cs | 8 + .../Services/IJournalDatabaseService.cs | 29 + Journal.Core/Services/ISpeechBridgeService.cs | 9 + Journal.Core/Services/IVaultCryptoService.cs | 8 + Journal.Core/Services/IVaultStorageService.cs | 10 + Journal.Core/Services/JournalConfigService.cs | 107 + .../Services/JournalDatabaseService.cs | 233 ++ Journal.Core/Services/JournalParser.cs | 175 ++ Journal.Core/Services/LogRedactor.cs | 73 + .../Services/PythonSidecarAiService.cs | 190 ++ .../Services/PythonSidecarSpeechService.cs | 184 ++ Journal.Core/Services/SidecarCli.cs | 385 +++ Journal.Core/Services/VaultCryptoService.cs | 83 + Journal.Core/Services/VaultStorageService.cs | 276 +++ Journal.Sidecar/App.cs | 5 +- .../Fixtures/transport_cases.json | 50 + Journal.SmokeTests/Journal.SmokeTests.csproj | 20 + Journal.SmokeTests/Program.cs | 2129 +++++++++++++++++ README.md | 112 +- scripts/dotnet-min.ps1 | 62 + scripts/nuget-export-cache.ps1 | 57 + scripts/nuget-import-cache.ps1 | 25 + 45 files changed, 5483 insertions(+), 70 deletions(-) create mode 100644 Journal.Core/Dtos/AiDtos.cs create mode 100644 Journal.Core/Dtos/EntrySearchDtos.cs create mode 100644 Journal.Core/Dtos/SpeechDtos.cs create mode 100644 Journal.Core/Models/JournalConfig.cs create mode 100644 Journal.Core/Models/JournalEntry.cs create mode 100644 Journal.Core/Models/ParsedSection.cs create mode 100644 Journal.Core/Models/SectionTitles.cs create mode 100644 Journal.Core/Repositories/FileFragmentRepository.cs create mode 100644 Journal.Core/Services/DisabledAiService.cs create mode 100644 Journal.Core/Services/DisabledSpeechBridgeService.cs create mode 100644 Journal.Core/Services/EntrySearchService.cs create mode 100644 Journal.Core/Services/IAiService.cs create mode 100644 Journal.Core/Services/IEntrySearchService.cs create mode 100644 Journal.Core/Services/IJournalConfigService.cs create mode 100644 Journal.Core/Services/IJournalDatabaseService.cs create mode 100644 Journal.Core/Services/ISpeechBridgeService.cs create mode 100644 Journal.Core/Services/IVaultCryptoService.cs create mode 100644 Journal.Core/Services/IVaultStorageService.cs create mode 100644 Journal.Core/Services/JournalConfigService.cs create mode 100644 Journal.Core/Services/JournalDatabaseService.cs create mode 100644 Journal.Core/Services/JournalParser.cs create mode 100644 Journal.Core/Services/LogRedactor.cs create mode 100644 Journal.Core/Services/PythonSidecarAiService.cs create mode 100644 Journal.Core/Services/PythonSidecarSpeechService.cs create mode 100644 Journal.Core/Services/SidecarCli.cs create mode 100644 Journal.Core/Services/VaultCryptoService.cs create mode 100644 Journal.Core/Services/VaultStorageService.cs create mode 100644 Journal.SmokeTests/Fixtures/transport_cases.json create mode 100644 Journal.SmokeTests/Journal.SmokeTests.csproj create mode 100644 Journal.SmokeTests/Program.cs create mode 100644 scripts/dotnet-min.ps1 create mode 100644 scripts/nuget-export-cache.ps1 create mode 100644 scripts/nuget-import-cache.ps1 diff --git a/.gitignore b/.gitignore index 6f18507..990d0ac 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ obj/ **/packages/ project.lock.json project.fragment.lock.json +.nuget/ +.dotnet_home/ +.journal-sidecar/ # Publish output publish/ diff --git a/Journal.Api/Journal.Api.csproj b/Journal.Api/Journal.Api.csproj index d1cee09..17d3d94 100644 --- a/Journal.Api/Journal.Api.csproj +++ b/Journal.Api/Journal.Api.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/Journal.Api/Journal.Api.http b/Journal.Api/Journal.Api.http index c02bf9c..4ca4b1b 100644 --- a/Journal.Api/Journal.Api.http +++ b/Journal.Api/Journal.Api.http @@ -1,6 +1,24 @@ @Journal.Api_HostAddress = http://localhost:5014 -GET {{Journal.Api_HostAddress}}/weatherforecast/ +GET {{Journal.Api_HostAddress}}/health Accept: application/json ### + +POST {{Journal.Api_HostAddress}}/api/command +Content-Type: application/json + +{ + "action": "config.get", + "payload": {} +} + +### + +POST {{Journal.Api_HostAddress}}/api/command +Content-Type: application/json + +{ + "action": + +### diff --git a/Journal.Api/Program.cs b/Journal.Api/Program.cs index 8000192..d1f9cae 100644 --- a/Journal.Api/Program.cs +++ b/Journal.Api/Program.cs @@ -1,41 +1,29 @@ -var builder = WebApplication.CreateBuilder(args); +using System.Text.Json; +using Journal.Core; -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); +builder.Services.AddFragmentServices(); +builder.Services.AddSingleton(); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) -{ app.MapOpenApi(); -} -app.UseHttpsRedirection(); +app.MapGet("/health", () => Results.Json(new { ok = true, data = "healthy" })); +app.MapGet("/healthz", () => Results.Json(new { ok = true, data = "healthy" })); +app.MapGet("/api/health", () => Results.Json(new { ok = true, data = "healthy" })); -var summaries = new[] +// Mirrors sidecar transport semantics over HTTP. +// request body is passed directly to Entry.HandleCommandAsync so malformed JSON +// and payload errors return the same {ok:false,error} envelope as sidecar mode. +app.MapPost("/api/command", async (HttpRequest request, Entry entry) => { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); + using var reader = new StreamReader(request.Body); + var body = await reader.ReadToEndAsync(); + var response = await entry.HandleCommandAsync(body); + return Results.Content(response, "application/json"); +}); app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/Journal.Core/Dtos/AiDtos.cs b/Journal.Core/Dtos/AiDtos.cs new file mode 100644 index 0000000..964498e --- /dev/null +++ b/Journal.Core/Dtos/AiDtos.cs @@ -0,0 +1,7 @@ +namespace Journal.Core.Dtos; + +public sealed record AiHealthDto( + string Provider, + bool Enabled, + bool Healthy, + string Message); diff --git a/Journal.Core/Dtos/EntrySearchDtos.cs b/Journal.Core/Dtos/EntrySearchDtos.cs new file mode 100644 index 0000000..2ac363b --- /dev/null +++ b/Journal.Core/Dtos/EntrySearchDtos.cs @@ -0,0 +1,17 @@ +namespace Journal.Core.Dtos; + +public sealed record EntrySearchRequestDto( + string DataDirectory, + string? Query = null, + string? Section = null, + string? StartDate = null, + string? EndDate = null, + IReadOnlyList? Tags = null, + IReadOnlyList? Types = null, + IReadOnlyList? Checked = null, + IReadOnlyList? Unchecked = null); + +public sealed record EntrySearchResultDto( + string Date, + string FileName, + string RawContent); diff --git a/Journal.Core/Dtos/SpeechDtos.cs b/Journal.Core/Dtos/SpeechDtos.cs new file mode 100644 index 0000000..aa3d1c9 --- /dev/null +++ b/Journal.Core/Dtos/SpeechDtos.cs @@ -0,0 +1,21 @@ +namespace Journal.Core.Dtos; + +public sealed record SpeechDeviceDto( + int Index, + string Name); + +public sealed record SpeechDevicesResultDto( + IReadOnlyList Devices, + string? Warning = null); + +public sealed record SpeechTranscribeRequestDto( + string? AudioBase64 = null, + string? Engine = null, + string? WhisperModel = null, + string? Text = null, + int? SimulateDelayMs = null); + +public sealed record SpeechTranscribeResultDto( + string Text, + string Engine, + string? Warning = null); diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs index 0cdac0a..39627f5 100644 --- a/Journal.Core/Entry.cs +++ b/Journal.Core/Entry.cs @@ -1,3 +1,7 @@ +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; @@ -8,51 +12,536 @@ namespace Journal.Core; public class Entry { private readonly IFragmentService _fragments; + private readonly IEntrySearchService _entrySearch; + private readonly IVaultStorageService _vaultStorage; + private readonly IJournalDatabaseService _database; + private readonly IJournalConfigService _config; + private readonly IAiService _ai; + private readonly ISpeechBridgeService _speech; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; - public Entry(IFragmentService fragments) => _fragments = fragments; + 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() { string? line; while ((line = Console.ReadLine()) is not null) { - var response = await HandleCommand(line); + var response = await HandleCommandAsync(line); Console.WriteLine(response); } } - private async Task HandleCommand(string json) + public async Task HandleCommandAsync(string json) { + if (string.IsNullOrWhiteSpace(json)) + return Error("Invalid command"); + + Command? cmd; try { - var cmd = JsonSerializer.Deserialize(json); - if (cmd is null) return Error("Invalid command"); - - object? result = cmd.Action switch - { - "fragments.list" => await _fragments.GetAllAsync(), - "fragments.get" => await _fragments.GetByIdAsync(Guid.Parse(cmd.Id!)), - "fragments.create" => await _fragments.CreateAsync( - cmd.Payload!.Value.Deserialize()!), - "fragments.update" => await _fragments.UpdateAsync( - Guid.Parse(cmd.Id!), - cmd.Payload!.Value.Deserialize()!), - "fragments.delete" => await _fragments.RemoveAsync(Guid.Parse(cmd.Id!)), - "fragments.search" => await _fragments.SearchAsync(cmd.Type, cmd.Tag), - _ => null - }; - - if (result is null) - return Error($"Unknown action: {cmd.Action}"); - - return JsonSerializer.Serialize(new { ok = true, data = result }); + cmd = JsonSerializer.Deserialize(json, JsonOptions); } - catch (Exception ex) + catch (JsonException) { + return Error("Invalid command JSON"); + } + + if (cmd is null || string.IsNullOrWhiteSpace(cmd.Action)) + return Error("Invalid command"); + + var action = cmd.Action.Trim(); + var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId) + ? Guid.NewGuid().ToString("N") + : cmd.CorrelationId.Trim(); + LogStart(action, correlationId, cmd.Payload); + object? result; + + try + { + switch (action) + { + case "fragments.list": + result = await _fragments.GetAllAsync(); + break; + case "fragments.get": + if (!Guid.TryParse(cmd.Id, out var getId)) + return Error("Invalid or missing id"); + result = await _fragments.GetByIdAsync(getId); + break; + case "fragments.create": + var createDto = DeserializePayload(cmd.Payload); + if (createDto is null) + return Error("Missing or invalid payload"); + result = await _fragments.CreateAsync(createDto); + break; + case "fragments.update": + if (!Guid.TryParse(cmd.Id, out var updateId)) + return Error("Invalid or missing id"); + var updateDto = DeserializePayload(cmd.Payload); + if (updateDto is null) + return Error("Missing or invalid payload"); + result = await _fragments.UpdateAsync(updateId, updateDto); + break; + case "fragments.delete": + if (!Guid.TryParse(cmd.Id, out var deleteId)) + return Error("Invalid or missing id"); + result = await _fragments.RemoveAsync(deleteId); + break; + case "fragments.search": + result = await _fragments.SearchAsync(cmd.Type, cmd.Tag); + break; + case "search.entries": + var searchPayload = DeserializePayload(cmd.Payload); + if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory)) + return Error("Missing or invalid payload"); + var searchRequest = new EntrySearchRequestDto( + DataDirectory: searchPayload.DataDirectory, + Query: searchPayload.Query, + Section: searchPayload.Section, + StartDate: searchPayload.StartDate, + EndDate: searchPayload.EndDate, + Tags: searchPayload.Tags, + Types: searchPayload.Types, + Checked: searchPayload.Checked, + Unchecked: searchPayload.Unchecked); + result = await _entrySearch.SearchEntriesAsync(searchRequest); + break; + case "entries.list": + var listPayload = DeserializePayload(cmd.Payload); + var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory) + ? listPayload.DataDirectory + : _config.Current.DataDirectory; + result = ListEntries(listDataDirectory); + break; + case "entries.load": + var loadEntryPayload = DeserializePayload(cmd.Payload); + if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath)) + return Error("Missing or invalid payload"); + result = LoadEntry(loadEntryPayload.FilePath); + break; + case "entries.save": + var saveEntryPayload = DeserializePayload(cmd.Payload); + if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content)) + return Error("Missing or invalid payload"); + result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory); + break; + case "config.get": + result = _config.Current; + break; + case "ai.health": + result = await _ai.HealthAsync(); + break; + case "ai.summarize_entry": + var summarizeEntryPayload = DeserializePayload(cmd.Payload); + if (summarizeEntryPayload is null || string.IsNullOrWhiteSpace(summarizeEntryPayload.Content)) + return Error("Missing or invalid payload"); + result = await _ai.SummarizeEntryAsync(summarizeEntryPayload.Content, summarizeEntryPayload.FileStem); + break; + case "ai.summarize_all": + var summarizeAllPayload = DeserializePayload(cmd.Payload); + if (summarizeAllPayload is null) + return Error("Missing or invalid payload"); + result = await _ai.SummarizeAllAsync(summarizeAllPayload.Entries ?? []); + break; + case "ai.chat": + var chatPayload = DeserializePayload(cmd.Payload); + if (chatPayload is null || string.IsNullOrWhiteSpace(chatPayload.Prompt)) + return Error("Missing or invalid payload"); + result = await _ai.ChatAsync(chatPayload.Prompt); + break; + case "ai.embed": + var embedPayload = DeserializePayload(cmd.Payload); + if (embedPayload is null || string.IsNullOrWhiteSpace(embedPayload.Content)) + return Error("Missing or invalid payload"); + result = await _ai.EmbedAsync(embedPayload.Content); + break; + case "speech.devices.list": + result = await _speech.ListDevicesAsync(); + break; + case "speech.transcribe": + var speechPayload = DeserializePayload(cmd.Payload); + if (speechPayload is null) + return Error("Missing or invalid payload"); + var audioBase64 = !string.IsNullOrWhiteSpace(speechPayload.AudioBase64) + ? speechPayload.AudioBase64 + : speechPayload.Audio_Base64; + var text = speechPayload.Text; + var whisperModel = !string.IsNullOrWhiteSpace(speechPayload.WhisperModel) + ? speechPayload.WhisperModel + : speechPayload.Whisper_Model; + var simulateDelayMs = speechPayload.SimulateDelayMs ?? speechPayload.Simulate_Delay_Ms; + if (string.IsNullOrWhiteSpace(audioBase64) && string.IsNullOrWhiteSpace(text)) + return Error("Missing or invalid payload"); + result = await _speech.TranscribeAsync(new SpeechTranscribeRequestDto( + AudioBase64: audioBase64, + Engine: speechPayload.Engine, + WhisperModel: whisperModel, + Text: text, + SimulateDelayMs: simulateDelayMs)); + break; + case "vault.initialize": + var initPayload = DeserializePayload(cmd.Payload); + if (initPayload is null || string.IsNullOrWhiteSpace(initPayload.Password) || string.IsNullOrWhiteSpace(initPayload.VaultDirectory)) + return Error("Missing or invalid payload"); + Directory.CreateDirectory(initPayload.VaultDirectory); + result = true; + break; + case "vault.load_all": + var loadPayload = DeserializePayload(cmd.Payload); + if (loadPayload is null) + return Error("Missing or invalid payload"); + result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory); + break; + case "vault.save_current_month": + var saveCurrentPayload = DeserializePayload(cmd.Payload); + if (saveCurrentPayload is null) + return Error("Missing or invalid payload"); + result = _vaultStorage.SaveCurrentMonthVault( + saveCurrentPayload.Password, + saveCurrentPayload.VaultDirectory, + saveCurrentPayload.DataDirectory, + ParseNowOrDefault(saveCurrentPayload.NowUtc)); + break; + case "vault.rebuild_all": + var rebuildPayload = DeserializePayload(cmd.Payload); + if (rebuildPayload is null) + return Error("Missing or invalid payload"); + _vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory); + result = true; + break; + case "vault.clear_data_directory": + var clearPayload = DeserializePayload(cmd.Payload); + if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory)) + return Error("Missing or invalid payload"); + _vaultStorage.ClearDataDirectory(clearPayload.DataDirectory); + result = true; + break; + case "db.status": + var dbStatusPayload = DeserializePayload(cmd.Payload); + if (dbStatusPayload is null || string.IsNullOrWhiteSpace(dbStatusPayload.Password)) + return Error("Missing or invalid payload"); + result = _database.GetStatus(dbStatusPayload.Password, dbStatusPayload.DataDirectory); + break; + case "db.initialize_schema": + var dbInitPayload = DeserializePayload(cmd.Payload); + if (dbInitPayload is null) + return Error("Missing or invalid payload"); + var schemaPath = _database.WriteSchemaBootstrap(dbInitPayload.DataDirectory); + result = new { schemaPath }; + break; + case "db.hydrate_workspace": + var dbHydratePayload = DeserializePayload(cmd.Payload); + if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password)) + return Error("Missing or invalid payload"); + result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory); + break; + default: + LogFailure(action, correlationId, "unknown_action"); + return Error($"Unknown action: {action}"); + } + } + catch (JsonException) + { + LogFailure(action, correlationId, "invalid_payload_json"); + return Error("Missing or invalid payload"); + } + catch (ValidationException ex) + { + LogFailure(action, correlationId, "validation", ex.Message); return Error(ex.Message); } + catch (ArgumentException ex) + { + LogFailure(action, correlationId, "argument", ex.Message); + return Error(ex.Message); + } + catch (TimeoutException ex) + { + LogFailure(action, correlationId, "timeout", ex.Message); + return Error(ex.Message); + } + catch (InvalidOperationException ex) + { + LogFailure(action, correlationId, "invalid_operation", ex.Message); + return Error(ex.Message); + } + catch (FileNotFoundException ex) + { + LogFailure(action, correlationId, "not_found", ex.Message); + return Error(ex.Message); + } + catch + { + LogFailure(action, correlationId, "internal_error"); + return Error("Internal error"); + } + + 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(JsonElement? payload) + { + if (payload is null) + return default; + return payload.Value.Deserialize(JsonOptions); + } + + private static DateTime ParseNowOrDefault(string? nowUtc) + { + if (string.IsNullOrWhiteSpace(nowUtc)) + return DateTime.UtcNow; + + if (DateTime.TryParse( + nowUtc, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + return parsed; + } + + throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time."); + } + + private static IReadOnlyList 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 = + [ + "", " lowered.Contains(marker, StringComparison.Ordinal))) + return true; + return Regex.Matches(lowered, "]*>").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[^>]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); + text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "]*>", "\n- ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", " ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "]*>", "\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? 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? Tags = null, + List? Types = null, + List? Checked = null, + List? Unchecked = null); } diff --git a/Journal.Core/Journal.Core.csproj b/Journal.Core/Journal.Core.csproj index 5831e53..7e67750 100644 --- a/Journal.Core/Journal.Core.csproj +++ b/Journal.Core/Journal.Core.csproj @@ -7,7 +7,9 @@ + + diff --git a/Journal.Core/Models/Command.cs b/Journal.Core/Models/Command.cs index 435345a..ac44027 100644 --- a/Journal.Core/Models/Command.cs +++ b/Journal.Core/Models/Command.cs @@ -5,6 +5,7 @@ namespace Journal.Core.Models; public class Command { public string Action { get; set; } = ""; + public string? CorrelationId { get; set; } public string? Id { get; set; } public string? Type { get; set; } public string? Tag { get; set; } diff --git a/Journal.Core/Models/Fragment.cs b/Journal.Core/Models/Fragment.cs index 7e6ac26..6b67db7 100644 --- a/Journal.Core/Models/Fragment.cs +++ b/Journal.Core/Models/Fragment.cs @@ -10,14 +10,33 @@ public class Fragment public Fragment(string type, string description) { - if (string.IsNullOrWhiteSpace(type)) - throw new ArgumentException("Type is required", nameof(type)); - if (string.IsNullOrWhiteSpace(description)) - throw new ArgumentException("Description is required", nameof(description)); + Validate(type, description); Id = Guid.NewGuid(); Type = type.Trim(); Description = description.Trim(); Time = DateTimeOffset.Now; } + + public Fragment(Guid id, string type, string description, DateTimeOffset time, IEnumerable? tags = null) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + Validate(type, description); + + Id = id; + Type = type.Trim(); + Description = description.Trim(); + Time = time; + if (tags is not null) + Tags = [.. tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim())]; + } + + private static void Validate(string type, string description) + { + if (string.IsNullOrWhiteSpace(type)) + throw new ArgumentException("Type is required", nameof(type)); + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description is required", nameof(description)); + } } diff --git a/Journal.Core/Models/JournalConfig.cs b/Journal.Core/Models/JournalConfig.cs new file mode 100644 index 0000000..f540a41 --- /dev/null +++ b/Journal.Core/Models/JournalConfig.cs @@ -0,0 +1,29 @@ +namespace Journal.Core.Models; + +public sealed record JournalConfig( + string ProjectRoot, + string AppDirectory, + string DataDirectory, + string VaultDirectory, + string LogDirectory, + string PidFile, + string ServerControlFile, + string DatabaseFilename, + string MonthlyVaultFormat, + string CloudAiApiKey, + string CloudAiApiUrl, + string LlamaCppUrl, + string LlamaCppModel, + int LlamaCppTimeout, + string EmbeddingApiUrl, + string EmbeddingModelName, + int ModelContextTokens, + int ChunkTokenBudget, + int? MicrophoneDeviceIndex, + string SpeechRecognitionEngine, + string WhisperModelSize, + string NlpBackend, + string AiProvider, + string PythonExecutable, + string PythonAiSidecarPath, + int AiSidecarTimeoutMs); diff --git a/Journal.Core/Models/JournalEntry.cs b/Journal.Core/Models/JournalEntry.cs new file mode 100644 index 0000000..e647c55 --- /dev/null +++ b/Journal.Core/Models/JournalEntry.cs @@ -0,0 +1,98 @@ +namespace Journal.Core.Models; + +public class JournalEntry +{ + public string Date { get; set; } + public List Fragments { get; set; } + public string RawContent { get; set; } + public Dictionary Sections { get; set; } + + public JournalEntry( + string date, + IEnumerable? fragments = null, + string rawContent = "", + IDictionary? sections = null) + { + if (string.IsNullOrWhiteSpace(date)) + throw new ArgumentException("Date is required", nameof(date)); + + Date = date.Trim(); + Fragments = fragments is null ? [] : [.. fragments]; + RawContent = rawContent ?? ""; + Sections = sections is null ? [] : new Dictionary(sections); + } + + public string GetSection(string sectionTitle) + { + if (string.IsNullOrWhiteSpace(sectionTitle)) + return ""; + if (!Sections.TryGetValue(sectionTitle, out var section)) + return ""; + return string.Join("\n", section.Content); + } + + public bool? GetCheckboxState(string sectionTitle, string checkboxText) + { + if (string.IsNullOrWhiteSpace(sectionTitle) || string.IsNullOrWhiteSpace(checkboxText)) + return null; + if (!Sections.TryGetValue(sectionTitle, out var section)) + return null; + return section.Checkboxes.TryGetValue(checkboxText, out var checkedState) ? checkedState : null; + } + + public void MergeWith(JournalEntry otherEntry) + { + ArgumentNullException.ThrowIfNull(otherEntry); + + foreach (var (title, newSection) in otherEntry.Sections) + { + if (newSection.Content.Any(line => !string.IsNullOrWhiteSpace(line))) + Sections[title] = newSection; + } + + var existingFragmentDescriptions = Fragments + .Select(fragment => fragment.Description) + .ToHashSet(StringComparer.Ordinal); + + foreach (var newFragment in otherEntry.Fragments) + { + if (!existingFragmentDescriptions.Contains(newFragment.Description)) + Fragments.Add(newFragment); + } + } + + public string ToMarkdown() + { + var lines = new List + { + "---", + "type: journal", + "---", + $"**Date:** {Date}\n" + }; + + foreach (var title in SectionTitles.Canonical) + { + if (!Sections.TryGetValue(title, out var section)) + continue; + + lines.Add($"## {section.Title}\n"); + lines.AddRange(section.Content); + lines.Add(""); + } + + if (Fragments.Count > 0) + { + lines.Add("# Fragments\n"); + foreach (var fragment in Fragments) + { + var timeStr = fragment.Time != default ? $"@{fragment.Time:O}" : ""; + var tagsStr = string.Join(" ", fragment.Tags.Select(tag => $"#{tag}")); + var header = $"{fragment.Type} {timeStr} {tagsStr}".Trim(); + lines.Add($"{header}\n{fragment.Description}\n"); + } + } + + return string.Join("\n", lines); + } +} diff --git a/Journal.Core/Models/ParsedSection.cs b/Journal.Core/Models/ParsedSection.cs new file mode 100644 index 0000000..bc9ff3c --- /dev/null +++ b/Journal.Core/Models/ParsedSection.cs @@ -0,0 +1,21 @@ +namespace Journal.Core.Models; + +public class ParsedSection +{ + public string Title { get; set; } + public List Content { get; set; } + public Dictionary Checkboxes { get; set; } + + public ParsedSection( + string title, + IEnumerable? content = null, + IDictionary? checkboxes = null) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Section title is required", nameof(title)); + + Title = title.Trim(); + Content = content is null ? [] : [.. content]; + Checkboxes = checkboxes is null ? [] : new Dictionary(checkboxes); + } +} diff --git a/Journal.Core/Models/SectionTitles.cs b/Journal.Core/Models/SectionTitles.cs new file mode 100644 index 0000000..3aaf666 --- /dev/null +++ b/Journal.Core/Models/SectionTitles.cs @@ -0,0 +1,20 @@ +namespace Journal.Core.Models; + +public static class SectionTitles +{ + public static readonly IReadOnlyList Canonical = + [ + "Summary", + "Cognitive State", + "Mental / Emotional Snapshot", + "Memory / Mind Failures", + "Events / Triggers", + "Communication / Expression Log", + "Coping / Tools Used", + "Reflection", + "Core Events or Memories", + "Autism/ADHD-Related Elements", + "Emotional & Bodily Reactions", + "Truth to Anchor Myself To", + ]; +} diff --git a/Journal.Core/Repositories/FileFragmentRepository.cs b/Journal.Core/Repositories/FileFragmentRepository.cs new file mode 100644 index 0000000..71e107d --- /dev/null +++ b/Journal.Core/Repositories/FileFragmentRepository.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public class FileFragmentRepository : IFragmentRepository +{ + private readonly Lock _lock = new(); + private readonly string _storagePath; + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true + }; + private readonly List _store; + + public FileFragmentRepository() : this(storagePath: null) + { + } + + public FileFragmentRepository(string? storagePath) + { + _storagePath = ResolveStoragePath(storagePath); + _store = LoadStore(_storagePath); + } + + public Task> GetAllAsync() + { + lock (_lock) + { + return Task.FromResult(_store.ToList()); + } + } + + public Task 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 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 UpdateAsync( + Guid id, + string? type = null, + string? description = null, + IEnumerable? 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> GetByTagAsync(string tag) + { + var q = tag?.Trim(); + if (string.IsNullOrWhiteSpace(q)) + return Task.FromResult(new List()); + + lock (_lock) + { + var items = _store + .Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))) + .ToList(); + return Task.FromResult(items); + } + } + + public Task> GetByTypeAsync(string type) + { + var q = type?.Trim(); + if (string.IsNullOrWhiteSpace(q)) + return Task.FromResult(new List()); + + lock (_lock) + { + var items = _store + .Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return Task.FromResult(items); + } + } + + public Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + { + var qType = type?.Trim(); + var qTag = tag?.Trim(); + + lock (_lock) + { + IEnumerable 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 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>(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 Tags { get; init; } = []; + } +} diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs index 97aa165..3bb6fc4 100644 --- a/Journal.Core/ServiceCollectionExtensions.cs +++ b/Journal.Core/ServiceCollectionExtensions.cs @@ -8,8 +8,46 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddFragmentServices(this IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + { + var config = provider.GetRequiredService().Current; + if (!string.Equals(config.AiProvider, "python-sidecar", StringComparison.OrdinalIgnoreCase)) + return new DisabledAiService(config.AiProvider); + + try + { + return new PythonSidecarAiService(config); + } + catch (Exception ex) + { + return new DisabledAiService( + provider: "python-sidecar", + message: $"Python AI sidecar unavailable: {ex.Message}", + healthy: false); + } + }); + services.AddSingleton(provider => + { + var config = provider.GetRequiredService().Current; + try + { + return new PythonSidecarSpeechService(config); + } + catch (Exception ex) + { + return new DisabledSpeechBridgeService( + provider: "python-sidecar", + message: $"Python speech sidecar unavailable: {ex.Message}"); + } + }); + services.AddSingleton(); return services; } } diff --git a/Journal.Core/Services/DisabledAiService.cs b/Journal.Core/Services/DisabledAiService.cs new file mode 100644 index 0000000..cc5b8ac --- /dev/null +++ b/Journal.Core/Services/DisabledAiService.cs @@ -0,0 +1,32 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services; + +public sealed class DisabledAiService : IAiService +{ + private readonly string _provider; + private readonly string _message; + private readonly bool _healthy; + + public DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) + { + _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); + _message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim(); + _healthy = healthy; + } + + public Task HealthAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message)); + + public Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default) => + Task.FromResult(_message); + + public Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default) => + Task.FromResult(_message); + + public Task ChatAsync(string prompt, CancellationToken cancellationToken = default) => + Task.FromResult(_message); + + public Task> EmbedAsync(string content, CancellationToken cancellationToken = default) => + Task.FromResult>([]); +} diff --git a/Journal.Core/Services/DisabledSpeechBridgeService.cs b/Journal.Core/Services/DisabledSpeechBridgeService.cs new file mode 100644 index 0000000..9f9a658 --- /dev/null +++ b/Journal.Core/Services/DisabledSpeechBridgeService.cs @@ -0,0 +1,32 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services; + +public sealed class DisabledSpeechBridgeService : ISpeechBridgeService +{ + private readonly string _provider; + private readonly string _message; + + public DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") + { + _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); + _message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim(); + } + + public Task ListDevicesAsync(CancellationToken cancellationToken = default) + { + var warning = $"{_message} (provider={_provider})"; + return Task.FromResult(new SpeechDevicesResultDto([], warning)); + } + + public Task TranscribeAsync( + SpeechTranscribeRequestDto request, + CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + var engine = string.IsNullOrWhiteSpace(request.Engine) ? "none" : request.Engine.Trim(); + var warning = $"{_message} (provider={_provider})"; + return Task.FromResult(new SpeechTranscribeResultDto("", engine, warning)); + } +} diff --git a/Journal.Core/Services/EntrySearchService.cs b/Journal.Core/Services/EntrySearchService.cs new file mode 100644 index 0000000..e2ff6dc --- /dev/null +++ b/Journal.Core/Services/EntrySearchService.cs @@ -0,0 +1,108 @@ +using Journal.Core.Dtos; +using System.Globalization; + +namespace Journal.Core.Services; + +public class EntrySearchService : IEntrySearchService +{ + public Task> SearchEntriesAsync(EntrySearchRequestDto request) + { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrWhiteSpace(request.DataDirectory)) + throw new ArgumentException("Data directory is required.", nameof(request.DataDirectory)); + + if (!Directory.Exists(request.DataDirectory)) + return Task.FromResult>([]); + + var hasQuery = !string.IsNullOrWhiteSpace(request.Query); + var query = request.Query?.Trim() ?? ""; + var hasSectionFilter = !string.IsNullOrWhiteSpace(request.Section); + var section = request.Section?.Trim() ?? ""; + + var typeSet = NormalizeSet(request.Types); + var tagSet = NormalizeSet(request.Tags); + var checkedSet = NormalizeSet(request.Checked); + var uncheckedSet = NormalizeSet(request.Unchecked); + var hasFragmentFilters = typeSet.Count > 0 || tagSet.Count > 0; + var hasCheckboxFilters = checkedSet.Count > 0 || uncheckedSet.Count > 0; + + var startDate = ParseOptionalDate(request.StartDate, nameof(request.StartDate)); + var endDate = ParseOptionalDate(request.EndDate, nameof(request.EndDate)); + if (startDate.HasValue && endDate.HasValue && startDate.Value > endDate.Value) + throw new ArgumentException("startDate cannot be after endDate."); + + var results = new List(); + foreach (var filePath in Directory.GetFiles(request.DataDirectory, "*.md") + .OrderBy(Path.GetFileName, StringComparer.Ordinal)) + { + var fileName = Path.GetFileName(filePath); + var fileStem = Path.GetFileNameWithoutExtension(filePath); + var rawContent = File.ReadAllText(filePath); + var entry = JournalParser.ParseJournalContent(rawContent, fileStem); + + if (startDate.HasValue || endDate.HasValue) + { + if (!DateOnly.TryParseExact(entry.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var entryDate)) + continue; + + if (startDate.HasValue && entryDate < startDate.Value) + continue; + if (endDate.HasValue && entryDate > endDate.Value) + continue; + } + + var contentMatch = true; + if (hasQuery) + { + var haystack = hasSectionFilter ? entry.GetSection(section) : entry.RawContent; + contentMatch = haystack.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; + } + if (!contentMatch) + continue; + + var fragmentMatch = !hasFragmentFilters || entry.Fragments.Any(fragment => + (typeSet.Count == 0 || typeSet.Contains(fragment.Type)) && + (tagSet.Count == 0 || fragment.Tags.Any(tagSet.Contains))); + if (!fragmentMatch) + continue; + + var checkboxMatch = !hasCheckboxFilters || entry.Sections.Values.Any(sectionValue => + sectionValue.Checkboxes.Any(checkbox => + (checkedSet.Count > 0 && checkbox.Value && checkedSet.Contains(checkbox.Key)) || + (uncheckedSet.Count > 0 && !checkbox.Value && uncheckedSet.Contains(checkbox.Key)))); + if (!checkboxMatch) + continue; + + results.Add(new EntrySearchResultDto(entry.Date, fileName, entry.RawContent)); + } + + return Task.FromResult>(results); + } + + private static HashSet NormalizeSet(IReadOnlyList? values) + { + if (values is null || values.Count == 0) + return []; + + var set = new HashSet(StringComparer.Ordinal); + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + set.Add(value.Trim()); + } + + return set; + } + + private static DateOnly? ParseOptionalDate(string? raw, string argumentName) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (DateOnly.TryParseExact(raw.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + return date; + + throw new ArgumentException($"Invalid {argumentName} value. Expected yyyy-MM-dd."); + } +} diff --git a/Journal.Core/Services/FragmentService.cs b/Journal.Core/Services/FragmentService.cs index b435660..fef24b7 100644 --- a/Journal.Core/Services/FragmentService.cs +++ b/Journal.Core/Services/FragmentService.cs @@ -37,12 +37,16 @@ public class FragmentService : IFragmentService public async Task UpdateAsync(Guid id, UpdateFragmentDto dto) { ArgumentNullException.ThrowIfNull(dto); - if (dto.Type != null) + if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type)) throw new ValidationException("Type cannot be empty"); if (dto.Description != null && string.IsNullOrWhiteSpace(dto.Description)) throw new ValidationException("Description cannot be empty"); - return await _repo.UpdateAsync(id, dto.Type, dto.Description, dto.Tags, dto.Time); + var type = dto.Type?.Trim(); + var description = dto.Description?.Trim(); + var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList(); + + return await _repo.UpdateAsync(id, type, description, tags, dto.Time); } public Task RemoveAsync(Guid id) => _repo.RemoveAsync(id); diff --git a/Journal.Core/Services/IAiService.cs b/Journal.Core/Services/IAiService.cs new file mode 100644 index 0000000..791873b --- /dev/null +++ b/Journal.Core/Services/IAiService.cs @@ -0,0 +1,12 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services; + +public interface IAiService +{ + Task HealthAsync(CancellationToken cancellationToken = default); + Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default); + Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default); + Task ChatAsync(string prompt, CancellationToken cancellationToken = default); + Task> EmbedAsync(string content, CancellationToken cancellationToken = default); +} diff --git a/Journal.Core/Services/IEntrySearchService.cs b/Journal.Core/Services/IEntrySearchService.cs new file mode 100644 index 0000000..e9bfede --- /dev/null +++ b/Journal.Core/Services/IEntrySearchService.cs @@ -0,0 +1,8 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services; + +public interface IEntrySearchService +{ + Task> SearchEntriesAsync(EntrySearchRequestDto request); +} diff --git a/Journal.Core/Services/IJournalConfigService.cs b/Journal.Core/Services/IJournalConfigService.cs new file mode 100644 index 0000000..3e0a7b5 --- /dev/null +++ b/Journal.Core/Services/IJournalConfigService.cs @@ -0,0 +1,8 @@ +using Journal.Core.Models; + +namespace Journal.Core.Services; + +public interface IJournalConfigService +{ + JournalConfig Current { get; } +} diff --git a/Journal.Core/Services/IJournalDatabaseService.cs b/Journal.Core/Services/IJournalDatabaseService.cs new file mode 100644 index 0000000..54b86bc --- /dev/null +++ b/Journal.Core/Services/IJournalDatabaseService.cs @@ -0,0 +1,29 @@ +namespace Journal.Core.Services; + +public interface IJournalDatabaseService +{ + string GetDatabasePath(string? dataDirectory = null); + byte[] DeriveDatabaseKey(string password); + string BuildPragmaKeyStatement(string password); + IReadOnlyDictionary GetSchemaStatements(); + string WriteSchemaBootstrap(string? dataDirectory = null); + 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 SchemaTables, + string SchemaBootstrapPath, + bool RuntimeReady, + string RuntimeMessage); + +public sealed record JournalDatabaseHydrationResult( + string DatabasePath, + string SchemaBootstrapPath, + int EntryFilesProcessed, + bool RuntimeReady, + string Message); diff --git a/Journal.Core/Services/ISpeechBridgeService.cs b/Journal.Core/Services/ISpeechBridgeService.cs new file mode 100644 index 0000000..0294722 --- /dev/null +++ b/Journal.Core/Services/ISpeechBridgeService.cs @@ -0,0 +1,9 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services; + +public interface ISpeechBridgeService +{ + Task ListDevicesAsync(CancellationToken cancellationToken = default); + Task TranscribeAsync(SpeechTranscribeRequestDto request, CancellationToken cancellationToken = default); +} diff --git a/Journal.Core/Services/IVaultCryptoService.cs b/Journal.Core/Services/IVaultCryptoService.cs new file mode 100644 index 0000000..85418e5 --- /dev/null +++ b/Journal.Core/Services/IVaultCryptoService.cs @@ -0,0 +1,8 @@ +namespace Journal.Core.Services; + +public interface IVaultCryptoService +{ + byte[] DeriveKey(string password, byte[] salt); + byte[] EncryptData(byte[] data, string password); + byte[] DecryptData(byte[] encryptedData, string password); +} diff --git a/Journal.Core/Services/IVaultStorageService.cs b/Journal.Core/Services/IVaultStorageService.cs new file mode 100644 index 0000000..525c1f3 --- /dev/null +++ b/Journal.Core/Services/IVaultStorageService.cs @@ -0,0 +1,10 @@ +namespace Journal.Core.Services; + +public interface IVaultStorageService +{ + string GetMonthlyVaultFileName(DateTime date); + bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory); + bool SaveCurrentMonthVault(string password, string vaultDirectory, string dataDirectory, DateTime now); + void RebuildAllVaults(string password, string vaultDirectory, string dataDirectory); + void ClearDataDirectory(string dataDirectory); +} diff --git a/Journal.Core/Services/JournalConfigService.cs b/Journal.Core/Services/JournalConfigService.cs new file mode 100644 index 0000000..bce9602 --- /dev/null +++ b/Journal.Core/Services/JournalConfigService.cs @@ -0,0 +1,107 @@ +using Journal.Core.Models; + +namespace Journal.Core.Services; + +public sealed class JournalConfigService : IJournalConfigService +{ + public JournalConfig Current { get; } = BuildConfig(); + + private static JournalConfig BuildConfig() + { + var projectRoot = ResolveProjectRoot(); + var appDirectory = ResolvePath("JOURNAL_APP_DIR", Path.Combine(projectRoot, "journal")); + + var dataDirectory = ResolvePath("JOURNAL_DATA_DIR", Path.Combine(appDirectory, "data")); + var vaultDirectory = ResolvePath("JOURNAL_VAULT_DIR", Path.Combine(appDirectory, "vault")); + var logDirectory = ResolvePath("JOURNAL_LOG_DIR", Path.Combine(projectRoot, "logs")); + + var pidFile = ResolvePath("JOURNAL_PID_FILE", Path.Combine(logDirectory, "nicegui_server.pid")); + var serverControlFile = ResolvePath("JOURNAL_SERVER_CONTROL_FILE", Path.Combine(logDirectory, "server_control.action")); + + var nlpBackend = (Environment.GetEnvironmentVariable("JOURNAL_NLP_BACKEND") ?? "auto").Trim().ToLowerInvariant(); + if (nlpBackend is not ("auto" or "spacy" or "fallback")) + nlpBackend = "auto"; + + var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "none").Trim().ToLowerInvariant(); + if (aiProvider is not ("none" or "python-sidecar")) + aiProvider = "none"; + + var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE"); + if (string.IsNullOrWhiteSpace(pythonExecutable)) + pythonExecutable = "python"; + + var defaultAiSidecarPath = Path.Combine(projectRoot, "journal", "ai", "sidecar.py"); + var pythonAiSidecarPath = ResolvePath("JOURNAL_AI_SIDECAR_PATH", defaultAiSidecarPath); + var aiSidecarTimeoutMs = ParseInt("JOURNAL_AI_TIMEOUT_MS", 45000); + + return new JournalConfig( + ProjectRoot: projectRoot, + AppDirectory: appDirectory, + DataDirectory: dataDirectory, + VaultDirectory: vaultDirectory, + LogDirectory: logDirectory, + PidFile: pidFile, + ServerControlFile: serverControlFile, + DatabaseFilename: Environment.GetEnvironmentVariable("JOURNAL_DATABASE_FILENAME") ?? "journal_cache.db", + MonthlyVaultFormat: Environment.GetEnvironmentVariable("JOURNAL_MONTHLY_VAULT_FORMAT") ?? "%Y-%m.vault", + CloudAiApiKey: Environment.GetEnvironmentVariable("CLOUDAI_API_KEY") ?? "", + CloudAiApiUrl: Environment.GetEnvironmentVariable("CLOUDAI_API_URL") ?? "", + LlamaCppUrl: Environment.GetEnvironmentVariable("LLAMA_CPP_URL") ?? "http://127.0.0.1:8085/v1/completions", + LlamaCppModel: Environment.GetEnvironmentVariable("LLAMA_CPP_MODEL") ?? "qwen/qwen3-4b", + LlamaCppTimeout: ParseInt("LLAMA_CPP_TIMEOUT", 6000), + EmbeddingApiUrl: Environment.GetEnvironmentVariable("EMBEDDING_API_URL") ?? "http://127.0.0.1:8086/v1/embeddings", + EmbeddingModelName: Environment.GetEnvironmentVariable("EMBEDDING_MODEL_NAME") ?? "text-embedding-nomic-embed-text-v2-moe", + ModelContextTokens: ParseInt("MODEL_CONTEXT_TOKENS", 131072), + ChunkTokenBudget: ParseInt("CHUNK_TOKEN_BUDGET", 120000), + MicrophoneDeviceIndex: ParseNullableInt("MICROPHONE_DEVICE_INDEX"), + SpeechRecognitionEngine: Environment.GetEnvironmentVariable("SPEECH_RECOGNITION_ENGINE") ?? "whisper", + WhisperModelSize: Environment.GetEnvironmentVariable("WHISPER_MODEL_SIZE") ?? "base", + NlpBackend: nlpBackend, + AiProvider: aiProvider, + PythonExecutable: pythonExecutable, + PythonAiSidecarPath: pythonAiSidecarPath, + AiSidecarTimeoutMs: aiSidecarTimeoutMs); + } + + private static string ResolveProjectRoot() + { + var envRoot = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); + if (!string.IsNullOrWhiteSpace(envRoot)) + return Path.GetFullPath(envRoot); + + var cwd = Directory.GetCurrentDirectory(); + if (Directory.Exists(Path.Combine(cwd, "journal"))) + return Path.GetFullPath(cwd); + + var upOne = Path.GetFullPath(Path.Combine(cwd, "..")); + if (Directory.Exists(Path.Combine(upOne, "journal"))) + return upOne; + + var upTwo = Path.GetFullPath(Path.Combine(cwd, "..", "..")); + if (Directory.Exists(Path.Combine(upTwo, "journal"))) + return upTwo; + + return Path.GetFullPath(cwd); + } + + private static string ResolvePath(string envVar, string defaultPath) + { + var value = Environment.GetEnvironmentVariable(envVar); + var raw = string.IsNullOrWhiteSpace(value) ? defaultPath : value; + return Path.GetFullPath(raw); + } + + private static int ParseInt(string envVar, int defaultValue) + { + var value = Environment.GetEnvironmentVariable(envVar); + return int.TryParse(value, out var parsed) ? parsed : defaultValue; + } + + private static int? ParseNullableInt(string envVar) + { + var value = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrWhiteSpace(value)) + return null; + return int.TryParse(value, out var parsed) ? parsed : null; + } +} diff --git a/Journal.Core/Services/JournalDatabaseService.cs b/Journal.Core/Services/JournalDatabaseService.cs new file mode 100644 index 0000000..73c0657 --- /dev/null +++ b/Journal.Core/Services/JournalDatabaseService.cs @@ -0,0 +1,233 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services; + +public sealed class JournalDatabaseService : IJournalDatabaseService +{ + public const int KeySize = 32; + 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 object SqliteInitLock = new(); + private static bool _sqliteInitialized; + private static readonly IReadOnlyList RequiredSchemaTables = + ["entries", "sections", "fragments", "tags", "fragment_tags"]; + + private readonly IJournalConfigService _config; + + public JournalDatabaseService(IJournalConfigService config) + { + _config = config; + } + + public string GetDatabasePath(string? dataDirectory = null) + { + var directory = string.IsNullOrWhiteSpace(dataDirectory) + ? _config.Current.DataDirectory + : dataDirectory; + + Directory.CreateDirectory(directory); + return Path.GetFullPath(Path.Combine(directory, _config.Current.DatabaseFilename)); + } + + public byte[] DeriveDatabaseKey(string password) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + + return Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + DatabaseKeySalt, + Iterations, + HashAlgorithmName.SHA256, + KeySize); + } + + public string BuildPragmaKeyStatement(string password) + { + var dbKeyHex = Convert.ToHexString(DeriveDatabaseKey(password)).ToLowerInvariant(); + return $"PRAGMA key = \"x'{dbKeyHex}'\""; + } + + public IReadOnlyDictionary GetSchemaStatements() + { + return new Dictionary(StringComparer.Ordinal) + { + ["entries"] = """ + CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE + ); + """, + ["sections"] = """ + CREATE TABLE IF NOT EXISTS sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + FOREIGN KEY (entry_id) REFERENCES entries (id) + ); + """, + ["fragments"] = """ + CREATE TABLE IF NOT EXISTS fragments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + type TEXT NOT NULL, + description TEXT, + time TEXT, + FOREIGN KEY (entry_id) REFERENCES entries (id) + ); + """, + ["tags"] = """ + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + ); + """, + ["fragment_tags"] = """ + CREATE TABLE IF NOT EXISTS fragment_tags ( + fragment_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (fragment_id, tag_id), + FOREIGN KEY (fragment_id) REFERENCES fragments (id), + FOREIGN KEY (tag_id) REFERENCES tags (id) + ); + """ + }; + } + + public string WriteSchemaBootstrap(string? dataDirectory = null) + { + var directory = string.IsNullOrWhiteSpace(dataDirectory) + ? _config.Current.DataDirectory + : dataDirectory; + Directory.CreateDirectory(directory); + + var bootstrapPath = Path.GetFullPath(Path.Combine(directory, "journal_schema.sql")); + var statements = GetSchemaStatements() + .Select(pair => $"-- {pair.Key}\n{pair.Value.Trim()}") + .ToArray(); + var content = string.Join("\n\n", statements) + "\n"; + File.WriteAllText(bootstrapPath, content); + return bootstrapPath; + } + + public JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null) + { + var tables = GetSchemaStatements().Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray(); + var bootstrapPath = WriteSchemaBootstrap(dataDirectory); + var runtime = ProbeRuntime(password, dataDirectory); + return new JournalDatabaseStatus( + DatabasePath: GetDatabasePath(dataDirectory), + KeyLengthBytes: DeriveDatabaseKey(password).Length, + Iterations: Iterations, + KeyDerivation: "PBKDF2-HMAC-SHA256", + SchemaTables: tables, + SchemaBootstrapPath: bootstrapPath, + RuntimeReady: runtime.Ready, + RuntimeMessage: runtime.Message); + } + + public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null) + { + var directory = string.IsNullOrWhiteSpace(dataDirectory) + ? _config.Current.DataDirectory + : dataDirectory; + Directory.CreateDirectory(directory); + + using var connection = OpenEncryptedConnection(password, directory); + CreateSchema(connection); + var runtimeReady = HasRequiredTables(connection); + + var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length; + var schemaPath = WriteSchemaBootstrap(directory); + + return new JournalDatabaseHydrationResult( + DatabasePath: GetDatabasePath(directory), + SchemaBootstrapPath: schemaPath, + EntryFilesProcessed: entryFilesProcessed, + RuntimeReady: runtimeReady, + Message: runtimeReady + ? "Workspace hydration completed with SQLCipher runtime schema validation." + : "Workspace hydration completed, but required schema tables were not found."); + } + + private static void EnsureSqliteInitialized() + { + if (_sqliteInitialized) + return; + + lock (SqliteInitLock) + { + if (_sqliteInitialized) + return; + + SQLitePCL.Batteries_V2.Init(); + _sqliteInitialized = true; + } + } + + private SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + + EnsureSqliteInitialized(); + + var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};Mode=ReadWriteCreate;Pooling=False"); + connection.Open(); + + using var keyCmd = connection.CreateCommand(); + keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";"; + keyCmd.ExecuteNonQuery(); + + using var verifyCmd = connection.CreateCommand(); + verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; + _ = verifyCmd.ExecuteScalar(); + + return connection; + } + + private void CreateSchema(SqliteConnection connection) + { + foreach (var statement in GetSchemaStatements().Values) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = statement; + cmd.ExecuteNonQuery(); + } + } + + private static bool HasRequiredTables(SqliteConnection connection) + { + var existing = new HashSet(StringComparer.OrdinalIgnoreCase); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table'"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + if (!reader.IsDBNull(0)) + existing.Add(reader.GetString(0)); + } + + return RequiredSchemaTables.All(existing.Contains); + } + + private (bool Ready, string Message) ProbeRuntime(string password, string? dataDirectory) + { + try + { + using var connection = OpenEncryptedConnection(password, dataDirectory); + CreateSchema(connection); + var ready = HasRequiredTables(connection); + return ready + ? (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}"); + } + } +} diff --git a/Journal.Core/Services/JournalParser.cs b/Journal.Core/Services/JournalParser.cs new file mode 100644 index 0000000..ff00634 --- /dev/null +++ b/Journal.Core/Services/JournalParser.cs @@ -0,0 +1,175 @@ +using System.Text.RegularExpressions; +using Journal.Core.Models; + +namespace Journal.Core.Services; + +public static partial class JournalParser +{ + [GeneratedRegex(@"(?:\*\*Date:\*\*|\*\*Date:|Date:)\s*(.+)")] + private static partial Regex DatePattern(); + [GeneratedRegex(@"^\#\#+\s*(.*)$")] + private static partial Regex SectionHeaderPattern(); + [GeneratedRegex(@"^\s*[-*]\s*\[([xX ])\]\s*(.*)$")] + private static partial Regex CheckboxPattern(); + [GeneratedRegex(@"^(!\w+)\s*((?:@\S+\s*)?)(?:\s*((?:#\S+\s*)*))?\s*$")] + private static partial Regex FragmentHeaderPattern(); + [GeneratedRegex(@"^!\w+\s*")] + private static partial Regex FragmentBoundaryPattern(); + + public static JournalEntry ParseJournalContent(string content, string fileStem) + { + ArgumentNullException.ThrowIfNull(content); + return new JournalEntry( + date: ExtractDate(content, fileStem), + rawContent: content, + sections: ParseSections(content), + fragments: ParseFragments(content)); + } + + public static string ExtractDate(string content, string fileStem) + { + ArgumentNullException.ThrowIfNull(content); + if (string.IsNullOrWhiteSpace(fileStem)) + throw new ArgumentException("File stem is required", nameof(fileStem)); + + var match = DatePattern().Match(content); + if (match.Success) + { + var parsed = match.Groups[1].Value.Trim(); + if (!string.IsNullOrWhiteSpace(parsed)) + return parsed; + } + + return fileStem.Trim(); + } + + public static Dictionary ParseSections(string content) + { + ArgumentNullException.ThrowIfNull(content); + + var parsedSections = new Dictionary(); + string? currentSectionTitle = null; + var currentSectionContent = new List(); + var currentSectionCheckboxes = new Dictionary(); + + var lines = content.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + foreach (var line in lines) + { + var sectionHeaderMatch = SectionHeaderPattern().Match(line.Trim()); + if (sectionHeaderMatch.Success) + { + if (currentSectionTitle is not null) + { + parsedSections[currentSectionTitle] = new ParsedSection( + currentSectionTitle, + currentSectionContent, + currentSectionCheckboxes); + } + + var headerText = sectionHeaderMatch.Groups[1].Value.Trim(); + var foundTitle = FindCanonicalSectionTitle(headerText); + + if (foundTitle is not null) + { + currentSectionTitle = foundTitle; + currentSectionContent = []; + currentSectionCheckboxes = []; + } + else + { + currentSectionTitle = null; + currentSectionContent = []; + currentSectionCheckboxes = []; + } + + continue; + } + + if (currentSectionTitle is not null) + { + var checkboxMatch = CheckboxPattern().Match(line); + if (checkboxMatch.Success) + { + var isChecked = checkboxMatch.Groups[1].Value.Trim().Equals("x", StringComparison.OrdinalIgnoreCase); + var checkboxText = checkboxMatch.Groups[2].Value.Trim(); + currentSectionCheckboxes[checkboxText] = isChecked; + } + + currentSectionContent.Add(line); + } + } + + if (currentSectionTitle is not null) + { + parsedSections[currentSectionTitle] = new ParsedSection( + currentSectionTitle, + currentSectionContent, + currentSectionCheckboxes); + } + + return parsedSections; + } + + public static List ParseFragments(string content) + { + ArgumentNullException.ThrowIfNull(content); + + var fragments = new List(); + var lines = content.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + + for (var i = 0; i < lines.Length; i++) + { + var headerMatch = FragmentHeaderPattern().Match(lines[i]); + if (!headerMatch.Success) + continue; + + var type = headerMatch.Groups[1].Value.Trim(); + var timeToken = headerMatch.Groups[2].Value.Trim().TrimStart('@'); + var tagsToken = headerMatch.Groups[3].Value.Trim(); + + var descriptionLines = new List(); + var j = i + 1; + while (j < lines.Length && !FragmentBoundaryPattern().IsMatch(lines[j])) + { + descriptionLines.Add(lines[j]); + j++; + } + + var description = string.Join("\n", descriptionLines).Trim(); + if (!string.IsNullOrWhiteSpace(description)) + { + var fragment = new Fragment(type, description); + if (!string.IsNullOrWhiteSpace(timeToken) && DateTimeOffset.TryParse(timeToken, out var parsedTime)) + fragment.Time = parsedTime; + + if (!string.IsNullOrWhiteSpace(tagsToken)) + { + fragment.Tags = + [ + .. tagsToken.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(t => t.StartsWith('#')) + .Select(t => t.Trim().TrimStart('#')) + .Where(t => !string.IsNullOrWhiteSpace(t)) + ]; + } + + fragments.Add(fragment); + } + + i = j - 1; + } + + return fragments; + } + + private static string? FindCanonicalSectionTitle(string headerText) + { + foreach (var title in SectionTitles.Canonical) + { + if (headerText.Contains(title, StringComparison.OrdinalIgnoreCase)) + return title; + } + + return null; + } +} diff --git a/Journal.Core/Services/LogRedactor.cs b/Journal.Core/Services/LogRedactor.cs new file mode 100644 index 0000000..4554174 --- /dev/null +++ b/Journal.Core/Services/LogRedactor.cs @@ -0,0 +1,73 @@ +using System.Text.Json; + +namespace Journal.Core.Services; + +public static class LogRedactor +{ + private static readonly HashSet SensitiveKeys = new(StringComparer.OrdinalIgnoreCase) + { + "password", + "passphrase", + "secret", + "token", + "apiKey", + "api_key", + "cloudAiApiKey", + "content", + "rawContent", + "prompt", + "audioBase64", + "audio_base64", + "text" + }; + + public static object? RedactPayload(JsonElement? payload) + { + if (payload is null) + return null; + return RedactElement(payload.Value, parentKey: null); + } + + private static object? RedactElement(JsonElement element, string? parentKey) + { + if (parentKey is not null && SensitiveKeys.Contains(parentKey)) + return "[REDACTED]"; + + return element.ValueKind switch + { + JsonValueKind.Object => RedactObject(element), + JsonValueKind.Array => RedactArray(element), + JsonValueKind.String => RedactString(element.GetString() ?? "", parentKey), + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + } + + private static Dictionary RedactObject(JsonElement element) + { + var output = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in element.EnumerateObject()) + output[property.Name] = RedactElement(property.Value, property.Name); + return output; + } + + private static List RedactArray(JsonElement element) + { + var output = new List(); + foreach (var item in element.EnumerateArray()) + output.Add(RedactElement(item, parentKey: null)); + return output; + } + + private static object RedactString(string value, string? key) + { + if (key is not null && SensitiveKeys.Contains(key)) + return "[REDACTED]"; + if (value.Length <= 128) + return value; + return $"{value[..128]}...(truncated)"; + } +} diff --git a/Journal.Core/Services/PythonSidecarAiService.cs b/Journal.Core/Services/PythonSidecarAiService.cs new file mode 100644 index 0000000..767b82a --- /dev/null +++ b/Journal.Core/Services/PythonSidecarAiService.cs @@ -0,0 +1,190 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Models; + +namespace Journal.Core.Services; + +public sealed class PythonSidecarAiService : IAiService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly JournalConfig _config; + + public PythonSidecarAiService(JournalConfig config) + { + _config = config; + if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath)) + throw new ArgumentException("Python AI sidecar path is required."); + if (!File.Exists(_config.PythonAiSidecarPath)) + throw new FileNotFoundException($"Python AI sidecar not found: {_config.PythonAiSidecarPath}"); + } + + public async Task 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 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 SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default) + { + entries ??= []; + var data = await SendAsync("summarize_all", new { entries }, cancellationToken); + return data?.GetString() ?? ""; + } + + public async Task 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> 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(); + 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 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. + } + } +} diff --git a/Journal.Core/Services/PythonSidecarSpeechService.cs b/Journal.Core/Services/PythonSidecarSpeechService.cs new file mode 100644 index 0000000..582d1cf --- /dev/null +++ b/Journal.Core/Services/PythonSidecarSpeechService.cs @@ -0,0 +1,184 @@ +using System.Diagnostics; +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Models; + +namespace Journal.Core.Services; + +public sealed class PythonSidecarSpeechService : ISpeechBridgeService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly JournalConfig _config; + + public PythonSidecarSpeechService(JournalConfig config) + { + _config = config; + if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath)) + throw new ArgumentException("Python sidecar path is required."); + if (!File.Exists(_config.PythonAiSidecarPath)) + throw new FileNotFoundException($"Python sidecar not found: {_config.PythonAiSidecarPath}"); + } + + public async Task 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(); + 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 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 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. + } + } +} diff --git a/Journal.Core/Services/SidecarCli.cs b/Journal.Core/Services/SidecarCli.cs new file mode 100644 index 0000000..86cf444 --- /dev/null +++ b/Journal.Core/Services/SidecarCli.cs @@ -0,0 +1,385 @@ +using System.Text; +using Journal.Core.Dtos; + +namespace Journal.Core.Services; + +public sealed class SidecarCli +{ + private readonly IVaultStorageService _vaultStorage; + private readonly IEntrySearchService _entrySearch; + private readonly IJournalConfigService _config; + + public SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config) + { + _vaultStorage = vaultStorage; + _entrySearch = entrySearch; + _config = config; + } + + public async Task RunAsync(string[] args, Entry entry) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(entry); + + if (args.Length == 0) + { + await entry.RunAsync(); + return 0; + } + + if (IsHelp(args[0])) + { + PrintUsage(); + return 0; + } + + if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase)) + return RunVaultCommand(args.Skip(1).ToArray()); + if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase)) + return RunSearchCommand(args.Skip(1).ToArray()); + + Console.Error.WriteLine($"Unknown command: {args[0]}"); + PrintUsage(); + return 2; + } + + public int RunVaultCommand(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (args.Length == 0 || IsHelp(args[0])) + { + PrintVaultUsage(); + return 2; + } + + var action = args[0].Trim().ToLowerInvariant(); + if (action is not ("load" or "save")) + { + Console.Error.WriteLine($"Unknown vault action: {args[0]}"); + PrintVaultUsage(); + return 2; + } + + if (!TryParseVaultOptions(args.Skip(1).ToArray(), out var options, out var parseError)) + { + Console.Error.WriteLine(parseError); + PrintVaultUsage(); + return 2; + } + + var password = options.Password; + if (string.IsNullOrWhiteSpace(password)) + password = PromptPassword(); + + if (string.IsNullOrWhiteSpace(password)) + { + Console.Error.WriteLine("Vault password cannot be empty."); + return 2; + } + + var (vaultDirectory, dataDirectory) = ResolveDirectories(options.VaultDirectory, options.DataDirectory); + + try + { + if (action == "load") + { + var ok = _vaultStorage.LoadAllVaults(password, vaultDirectory, dataDirectory); + if (!ok) + { + Console.Error.WriteLine("Incorrect password."); + return 1; + } + + Console.WriteLine($"Vault loaded. Decrypted files are in {dataDirectory}"); + return 0; + } + + _vaultStorage.RebuildAllVaults(password, vaultDirectory, dataDirectory); + Console.WriteLine($"Vault saved from decrypted files in {dataDirectory}"); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Vault command failed: {ex.Message}"); + return 1; + } + } + + public int RunSearchCommand(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (args.Length > 0 && IsHelp(args[0])) + { + PrintSearchUsage(); + return 0; + } + + if (!TryParseSearchOptions(args, out var options, out var parseError)) + { + Console.Error.WriteLine(parseError); + PrintSearchUsage(); + return 2; + } + + var (_, dataDirectory) = ResolveDirectories(vaultOverride: null, options.DataDirectory); + if (!Directory.Exists(dataDirectory) || Directory.GetFiles(dataDirectory, "*.md").Length == 0) + { + Console.WriteLine("No decrypted journal entries found. Please load the vault first: journal vault load"); + return 0; + } + + try + { + var request = new EntrySearchRequestDto( + DataDirectory: dataDirectory, + Query: options.Query, + Section: options.Section, + StartDate: options.StartDate, + EndDate: options.EndDate, + Tags: options.Tags, + Types: options.Types, + Checked: options.Checked, + Unchecked: options.Unchecked); + + var results = _entrySearch.SearchEntriesAsync(request).GetAwaiter().GetResult(); + if (results.Count == 0) + { + Console.WriteLine("No entries found matching the criteria."); + return 0; + } + + foreach (var result in results) + { + Console.WriteLine($"--- {result.Date} ---"); + Console.WriteLine(result.RawContent); + Console.WriteLine(); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Search command failed: {ex.Message}"); + return 1; + } + } + + private static bool TryParseVaultOptions(string[] args, out VaultOptions options, out string error) + { + var parsed = new VaultOptions(); + for (var i = 0; i < args.Length; i++) + { + var token = args[i]; + if (IsHelp(token)) + { + options = parsed; + error = ""; + return false; + } + + if (i + 1 >= args.Length) + { + options = parsed; + error = $"Missing value for option '{token}'."; + return false; + } + + var value = args[i + 1]; + switch (token) + { + case "--password": + case "-p": + parsed.Password = value; + break; + case "--vault-dir": + parsed.VaultDirectory = value; + break; + case "--data-dir": + parsed.DataDirectory = value; + break; + default: + options = parsed; + error = $"Unknown option '{token}'."; + return false; + } + + i++; + } + + options = parsed; + error = ""; + return true; + } + + private static bool TryParseSearchOptions(string[] args, out SearchOptions options, out string error) + { + var parsed = new SearchOptions(); + for (var i = 0; i < args.Length; i++) + { + var token = args[i]; + if (IsHelp(token)) + { + options = parsed; + error = ""; + return false; + } + + if (!token.StartsWith("-", StringComparison.Ordinal)) + { + if (parsed.Query is null) + { + parsed.Query = token; + continue; + } + + options = parsed; + error = $"Unexpected positional argument '{token}'."; + return false; + } + + if (i + 1 >= args.Length) + { + options = parsed; + error = $"Missing value for option '{token}'."; + return false; + } + + var value = args[i + 1]; + switch (token) + { + case "--data-dir": + parsed.DataDirectory = value; + break; + case "--tag": + case "-t": + parsed.Tags.Add(value); + break; + case "--type": + case "-y": + parsed.Types.Add(value); + break; + case "--start-date": + case "-s": + parsed.StartDate = value; + break; + case "--end-date": + case "-e": + parsed.EndDate = value; + break; + case "--section": + case "-sec": + parsed.Section = value; + break; + case "--checked": + case "-chk": + parsed.Checked.Add(value); + break; + case "--unchecked": + case "-uchk": + parsed.Unchecked.Add(value); + break; + default: + options = parsed; + error = $"Unknown option '{token}'."; + return false; + } + + i++; + } + + options = parsed; + error = ""; + return true; + } + + private (string VaultDirectory, string DataDirectory) ResolveDirectories(string? vaultOverride, string? dataOverride) + { + var envVault = Environment.GetEnvironmentVariable("JOURNAL_VAULT_DIR"); + var envData = Environment.GetEnvironmentVariable("JOURNAL_DATA_DIR"); + var defaults = _config.Current; + + var vault = FirstNonEmpty(vaultOverride, envVault) ?? defaults.VaultDirectory; + var data = FirstNonEmpty(dataOverride, envData) ?? defaults.DataDirectory; + return (Path.GetFullPath(vault), Path.GetFullPath(data)); + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + + private static string PromptPassword() + { + if (Console.IsInputRedirected) + return Console.ReadLine() ?? ""; + + Console.Write("Vault password: "); + var builder = new StringBuilder(); + while (true) + { + var keyInfo = Console.ReadKey(intercept: true); + if (keyInfo.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + + if (keyInfo.Key == ConsoleKey.Backspace) + { + if (builder.Length > 0) + builder.Length--; + continue; + } + + if (!char.IsControl(keyInfo.KeyChar)) + builder.Append(keyInfo.KeyChar); + } + + return builder.ToString(); + } + + private static bool IsHelp(string token) => + string.Equals(token, "--help", StringComparison.OrdinalIgnoreCase) || + string.Equals(token, "-h", StringComparison.OrdinalIgnoreCase) || + string.Equals(token, "help", StringComparison.OrdinalIgnoreCase); + + private static void PrintUsage() + { + Console.WriteLine("Usage:"); + Console.WriteLine(" Journal.Sidecar # sidecar stdin/stdout mode"); + Console.WriteLine(" Journal.Sidecar vault load [--password ] [--vault-dir ] [--data-dir ]"); + Console.WriteLine(" Journal.Sidecar vault save [--password ] [--vault-dir ] [--data-dir ]"); + Console.WriteLine(" Journal.Sidecar search [query] [--tag ] [--type ] [--start-date ] [--end-date ] [--section ] [--checked <text>] [--unchecked <text>] [--data-dir <path>]"); + } + + private static void PrintVaultUsage() + { + Console.WriteLine("Vault usage:"); + Console.WriteLine(" Journal.Sidecar vault load [--password <value>] [--vault-dir <path>] [--data-dir <path>]"); + Console.WriteLine(" Journal.Sidecar vault save [--password <value>] [--vault-dir <path>] [--data-dir <path>]"); + } + + private static void PrintSearchUsage() + { + Console.WriteLine("Search usage:"); + Console.WriteLine(" Journal.Sidecar search [query] [--tag <value>] [--type <value>] [--start-date <yyyy-MM-dd>] [--end-date <yyyy-MM-dd>] [--section <title>] [--checked <text>] [--unchecked <text>] [--data-dir <path>]"); + } + + private sealed class VaultOptions + { + public string? Password { get; set; } + public string? VaultDirectory { get; set; } + public string? DataDirectory { get; set; } + } + + private sealed class SearchOptions + { + public string? Query { get; set; } + public string? DataDirectory { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } + public string? Section { get; set; } + public List<string> Tags { get; } = []; + public List<string> Types { get; } = []; + public List<string> Checked { get; } = []; + public List<string> Unchecked { get; } = []; + } +} diff --git a/Journal.Core/Services/VaultCryptoService.cs b/Journal.Core/Services/VaultCryptoService.cs new file mode 100644 index 0000000..e6c7df7 --- /dev/null +++ b/Journal.Core/Services/VaultCryptoService.cs @@ -0,0 +1,83 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Journal.Core.Services; + +public class VaultCryptoService : IVaultCryptoService +{ + public const int SaltSize = 16; + public const int KeySize = 32; + public const int NonceSize = 12; + public const int TagSize = 16; + public const int Iterations = 600_000; + + public byte[] DeriveKey(string password, byte[] salt) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + ArgumentNullException.ThrowIfNull(salt); + if (salt.Length != SaltSize) + throw new ArgumentException($"Salt must be {SaltSize} bytes.", nameof(salt)); + + return Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + salt, + Iterations, + HashAlgorithmName.SHA256, + KeySize); + } + + public byte[] EncryptData(byte[] data, string password) + { + ArgumentNullException.ThrowIfNull(data); + + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + return EncryptData(data, password, salt, nonce); + } + + public byte[] DecryptData(byte[] encryptedData, string password) + { + ArgumentNullException.ThrowIfNull(encryptedData); + + var minLength = SaltSize + NonceSize + TagSize; + if (encryptedData.Length < minLength) + throw new ArgumentException("Encrypted payload is too short.", nameof(encryptedData)); + + var salt = encryptedData.AsSpan(0, SaltSize).ToArray(); + var nonce = encryptedData.AsSpan(SaltSize, NonceSize).ToArray(); + var tag = encryptedData.AsSpan(SaltSize + NonceSize, TagSize).ToArray(); + var ciphertext = encryptedData.AsSpan(SaltSize + NonceSize + TagSize).ToArray(); + + var key = DeriveKey(password, salt); + var plaintext = new byte[ciphertext.Length]; + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + return plaintext; + } + + public byte[] EncryptData(byte[] data, string password, byte[] salt, byte[] nonce) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(salt); + ArgumentNullException.ThrowIfNull(nonce); + if (salt.Length != SaltSize) + throw new ArgumentException($"Salt must be {SaltSize} bytes.", nameof(salt)); + if (nonce.Length != NonceSize) + throw new ArgumentException($"Nonce must be {NonceSize} bytes.", nameof(nonce)); + + var key = DeriveKey(password, salt); + var ciphertext = new byte[data.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(key, TagSize); + aes.Encrypt(nonce, data, ciphertext, tag); + + var payload = new byte[SaltSize + NonceSize + TagSize + ciphertext.Length]; + Buffer.BlockCopy(salt, 0, payload, 0, SaltSize); + Buffer.BlockCopy(nonce, 0, payload, SaltSize, NonceSize); + Buffer.BlockCopy(tag, 0, payload, SaltSize + NonceSize, TagSize); + Buffer.BlockCopy(ciphertext, 0, payload, SaltSize + NonceSize + TagSize, ciphertext.Length); + return payload; + } +} diff --git a/Journal.Core/Services/VaultStorageService.cs b/Journal.Core/Services/VaultStorageService.cs new file mode 100644 index 0000000..2ccf253 --- /dev/null +++ b/Journal.Core/Services/VaultStorageService.cs @@ -0,0 +1,276 @@ +using System.IO.Compression; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Threading; + +namespace Journal.Core.Services; + +public class VaultStorageService : IVaultStorageService +{ + private readonly IVaultCryptoService _crypto; + private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal); + private readonly object _vaultIoLock = new(); + + public VaultStorageService(IVaultCryptoService crypto) => _crypto = crypto; + + public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault"; + + public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory) + { + EnsureRequiredArguments(password, vaultDirectory, dataDirectory); + + lock (_vaultIoLock) + { + _monthFingerprintCache.Clear(); + PrepareDataDirectory(dataDirectory); + + if (!Directory.Exists(vaultDirectory)) + return true; + + var vaultFiles = Directory.GetFiles(vaultDirectory, "*.vault") + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + .ToArray(); + if (vaultFiles.Length == 0) + return true; + + var anyDecrypted = false; + var anyVaultFiles = false; + foreach (var vaultFile in vaultFiles) + { + var fileName = Path.GetFileName(vaultFile); + if (string.Equals(fileName, "_init_vault.vault", StringComparison.OrdinalIgnoreCase)) + { + try + { + File.Delete(vaultFile); + } + catch + { + // Legacy file cleanup should never block loading. + } + continue; + } + + anyVaultFiles = true; + try + { + var encrypted = File.ReadAllBytes(vaultFile); + var decryptedZip = _crypto.DecryptData(encrypted, password); + ExtractZipContent(decryptedZip, dataDirectory); + anyDecrypted = true; + } + catch (CryptographicException) + { + // Wrong password for this vault file; continue trying others. + } + catch + { + // Non-password vault read/decrypt/extract error; continue loading others. + } + } + + if (!anyDecrypted && anyVaultFiles) + return false; + + return true; + } + } + + public bool SaveCurrentMonthVault(string password, string vaultDirectory, string dataDirectory, DateTime now) + { + EnsureRequiredArguments(password, vaultDirectory, dataDirectory); + + lock (_vaultIoLock) + { + Directory.CreateDirectory(vaultDirectory); + if (!Directory.Exists(dataDirectory)) + return false; + + var monthKey = now.ToString("yyyy-MM", CultureInfo.InvariantCulture); + var filesInMonth = Directory.GetFiles(dataDirectory, "*.md") + .Where(path => Path.GetFileNameWithoutExtension(path).StartsWith(monthKey, StringComparison.Ordinal)) + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + .ToList(); + + if (filesInMonth.Count == 0) + return false; + + var currentFingerprint = ComputeMonthFingerprint(filesInMonth); + if (_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) && + string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal)) + { + return false; + } + + SaveMonth(password, monthKey, filesInMonth, vaultDirectory); + return true; + } + } + + public void RebuildAllVaults(string password, string vaultDirectory, string dataDirectory) + { + EnsureRequiredArguments(password, vaultDirectory, dataDirectory); + + lock (_vaultIoLock) + { + Directory.CreateDirectory(vaultDirectory); + if (!Directory.Exists(dataDirectory)) + return; + + var monthlyFiles = new Dictionary<string, List<string>>(StringComparer.Ordinal); + foreach (var filePath in Directory.GetFiles(dataDirectory, "*.md")) + { + var stem = Path.GetFileNameWithoutExtension(filePath); + if (!DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fileDate)) + continue; + + var monthKey = fileDate.ToString("yyyy-MM", CultureInfo.InvariantCulture); + if (!monthlyFiles.TryGetValue(monthKey, out var files)) + { + files = []; + monthlyFiles[monthKey] = files; + } + + files.Add(filePath); + } + + foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + SaveMonth(password, monthKey, filesInMonth, vaultDirectory); + } + } + + public void ClearDataDirectory(string dataDirectory) + { + if (string.IsNullOrWhiteSpace(dataDirectory)) + throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); + + lock (_vaultIoLock) + { + PrepareDataDirectory(dataDirectory); + _monthFingerprintCache.Clear(); + } + } + + private static void PrepareDataDirectory(string dataDirectory) + { + DeleteDirectoryWithRetries(dataDirectory); + Directory.CreateDirectory(dataDirectory); + } + + private static void DeleteDirectoryWithRetries(string dataDirectory, int retries = 5, int delayMs = 200) + { + if (!Directory.Exists(dataDirectory)) + return; + + for (var attempt = 0; attempt < retries; attempt++) + { + try + { + Directory.Delete(dataDirectory, recursive: true); + return; + } + catch (IOException) when (attempt < retries - 1) + { + Thread.Sleep(delayMs); + } + catch (UnauthorizedAccessException) when (attempt < retries - 1) + { + Thread.Sleep(delayMs); + } + } + + // Final attempt should throw with the underlying exception if deletion still fails. + Directory.Delete(dataDirectory, recursive: true); + } + + private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + if (string.IsNullOrWhiteSpace(vaultDirectory)) + throw new ArgumentException("Vault directory is required.", nameof(vaultDirectory)); + if (string.IsNullOrWhiteSpace(dataDirectory)) + throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); + } + + private void SaveMonth(string password, string monthKey, List<string> filesInMonth, string vaultDirectory) + { + var monthDate = DateTime.ParseExact(monthKey, "yyyy-MM", CultureInfo.InvariantCulture); + var monthlyVaultPath = Path.Combine(vaultDirectory, GetMonthlyVaultFileName(monthDate)); + + var zipBytes = CreateMonthlyArchive(filesInMonth); + var encryptedPayload = _crypto.EncryptData(zipBytes, password); + File.WriteAllBytes(monthlyVaultPath, encryptedPayload); + + _monthFingerprintCache[monthKey] = ComputeMonthFingerprint(filesInMonth); + } + + private static byte[] CreateMonthlyArchive(List<string> filesInMonth) + { + using var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var filePath in filesInMonth.OrderBy(Path.GetFileName, StringComparer.Ordinal)) + { + var fileName = Path.GetFileName(filePath); + if (string.IsNullOrWhiteSpace(fileName)) + continue; + + var entry = archive.CreateEntry(fileName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + using var sourceStream = File.OpenRead(filePath); + sourceStream.CopyTo(entryStream); + } + } + + return memoryStream.ToArray(); + } + + private static string ComputeMonthFingerprint(List<string> files) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + foreach (var filePath in files.OrderBy(Path.GetFileName, StringComparer.Ordinal)) + { + var fileInfo = new FileInfo(filePath); + if (!fileInfo.Exists) + continue; + + AppendUtf8(hash, fileInfo.Name); + AppendAscii(hash, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)); + AppendAscii(hash, fileInfo.Length.ToString(CultureInfo.InvariantCulture)); + } + + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static void AppendUtf8(IncrementalHash hash, string value) => hash.AppendData(Encoding.UTF8.GetBytes(value)); + private static void AppendAscii(IncrementalHash hash, string value) => hash.AppendData(Encoding.ASCII.GetBytes(value)); + + private static void ExtractZipContent(byte[] zipBytes, string dataDirectory) + { + using var stream = new MemoryStream(zipBytes); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var dataRoot = Path.GetFullPath(dataDirectory); + if (!dataRoot.EndsWith(Path.DirectorySeparatorChar)) + dataRoot += Path.DirectorySeparatorChar; + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + continue; + + var destinationPath = Path.GetFullPath(Path.Combine(dataDirectory, entry.FullName)); + if (!destinationPath.StartsWith(dataRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Zip entry path escapes target data directory."); + + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDir)) + Directory.CreateDirectory(destinationDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + } + } +} diff --git a/Journal.Sidecar/App.cs b/Journal.Sidecar/App.cs index c62cbd5..cb0c2f1 100644 --- a/Journal.Sidecar/App.cs +++ b/Journal.Sidecar/App.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Journal.Core; +using Journal.Core.Services; var services = new ServiceCollection(); services.AddFragmentServices(); @@ -7,4 +8,6 @@ services.AddSingleton<Entry>(); var provider = services.BuildServiceProvider(); var entry = provider.GetRequiredService<Entry>(); -await entry.RunAsync(); \ No newline at end of file +var cli = provider.GetRequiredService<SidecarCli>(); +var exitCode = await cli.RunAsync(args, entry); +Environment.ExitCode = exitCode; diff --git a/Journal.SmokeTests/Fixtures/transport_cases.json b/Journal.SmokeTests/Fixtures/transport_cases.json new file mode 100644 index 0000000..b6c84dd --- /dev/null +++ b/Journal.SmokeTests/Fixtures/transport_cases.json @@ -0,0 +1,50 @@ +[ + { + "name": "List returns array envelope", + "request": "{\"action\":\"fragments.list\"}", + "expectOk": true, + "dataKind": "array" + }, + { + "name": "Create returns object envelope", + "request": "{\"action\":\"fragments.create\",\"payload\":{\"type\":\"!NOTE\",\"description\":\"fixture create\"}}", + "expectOk": true, + "dataKind": "object" + }, + { + "name": "Get missing id returns null data", + "request": "{\"action\":\"fragments.get\",\"id\":\"00000000-0000-0000-0000-000000000001\"}", + "expectOk": true, + "dataKind": "null" + }, + { + "name": "Create missing payload fails", + "request": "{\"action\":\"fragments.create\"}", + "expectOk": false, + "errorContains": "payload" + }, + { + "name": "AI health returns object envelope", + "request": "{\"action\":\"ai.health\"}", + "expectOk": true, + "dataKind": "object" + }, + { + "name": "AI summarize entry returns string envelope", + "request": "{\"action\":\"ai.summarize_entry\",\"payload\":{\"content\":\"transport test\"}}", + "expectOk": true, + "dataKind": "string" + }, + { + "name": "Unknown action fails", + "request": "{\"action\":\"unknown.action\"}", + "expectOk": false, + "errorContains": "Unknown action" + }, + { + "name": "Malformed JSON fails", + "request": "{\"action\":\"fragments.list\"", + "expectOk": false, + "errorContains": "Invalid command JSON" + } +] diff --git a/Journal.SmokeTests/Journal.SmokeTests.csproj b/Journal.SmokeTests/Journal.SmokeTests.csproj new file mode 100644 index 0000000..a81680c --- /dev/null +++ b/Journal.SmokeTests/Journal.SmokeTests.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\Journal.Core\Journal.Core.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="Fixtures\*.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + +</Project> diff --git a/Journal.SmokeTests/Program.cs b/Journal.SmokeTests/Program.cs new file mode 100644 index 0000000..55eeddf --- /dev/null +++ b/Journal.SmokeTests/Program.cs @@ -0,0 +1,2129 @@ +using System.ComponentModel.DataAnnotations; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; +using Journal.Core; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; +using Journal.Core.Services; + +var tests = new List<(string Name, Func<Task> Run)> +{ + ("CreateAsync trims fields", TestCreateTrimsAsync), + ("UpdateAsync accepts valid type updates", TestUpdateAcceptsTypeAsync), + ("UpdateAsync rejects whitespace type", TestUpdateRejectsWhitespaceTypeAsync), + ("JournalEntry model stores parity fields", TestJournalEntryModelAsync), + ("MergeWith overwrites section when new content is meaningful", TestMergeOverwritesMeaningfulSectionAsync), + ("MergeWith ignores whitespace-only section updates", TestMergeIgnoresWhitespaceOnlySectionAsync), + ("MergeWith appends non-duplicate fragments by description", TestMergeAppendsNonDuplicateFragmentsAsync), + ("ToMarkdown writes canonical section order", TestToMarkdownCanonicalSectionOrderAsync), + ("ToMarkdown writes fragment blocks", TestToMarkdownFragmentFormattingAsync), + ("Vault crypto roundtrip preserves data and layout", TestVaultCryptoRoundtripAsync), + ("Vault crypto decrypts Python payload fixture", TestVaultCryptoDecryptsPythonFixtureAsync), + ("Vault key derivation matches Python fixture", TestVaultKeyDerivationMatchesPythonAsync), + ("Vault monthly filename matches parity format", TestVaultMonthlyFilenameParityAsync), + ("Vault load clears workspace and extracts decrypted files", TestVaultLoadClearsAndExtractsAsync), + ("Vault load wrong password does not modify vault files", TestVaultLoadWrongPasswordPreservesVaultAsync), + ("Vault load ignores and removes legacy _init_vault.vault", TestVaultLoadLegacyInitVaultHandlingAsync), + ("Vault current-month save writes only current month and skips unchanged state", TestVaultCurrentMonthSaveOptimizedAsync), + ("Vault rebuild saves grouped monthly archives from decrypted files", TestVaultRebuildAllVaultsAsync), + ("Vault clear data directory removes decrypted workspace artifacts", TestVaultClearDataDirectoryAsync), + ("Parser extracts date from **Date:** marker", TestParserExtractsBoldDateAsync), + ("Parser extracts date from Date: marker", TestParserExtractsPlainDateAsync), + ("Parser falls back to file stem when date missing", TestParserFallsBackToFileStemAsync), + ("Parser captures canonical sections and content", TestParserCapturesSectionsAsync), + ("Parser ignores non-canonical section headers", TestParserIgnoresNonCanonicalHeadersAsync), + ("Parser captures checkbox states per section", TestParserCapturesCheckboxStatesAsync), + ("Parser captures multiline fragment blocks", TestParserCapturesMultilineFragmentsAsync), + ("Parser fragment boundary follows header lines", TestParserFragmentBoundaryBehaviorAsync), + ("File repository persists fragments", TestFileRepositoryPersistsAsync), + ("Entry invalid JSON returns error envelope", TestEntryInvalidJsonAsync), + ("Entry unknown action returns error envelope", TestEntryUnknownActionAsync), + ("Entry get missing id returns ok with null data", TestEntryGetMissingReturnsNullDataAsync), + ("Entry create without payload returns error envelope", TestEntryCreateMissingPayloadAsync), + ("Entry entries.save writes and merges content", TestEntryEntriesSaveMergeAsync), + ("Entry entries.load returns raw content payload", TestEntryEntriesLoadAsync), + ("Entry entries.list returns markdown files", TestEntryEntriesListAsync), + ("Entry search.entries matches query against full raw content", TestEntrySearchEntriesMatchesRawContentAsync), + ("Entry search.entries without query returns all markdown entries", TestEntrySearchEntriesWithoutQueryReturnsAllAsync), + ("Entry search.entries applies date range filter", TestEntrySearchEntriesDateRangeFilterAsync), + ("Entry search.entries applies section-scoped query filter", TestEntrySearchEntriesSectionFilterAsync), + ("Entry search.entries applies fragment tag and type filters", TestEntrySearchEntriesTagTypeFilterAsync), + ("Entry search.entries applies checkbox checked and unchecked filters", TestEntrySearchEntriesCheckboxFilterAsync), + ("Entry search.entries rejects invalid date filter format", TestEntrySearchEntriesRejectsInvalidDateAsync), + ("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync), + ("Database schema parity tables are created", TestDatabaseSchemaParityAsync), + ("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync), + ("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), + ("Entry config.get returns config payload", TestEntryConfigGetAsync), + ("Log redactor scrubs sensitive payload fields", TestLogRedactorScrubsSensitiveFieldsAsync), + ("Log redactor preserves non-sensitive payload fields", TestLogRedactorPreservesNonSensitiveFieldsAsync), + ("Entry ai.health returns disabled by default", TestEntryAiHealthDefaultAsync), + ("Entry ai.summarize_entry succeeds when disabled", TestEntryAiSummarizeEntryDisabledAsync), + ("Entry ai.summarize_all succeeds when disabled", TestEntryAiSummarizeAllDisabledAsync), + ("Entry ai.chat succeeds when disabled", TestEntryAiChatDisabledAsync), + ("Entry ai.embed returns empty vector when disabled", TestEntryAiEmbedDisabledAsync), + ("Entry speech.devices.list returns envelope when disabled", TestEntrySpeechDevicesListDisabledAsync), + ("Entry speech.transcribe returns envelope when disabled", TestEntrySpeechTranscribeDisabledAsync), + ("Python sidecar AI service parses last JSON line", TestPythonSidecarAiServiceJsonLineAsync), + ("Python sidecar AI service surfaces sidecar errors", TestPythonSidecarAiServiceErrorAsync), + ("Python sidecar speech service handles empty devices payload", TestPythonSidecarSpeechServiceNoDevicesAsync), + ("Python sidecar speech service surfaces unavailable engine errors", TestPythonSidecarSpeechServiceErrorAsync), + ("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync), + ("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync), + ("Entry vault.clear_data_directory removes files", TestEntryVaultClearDataDirectoryAsync), + ("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync), + ("Sidecar vault CLI save writes monthly vault with --password", TestSidecarVaultCliSaveAsync), + ("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync), + ("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync), + ("Transport fixtures produce stable envelopes", TestTransportFixturesAsync), +}; + +var passed = 0; +foreach (var (name, run) in tests) +{ + try + { + await run(); + Console.WriteLine($"PASS {name}"); + passed++; + } + catch (Exception ex) + { + Console.WriteLine($"FAIL {name}: {ex.Message}"); + } +} + +Console.WriteLine($"Summary: {passed}/{tests.Count} passed."); +Environment.ExitCode = passed == tests.Count ? 0 : 1; + +static FragmentService NewService() +{ + IFragmentRepository repo = new InMemoryFragmentRepository(); + return new FragmentService(repo); +} + +static Entry NewEntry() => new( + NewService(), + new EntrySearchService(), + new VaultStorageService(new VaultCryptoService()), + new JournalDatabaseService(new JournalConfigService()), + new JournalConfigService(), + new DisabledAiService("none"), + new DisabledSpeechBridgeService("none")); + +static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService()); + +static async Task TestCreateTrimsAsync() +{ + var service = NewService(); + var created = await service.CreateAsync(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "])); + + Assert(created.Type == "!TRIGGER", "Type should be trimmed."); + Assert(created.Description == "stomach drop", "Description should be trimmed."); + Assert(created.Tags.Count == 2, "Expected two normalized tags."); + Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved."); +} + +static async Task TestUpdateAcceptsTypeAsync() +{ + var service = NewService(); + var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "one")); + var ok = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); + + Assert(ok, "Expected update to succeed."); + var updated = await service.GetByIdAsync(created.Id); + Assert(updated is not null, "Updated fragment should exist."); + Assert(updated!.Type == "!FLASHBACK", "Updated type 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."); +} + +static async Task TestUpdateRejectsWhitespaceTypeAsync() +{ + var service = NewService(); + var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "desc")); + + try + { + _ = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " ")); + } + catch (ValidationException) + { + return; + } + + throw new InvalidOperationException("Expected ValidationException for whitespace type update."); +} + +static async Task TestFileRepositoryPersistsAsync() +{ + var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")); + var storePath = Path.Combine(tempRoot, "fragments.json"); + + try + { + IFragmentRepository repo1 = new FileFragmentRepository(storePath); + var service1 = new FragmentService(repo1); + var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); + + IFragmentRepository repo2 = new FileFragmentRepository(storePath); + var service2 = new FragmentService(repo2); + var loaded = await service2.GetByIdAsync(created.Id); + + Assert(loaded is not null, "Expected fragment to persist across repository instances."); + Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch."); + Assert(loaded.Tags.Count == 1 && loaded.Tags[0] == "tag1", "Persisted tags mismatch."); + } + finally + { + if (Directory.Exists(tempRoot)) + Directory.Delete(tempRoot, recursive: true); + } +} + +static Task TestJournalEntryModelAsync() +{ + var fragment = new Fragment("!TRIGGER", "test fragment"); + var section = new ParsedSection( + "Summary", + content: ["line one", "- [x] completed thing"], + checkboxes: new Dictionary<string, bool> { ["completed thing"] = true }); + + var entry = new JournalEntry( + date: "2026-02-22", + fragments: [fragment], + rawContent: "raw markdown content", + sections: new Dictionary<string, ParsedSection> { ["Summary"] = section }); + + Assert(entry.Date == "2026-02-22", "JournalEntry date mismatch."); + Assert(entry.RawContent == "raw markdown content", "JournalEntry raw content mismatch."); + Assert(entry.Fragments.Count == 1, "JournalEntry fragment count mismatch."); + Assert(entry.Sections.Count == 1, "JournalEntry section count mismatch."); + Assert(entry.GetSection("Summary").Contains("line one"), "JournalEntry section content mismatch."); + Assert(entry.GetCheckboxState("Summary", "completed thing") is true, "JournalEntry checkbox state mismatch."); + Assert(entry.GetCheckboxState("Summary", "missing") is null, "JournalEntry checkbox should return null when missing."); + + return Task.CompletedTask; +} + +static Task TestMergeOverwritesMeaningfulSectionAsync() +{ + var current = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection("Summary", ["old content"]) + }); + + var incoming = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection( + "Summary", + [" ", "new content line"], + new Dictionary<string, bool> { ["new check"] = true }), + ["Reflection"] = new ParsedSection("Reflection", ["reflective note"]) + }); + + current.MergeWith(incoming); + + Assert(current.GetSection("Summary").Contains("new content line"), "Meaningful section update should overwrite existing section."); + Assert(!current.GetSection("Summary").Contains("old content"), "Old section content should be replaced."); + Assert(current.GetCheckboxState("Summary", "new check") is true, "Overwritten section checkbox state should come from incoming section."); + Assert(current.GetSection("Reflection").Contains("reflective note"), "Meaningful new section should be added."); + + return Task.CompletedTask; +} + +static Task TestMergeIgnoresWhitespaceOnlySectionAsync() +{ + var current = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection("Summary", ["keep existing"]) + }); + + var incoming = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection("Summary", [" ", "\t", ""]) + }); + + current.MergeWith(incoming); + + Assert(current.GetSection("Summary").Contains("keep existing"), "Whitespace-only section update should be ignored."); + + return Task.CompletedTask; +} + +static Task TestMergeAppendsNonDuplicateFragmentsAsync() +{ + var current = new JournalEntry( + date: "2026-02-22", + fragments: + [ + new Fragment("!TRIGGER", "duplicate description") + ]); + + var incoming = new JournalEntry( + date: "2026-02-22", + fragments: + [ + new Fragment("!NOTE", "duplicate description"), + new Fragment("!NOTE", "new description") + ]); + + current.MergeWith(incoming); + + Assert(current.Fragments.Count == 2, "Expected only one new fragment to be appended."); + Assert(current.Fragments.Count(fragment => fragment.Description == "duplicate description") == 1, "Duplicate description should not be appended."); + Assert(current.Fragments.Any(fragment => fragment.Description == "new description"), "New fragment description should be appended."); + + return Task.CompletedTask; +} + +static Task TestToMarkdownCanonicalSectionOrderAsync() +{ + var entry = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Reflection"] = new ParsedSection("Reflection", ["reflection body"]), + ["Summary"] = new ParsedSection("Summary", ["summary body"]) + }); + + var markdown = entry.ToMarkdown(); + var summaryIdx = markdown.IndexOf("## Summary", StringComparison.Ordinal); + var reflectionIdx = markdown.IndexOf("## Reflection", StringComparison.Ordinal); + + Assert(summaryIdx >= 0, "Summary header should be emitted."); + Assert(reflectionIdx >= 0, "Reflection header should be emitted."); + Assert(summaryIdx < reflectionIdx, "Sections should be emitted in canonical order."); + + return Task.CompletedTask; +} + +static Task TestToMarkdownFragmentFormattingAsync() +{ + var fragment = new Fragment("!TRIGGER", "fragment body") + { + Time = default, + Tags = ["stress", "body"] + }; + var entry = new JournalEntry( + date: "2026-02-22", + fragments: [fragment]); + + var markdown = entry.ToMarkdown(); + + Assert(markdown.Contains("# Fragments\n", StringComparison.Ordinal), "Fragments header should be present."); + Assert(markdown.Contains("!TRIGGER #stress #body\nfragment body\n", StringComparison.Ordinal), "Fragment block format should match parity shape."); + Assert(markdown.Contains("**Date:** 2026-02-22", StringComparison.Ordinal), "Date frontmatter line should be present."); + + return Task.CompletedTask; +} + +static Task TestVaultCryptoRoundtripAsync() +{ + var crypto = new VaultCryptoService(); + var plaintext = "sample vault payload"; + var payload = crypto.EncryptData(System.Text.Encoding.UTF8.GetBytes(plaintext), "vault-pass-123"); + + Assert(payload.Length == VaultCryptoService.SaltSize + VaultCryptoService.NonceSize + VaultCryptoService.TagSize + plaintext.Length, "Vault payload length should match salt+nonce+tag+ciphertext layout."); + var decrypted = crypto.DecryptData(payload, "vault-pass-123"); + Assert(System.Text.Encoding.UTF8.GetString(decrypted) == plaintext, "Vault roundtrip decrypt should return original plaintext."); + + return Task.CompletedTask; +} + +static Task TestVaultCryptoDecryptsPythonFixtureAsync() +{ + var crypto = new VaultCryptoService(); + + var payload = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODwABAgMEBQYHCAkKC6AErhDEMERBl7OFkG4L4oZ2JZckS0VzhxaZoVLckF7VXE+NIYXILsJ8f1I="); + var expectedPlaintext = Convert.FromBase64String("dmF1bHQgcGF5bG9hZCBleGFtcGxlCmxpbmUy"); + var decrypted = crypto.DecryptData(payload, "vault-pass-123"); + + Assert(decrypted.SequenceEqual(expectedPlaintext), "C# decrypt should match Python-generated payload plaintext."); + + return Task.CompletedTask; +} + +static Task TestVaultKeyDerivationMatchesPythonAsync() +{ + var crypto = new VaultCryptoService(); + var salt = Enumerable.Range(0, VaultCryptoService.SaltSize).Select(i => (byte)i).ToArray(); + var key = crypto.DeriveKey("vault-pass-123", salt); + var expectedKeyHex = "b29f523f28bf178f6815c6ca9ee2a588d79b3bd9a822c92a2f0dde5bc853bb52"; + var actualKeyHex = Convert.ToHexString(key).ToLowerInvariant(); + + Assert(actualKeyHex == expectedKeyHex, "Derived key should match Python PBKDF2 fixture key."); + + return Task.CompletedTask; +} + +static Task TestVaultMonthlyFilenameParityAsync() +{ + IVaultStorageService vaultStorage = new VaultStorageService(new VaultCryptoService()); + var name = vaultStorage.GetMonthlyVaultFileName(new DateTime(2026, 2, 7)); + Assert(name == "2026-02.vault", "Monthly vault filename must match yyyy-MM.vault format."); + return Task.CompletedTask; +} + +static Task TestVaultLoadClearsAndExtractsAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + File.WriteAllText(Path.Combine(dataDir, "old_file.md"), "stale"); + + var zipBytes = CreateZipBytes(new Dictionary<string, string> + { + ["2026-02-01.md"] = "hello from vault" + }); + var crypto = new VaultCryptoService(); + var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123"); + File.WriteAllBytes(Path.Combine(vaultDir, "2026-02.vault"), encrypted); + + IVaultStorageService storage = new VaultStorageService(crypto); + var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); + + Assert(ok, "Expected vault load success with correct password."); + Assert(!File.Exists(Path.Combine(dataDir, "old_file.md")), "Data directory should be cleared before extraction."); + var extractedPath = Path.Combine(dataDir, "2026-02-01.md"); + Assert(File.Exists(extractedPath), "Expected markdown file extracted from vault archive."); + Assert(File.ReadAllText(extractedPath) == "hello from vault", "Extracted file content mismatch."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestVaultLoadWrongPasswordPreservesVaultAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + var zipBytes = CreateZipBytes(new Dictionary<string, string> + { + ["2026-02-01.md"] = "hello from vault" + }); + var crypto = new VaultCryptoService(); + var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123"); + var vaultPath = Path.Combine(vaultDir, "2026-02.vault"); + File.WriteAllBytes(vaultPath, encrypted); + var before = File.ReadAllBytes(vaultPath); + + IVaultStorageService storage = new VaultStorageService(crypto); + var ok = storage.LoadAllVaults("wrong-password", vaultDir, dataDir); + var after = File.ReadAllBytes(vaultPath); + + Assert(!ok, "Expected vault load failure with wrong password."); + Assert(before.SequenceEqual(after), "Vault file bytes should remain unchanged on wrong password."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestVaultLoadLegacyInitVaultHandlingAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + var legacyPath = Path.Combine(vaultDir, "_init_vault.vault"); + File.WriteAllBytes(legacyPath, [1, 2, 3, 4]); + + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); + + Assert(ok, "Legacy-only vault directory should still be treated as successful load state."); + Assert(!File.Exists(legacyPath), "Legacy _init_vault.vault should be removed during load."); + Assert(Directory.Exists(dataDir), "Data directory should exist after load workflow."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestVaultCurrentMonthSaveOptimizedAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "feb one"); + File.WriteAllText(Path.Combine(dataDir, "2026-02-18.md"), "feb two"); + File.WriteAllText(Path.Combine(dataDir, "2026-01-31.md"), "jan one"); + + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + var now = new DateTime(2026, 2, 22, 12, 0, 0, DateTimeKind.Utc); + + var firstSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now); + Assert(firstSaved, "Expected first current-month save to write vault data."); + + var febVaultPath = Path.Combine(vaultDir, "2026-02.vault"); + var janVaultPath = Path.Combine(vaultDir, "2026-01.vault"); + Assert(File.Exists(febVaultPath), "Expected current-month vault file to be created."); + Assert(!File.Exists(janVaultPath), "Current-month save should not write non-current month vault files."); + + var entries = ReadVaultEntryTexts(febVaultPath, "vault-pass-123"); + Assert(entries.Count == 2, "Current-month vault should include only current-month markdown files."); + Assert(entries.ContainsKey("2026-02-01.md"), "Missing first current-month entry in vault archive."); + Assert(entries.ContainsKey("2026-02-18.md"), "Missing second current-month entry in vault archive."); + Assert(!entries.ContainsKey("2026-01-31.md"), "Current-month vault must not include previous-month files."); + + var beforeSkipBytes = File.ReadAllBytes(febVaultPath); + var secondSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now); + var afterSkipBytes = File.ReadAllBytes(febVaultPath); + Assert(!secondSaved, "Expected unchanged current-month save to skip write."); + Assert(beforeSkipBytes.SequenceEqual(afterSkipBytes), "Vault bytes should remain unchanged when save is skipped."); + + File.WriteAllText(Path.Combine(dataDir, "2026-02-18.md"), "feb two changed"); + var thirdSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now); + Assert(thirdSaved, "Expected save to run after current-month file change."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestVaultRebuildAllVaultsAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + File.WriteAllText(Path.Combine(dataDir, "2026-01-31.md"), "jan body"); + File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "feb body"); + File.WriteAllText(Path.Combine(dataDir, "not-a-journal.md"), "should be ignored"); + + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir); + + var janVaultPath = Path.Combine(vaultDir, "2026-01.vault"); + var febVaultPath = Path.Combine(vaultDir, "2026-02.vault"); + Assert(File.Exists(janVaultPath), "Expected January vault from rebuild flow."); + Assert(File.Exists(febVaultPath), "Expected February vault from rebuild flow."); + + var janEntries = ReadVaultEntryTexts(janVaultPath, "vault-pass-123"); + var febEntries = ReadVaultEntryTexts(febVaultPath, "vault-pass-123"); + + Assert(janEntries.Count == 1 && janEntries.ContainsKey("2026-01-31.md"), "January vault contents mismatch."); + Assert(febEntries.Count == 1 && febEntries.ContainsKey("2026-02-01.md"), "February vault contents mismatch."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestVaultClearDataDirectoryAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + Directory.CreateDirectory(Path.Combine(dataDir, "nested")); + + try + { + File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "decrypted content"); + File.WriteAllText(Path.Combine(dataDir, "journal_cache.db"), "cache"); + File.WriteAllText(Path.Combine(dataDir, "nested", "tmp.txt"), "temp"); + + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + storage.ClearDataDirectory(dataDir); + + Assert(Directory.Exists(dataDir), "Data directory should be recreated after cleanup."); + Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after cleanup."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Dictionary<string, string> ReadVaultEntryTexts(string vaultPath, string password) +{ + var crypto = new VaultCryptoService(); + var encrypted = File.ReadAllBytes(vaultPath); + var zipBytes = crypto.DecryptData(encrypted, password); + + using var stream = new MemoryStream(zipBytes); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var result = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + continue; + + using var reader = new StreamReader(entry.Open()); + result[entry.Name] = reader.ReadToEnd(); + } + + return result; +} + +static byte[] CreateZipBytes(Dictionary<string, string> files) +{ + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in files) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + } + return stream.ToArray(); +} + +static Task TestParserExtractsBoldDateAsync() +{ + var content = """ + --- + type: journal + --- + **Date:** 2026-02-22 + ## Summary + hello + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + Assert(entry.Date == "2026-02-22", "Parser should read date from **Date:** marker."); + return Task.CompletedTask; +} + +static Task TestParserExtractsPlainDateAsync() +{ + var content = """ + Date: 2026-02-23 + ## Summary + hello + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + Assert(entry.Date == "2026-02-23", "Parser should read date from Date: marker."); + return Task.CompletedTask; +} + +static Task TestParserFallsBackToFileStemAsync() +{ + var content = """ + ## Summary + no explicit date + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-24"); + Assert(entry.Date == "2026-02-24", "Parser should fall back to file stem when no date marker is present."); + return Task.CompletedTask; +} + +static Task TestParserCapturesSectionsAsync() +{ + var content = """ + Date: 2026-02-25 + ## Summary + line one + line two + ### Events / Triggers - Work + trigger line + ## reflection notes + anchor line + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.Sections.ContainsKey("Summary"), "Parser should capture Summary section."); + Assert(entry.Sections.ContainsKey("Events / Triggers"), "Parser should capture Events / Triggers section."); + Assert(entry.Sections.ContainsKey("Reflection"), "Parser should match canonical section title by substring."); + Assert(entry.GetSection("Summary").Contains("line one"), "Summary section content mismatch."); + Assert(entry.GetSection("Events / Triggers").Contains("trigger line"), "Events / Triggers section content mismatch."); + Assert(entry.GetSection("Reflection").Contains("anchor line"), "Reflection section content mismatch."); + + return Task.CompletedTask; +} + +static Task TestParserIgnoresNonCanonicalHeadersAsync() +{ + var content = """ + ## Summary + keep this + ## Totally Custom Header + should not be captured + ### Events / Triggers + keep this too + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.GetSection("Summary").Contains("keep this"), "Summary section should be captured."); + Assert(!entry.GetSection("Summary").Contains("should not be captured"), "Non-canonical section content should not bleed into previous section."); + Assert(entry.GetSection("Events / Triggers").Contains("keep this too"), "Canonical section after custom header should be captured."); + + return Task.CompletedTask; +} + +static Task TestParserCapturesCheckboxStatesAsync() +{ + var content = """ + ## Summary + - [x] took medication + - [ ] drank water + * [X] wrote reflection + ## Events / Triggers + - [ ] talked to manager + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.GetCheckboxState("Summary", "took medication") is true, "Expected checked state for '- [x]' checkbox."); + Assert(entry.GetCheckboxState("Summary", "drank water") is false, "Expected unchecked state for '- [ ]' checkbox."); + Assert(entry.GetCheckboxState("Summary", "wrote reflection") is true, "Expected checked state for '* [X]' checkbox."); + Assert(entry.GetCheckboxState("Events / Triggers", "talked to manager") is false, "Expected unchecked state in Events / Triggers section."); + Assert(entry.GetCheckboxState("Summary", "missing item") is null, "Missing checkbox text should return null."); + + return Task.CompletedTask; +} + +static Task TestParserCapturesMultilineFragmentsAsync() +{ + var content = """ + Date: 2026-02-26 + ## Summary + text + !TRIGGER @2026-02-26T10:15:00Z #stress #body + first line + second line + !NOTE #daily + short note + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.Fragments.Count == 2, "Expected two parsed fragments."); + Assert(entry.Fragments[0].Type == "!TRIGGER", "First fragment type mismatch."); + Assert(entry.Fragments[0].Description == "first line\nsecond line", "First fragment multiline description mismatch."); + Assert(entry.Fragments[0].Tags.Count == 2, "First fragment tag count mismatch."); + Assert(entry.Fragments[0].Tags[0] == "stress" && entry.Fragments[0].Tags[1] == "body", "First fragment tags mismatch."); + Assert(entry.Fragments[1].Type == "!NOTE", "Second fragment type mismatch."); + Assert(entry.Fragments[1].Description == "short note", "Second fragment description mismatch."); + Assert(entry.Fragments[1].Tags.Count == 1 && entry.Fragments[1].Tags[0] == "daily", "Second fragment tags mismatch."); + + return Task.CompletedTask; +} + +static Task TestParserFragmentBoundaryBehaviorAsync() +{ + var content = """ + !TRIGGER #a + line one + !NOTE this starts another fragment header + line two + """; + + var fragments = JournalParser.ParseFragments(content); + Assert(fragments.Count == 1, "Expected one parsed fragment because second boundary line is not a valid fragment header."); + Assert(fragments[0].Description == "line one", "First fragment boundary capture mismatch."); + return Task.CompletedTask; +} + +static async Task TestEntryUnknownActionAsync() +{ + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"unknown.action"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Unknown action"), "Expected unknown action error."); +} + +static async Task TestEntryInvalidJsonAsync() +{ + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("{\"action\":\"fragments.list\""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Invalid command JSON"), "Expected invalid JSON error."); +} + +static async Task TestEntryGetMissingReturnsNullDataAsync() +{ + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "fragments.get", + id = Guid.NewGuid().ToString(), + }); + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true."); + Assert(doc.RootElement.GetProperty("data").ValueKind == JsonValueKind.Null, "Expected data=null for missing fragment."); +} + +static async Task TestEntryCreateMissingPayloadAsync() +{ + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"fragments.create"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("payload", StringComparison.OrdinalIgnoreCase), "Expected payload validation error."); +} + +static async Task TestEntryEntriesSaveMergeAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var filePath = Path.Combine(root, "2026-02-22.md"); + File.WriteAllText(filePath, """ +Date: 2026-02-22 +## Summary +old summary text +"""); + + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + filePath, + mode = "Daily", + content = """ +Date: 2026-02-22 +## Summary +new summary text +## Reflection +new reflection text +""" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save."); + + var saved = File.ReadAllText(filePath); + Assert(saved.Contains("new summary text", StringComparison.Ordinal), "Expected merged file to contain new summary text."); + Assert(!saved.Contains("old summary text", StringComparison.Ordinal), "Expected merged file to replace old summary section."); + Assert(saved.Contains("new reflection text", StringComparison.Ordinal), "Expected merged file to contain new reflection section."); + + var fragmentSaveRequest = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + filePath, + mode = "Fragment", + content = "!NOTE\nfragment append text" + } + }); + + var fragmentResponse = await entry.HandleCommandAsync(fragmentSaveRequest); + using var fragmentDoc = JsonDocument.Parse(fragmentResponse); + Assert(fragmentDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save fragment mode."); + var appended = File.ReadAllText(filePath); + Assert(appended.Contains("fragment append text", StringComparison.Ordinal), "Expected fragment append text in saved file."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntryEntriesLoadAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var filePath = Path.Combine(root, "2026-02-22.md"); + var content = """ +Date: 2026-02-22 +## Summary +hello world +"""; + File.WriteAllText(filePath, content); + + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "entries.load", + payload = new + { + filePath + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.load."); + + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load."); + Assert(data.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load."); + Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntryEntriesListAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "c"); + File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "a"); + File.WriteAllText(Path.Combine(root, "ignore.txt"), "x"); + + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "entries.list", + payload = new + { + dataDirectory = root + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list."); + + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected entries.list data array."); + Assert(data.GetArrayLength() == 2, "Expected entries.list to return only markdown files."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Expected entries.list sort order by file name."); + Assert(data[1].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected entries.list sort order by file name."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntrySearchEntriesMatchesRawContentAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "## Summary\nAlpha line\ncommon token"); + File.WriteAllText(Path.Combine(root, "2026-02-02.md"), "## Summary\nbeta line\nCOMMON token"); + File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "## Summary\ngamma only"); + + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + query = "common token", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries."); + Assert(data.GetArrayLength() == 2, "Expected two entries matching query across raw content."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntrySearchEntriesWithoutQueryReturnsAllAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "one"); + File.WriteAllText(Path.Combine(root, "2026-02-02.md"), "two"); + File.WriteAllText(Path.Combine(root, "ignore.txt"), "not markdown"); + + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries without query."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries."); + Assert(data.GetArrayLength() == 2, "Expected all markdown files to be returned when query is omitted."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntrySearchEntriesDateRangeFilterAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + startDate = "2026-02-02", + endDate = "2026-02-28", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for date-range filtered search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 1, "Expected one result for filtered date range."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-05.md", "Date-range result mismatch."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntrySearchEntriesSectionFilterAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + query = "focus area", + section = "Reflection", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for section-scoped search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 1, "Expected one section-scoped result."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Section filter result mismatch."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntrySearchEntriesTagTypeFilterAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + tags = new[] { "stress" }, + types = new[] { "!TRIGGER" }, + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for fragment tag/type filtered search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 1, "Expected one result for fragment tag/type filters."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Tag/type filter result mismatch."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntrySearchEntriesCheckboxFilterAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + @checked = new[] { "med taken" }, + @unchecked = new[] { "drink water" }, + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for checkbox filtered search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 2, "Expected OR-style checkbox match across checked/unchecked filters."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntrySearchEntriesRejectsInvalidDateAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + startDate = "2026/02/01", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false for invalid date format."); + var error = doc.RootElement.GetProperty("error").GetString() ?? ""; + Assert(error.Contains("invalid startdate value", StringComparison.OrdinalIgnoreCase), "Expected invalid startDate error."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static void WriteSearchFixtureFiles(string root) +{ + File.WriteAllText(Path.Combine(root, "2026-02-01.md"), """ +Date: 2026-02-01 +## Summary +Alpha common +## Reflection +focus area +- [x] med taken +!TRIGGER #stress +fragment one +"""); + + File.WriteAllText(Path.Combine(root, "2026-02-05.md"), """ +Date: 2026-02-05 +## Summary +Beta common +## Reflection +other notes +- [ ] drink water +!NOTE #daily +fragment two +"""); + + File.WriteAllText(Path.Combine(root, "2026-03-01.md"), """ +Date: 2026-03-01 +## Summary +Gamma unique +## Reflection +nothing related +!NOTE #other +fragment three +"""); +} + +static Task TestDatabaseKeyDerivationMatchesPythonAsync() +{ + var service = NewDatabaseService(); + var keyHex = Convert.ToHexString(service.DeriveDatabaseKey("vault-pass-123")).ToLowerInvariant(); + var expected = "6a9de08e13357aa8f14e7eb0ccde119e7b4d277c60aaaca6493d9a1e1eaa5b04"; + Assert(keyHex == expected, "Database key derivation should match Python PBKDF2 fixture."); + return Task.CompletedTask; +} + +static Task TestDatabaseSchemaParityAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var service = NewDatabaseService(); + var schemaPath = service.WriteSchemaBootstrap(root); + var statements = service.GetSchemaStatements(); + var tableNames = new HashSet<string>(statements.Keys, StringComparer.OrdinalIgnoreCase); + + Assert(tableNames.Contains("entries"), "Schema should contain entries table."); + Assert(tableNames.Contains("sections"), "Schema should contain sections table."); + Assert(tableNames.Contains("fragments"), "Schema should contain fragments table."); + Assert(tableNames.Contains("tags"), "Schema should contain tags table."); + Assert(tableNames.Contains("fragment_tags"), "Schema should contain fragment_tags table."); + + Assert(File.Exists(schemaPath), "Schema bootstrap file should be written."); + var fragmentTagsSql = statements["fragment_tags"]; + Assert(fragmentTagsSql.Contains("PRIMARY KEY (fragment_id, tag_id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should enforce composite primary key parity."); + Assert(fragmentTagsSql.Contains("FOREIGN KEY (fragment_id) REFERENCES fragments (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain fragment foreign key parity."); + Assert(fragmentTagsSql.Contains("FOREIGN KEY (tag_id) REFERENCES tags (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain tag foreign key parity."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static async Task TestEntryDatabaseStatusAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "db.status", + payload = new + { + password = "vault-pass-123", + dataDirectory = root + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.status."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in db.status payload."); + Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath."); + Assert(data.TryGetProperty("KeyDerivation", out var keyDerivation), "Expected KeyDerivation in db.status payload."); + Assert(string.Equals(keyDerivation.GetString(), "PBKDF2-HMAC-SHA256", StringComparison.Ordinal), "Expected PBKDF2-HMAC-SHA256 key derivation."); + Assert(data.TryGetProperty("SchemaTables", out var schemaTables), "Expected SchemaTables list in db.status payload."); + Assert(schemaTables.ValueKind == JsonValueKind.Array && schemaTables.GetArrayLength() >= 5, "Expected schema table list in db.status payload."); + Assert(data.TryGetProperty("SchemaBootstrapPath", out var schemaBootstrapPath), "Expected SchemaBootstrapPath in db.status payload."); + 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."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntryDatabaseInitializeSchemaAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "db.initialize_schema", + payload = new + { + password = "vault-pass-123", + dataDirectory = root + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.initialize_schema."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.TryGetProperty("schemaPath", out var schemaPath), "Expected schemaPath in db.initialize_schema response."); + Assert(schemaPath.ValueKind == JsonValueKind.String, "Expected string schemaPath value."); + var resolvedPath = schemaPath.GetString() ?? ""; + Assert(File.Exists(resolvedPath), "db.initialize_schema should write schema bootstrap file."); + var schemaText = File.ReadAllText(resolvedPath); + Assert(schemaText.Contains("CREATE TABLE IF NOT EXISTS entries", StringComparison.OrdinalIgnoreCase), "schema bootstrap should include entries table."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntryDatabaseHydrateWorkspaceAsync() +{ + 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"); + File.WriteAllText(Path.Combine(root, "2026-02-21.md"), "two"); + + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "db.hydrate_workspace", + payload = new + { + password = "vault-pass-123", + dataDirectory = root + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.hydrate_workspace."); + var data = doc.RootElement.GetProperty("data"); + + Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in hydrate payload."); + Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath."); + Assert(data.TryGetProperty("SchemaBootstrapPath", out var schemaPath), "Expected SchemaBootstrapPath in hydrate payload."); + Assert(schemaPath.ValueKind == JsonValueKind.String && File.Exists(schemaPath.GetString()), "Expected hydrate to write schema bootstrap file."); + Assert(data.TryGetProperty("EntryFilesProcessed", out var filesProcessed), "Expected EntryFilesProcessed in hydrate payload."); + 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."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static Task TestConfigServiceParityKeysAsync() +{ + IJournalConfigService config = new JournalConfigService(); + var current = config.Current; + + Assert(!string.IsNullOrWhiteSpace(current.ProjectRoot), "Config ProjectRoot should not be empty."); + Assert(!string.IsNullOrWhiteSpace(current.AppDirectory), "Config AppDirectory should not be empty."); + Assert(!string.IsNullOrWhiteSpace(current.DataDirectory), "Config DataDirectory should not be empty."); + Assert(!string.IsNullOrWhiteSpace(current.VaultDirectory), "Config VaultDirectory should not be empty."); + Assert(current.MonthlyVaultFormat == "%Y-%m.vault", "Config MonthlyVaultFormat should match Python format token."); + + Assert(current.LlamaCppUrl == "http://127.0.0.1:8085/v1/completions", "Config LlamaCppUrl default mismatch."); + Assert(current.LlamaCppModel == "qwen/qwen3-4b", "Config LlamaCppModel default mismatch."); + Assert(current.EmbeddingApiUrl == "http://127.0.0.1:8086/v1/embeddings", "Config EmbeddingApiUrl default mismatch."); + Assert(current.SpeechRecognitionEngine == "whisper", "Config SpeechRecognitionEngine default mismatch."); + Assert(current.WhisperModelSize == "base", "Config WhisperModelSize default mismatch."); + Assert(current.AiProvider == "none", "Config AiProvider default mismatch."); + Assert(current.PythonExecutable == "python", "Config PythonExecutable default mismatch."); + Assert(current.AiSidecarTimeoutMs == 45000, "Config AiSidecarTimeoutMs default mismatch."); + Assert(current.PythonAiSidecarPath.EndsWith(Path.Combine("journal", "ai", "sidecar.py"), StringComparison.OrdinalIgnoreCase), "Config PythonAiSidecarPath default mismatch."); + + return Task.CompletedTask; +} + +static async Task TestEntryConfigGetAsync() +{ + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"config.get"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for config.get."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get."); + + Assert(data.TryGetProperty("DataDirectory", out var dataDirectory), "Expected DataDirectory in config payload."); + Assert(dataDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(dataDirectory.GetString()), "Expected non-empty DataDirectory value."); + Assert(data.TryGetProperty("MonthlyVaultFormat", out var monthlyVaultFormat), "Expected MonthlyVaultFormat in config payload."); + Assert(monthlyVaultFormat.GetString() == "%Y-%m.vault", "Expected Python-compatible MonthlyVaultFormat value."); + Assert(data.TryGetProperty("LlamaCppUrl", out _), "Expected LlamaCppUrl in config payload."); + Assert(data.TryGetProperty("SpeechRecognitionEngine", out _), "Expected SpeechRecognitionEngine in config payload."); +} + +static Task TestLogRedactorScrubsSensitiveFieldsAsync() +{ + var payload = JsonSerializer.SerializeToElement(new + { + password = "vault-pass-123", + content = "private journal body", + prompt = "private ai prompt", + nested = new + { + token = "abc123" + } + }); + + var redacted = LogRedactor.RedactPayload(payload); + var serialized = JsonSerializer.Serialize(redacted); + + Assert(!serialized.Contains("vault-pass-123", StringComparison.Ordinal), "Password should be redacted."); + Assert(!serialized.Contains("private journal body", StringComparison.Ordinal), "Entry content should be redacted."); + Assert(!serialized.Contains("private ai prompt", StringComparison.Ordinal), "Prompt should be redacted."); + Assert(!serialized.Contains("abc123", StringComparison.Ordinal), "Nested token should be redacted."); + Assert(serialized.Contains("[REDACTED]", StringComparison.Ordinal), "Redacted marker should be present."); + + return Task.CompletedTask; +} + +static Task TestLogRedactorPreservesNonSensitiveFieldsAsync() +{ + var payload = JsonSerializer.SerializeToElement(new + { + action = "entries.save", + mode = "Daily", + filePath = "E:/journal/2026-02-24.md" + }); + + var redacted = LogRedactor.RedactPayload(payload); + var serialized = JsonSerializer.Serialize(redacted); + + Assert(serialized.Contains("entries.save", StringComparison.Ordinal), "Non-sensitive action field should be preserved."); + Assert(serialized.Contains("Daily", StringComparison.Ordinal), "Non-sensitive mode field should be preserved."); + Assert(serialized.Contains("2026-02-24.md", StringComparison.Ordinal), "Non-sensitive path field should be preserved."); + + return Task.CompletedTask; +} + +static async Task TestEntryAiHealthDefaultAsync() +{ + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"ai.health"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for ai.health."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetProperty("Enabled").GetBoolean() is false, "Expected AI disabled by default."); + Assert(string.Equals(data.GetProperty("Provider").GetString(), "none", StringComparison.OrdinalIgnoreCase), "Expected default provider 'none'."); +} + +static async Task TestEntryAiSummarizeEntryDisabledAsync() +{ + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.summarize_entry", + payload = new + { + content = "sample entry" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_entry."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_entry."); +} + +static async Task TestEntryAiSummarizeAllDisabledAsync() +{ + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.summarize_all", + payload = new + { + entries = new[] { "entry one", "entry two" } + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_all."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_all."); +} + +static async Task TestEntryAiChatDisabledAsync() +{ + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.chat", + payload = new + { + prompt = "hello cloud" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.chat."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.chat."); +} + +static async Task TestEntryAiEmbedDisabledAsync() +{ + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.embed", + payload = new + { + content = "embedding source text" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.embed."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected ai.embed response to be a JSON array."); + Assert(data.GetArrayLength() == 0, "Expected disabled ai.embed to return an empty vector."); +} + +static async Task TestEntrySpeechDevicesListDisabledAsync() +{ + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "speech.devices.list", + payload = new { } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.devices.list when disabled."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.devices.list data to be an object."); +} + +static async Task TestEntrySpeechTranscribeDisabledAsync() +{ + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "speech.transcribe", + payload = new + { + text = "fixture transcript", + engine = "whisper" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.transcribe when disabled."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.transcribe data to be an object."); + var warning = data.TryGetProperty("Warning", out var warningNode) ? warningNode.GetString() ?? "" : ""; + Assert(warning.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled speech warning."); +} + +static async Task TestPythonSidecarAiServiceJsonLineAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_ai_sidecar.py"); + File.WriteAllText(scriptPath, """ +import json, sys +request = json.loads(sys.stdin.readline()) +action = request.get("action", "") +print("DEBUG prelude") +if action == "health": + print(json.dumps({"ok": True, "data": {"provider": "python-sidecar", "healthy": True, "message": "ok"}})) +elif action == "summarize_entry": + payload = request.get("payload") or {} + print(json.dumps({"ok": True, "data": "ENTRY::" + str(payload.get("content", ""))})) +elif action == "summarize_all": + payload = request.get("payload") or {} + entries = payload.get("entries") or [] + print(json.dumps({"ok": True, "data": "ALL::" + str(len(entries))})) +elif action == "chat": + payload = request.get("payload") or {} + print(json.dumps({"ok": True, "data": "CHAT::" + str(payload.get("prompt", ""))})) +elif action == "embed": + payload = request.get("payload") or {} + text = str(payload.get("content", "")) + print(json.dumps({"ok": True, "data": [float(len(text)), 2.5, -1.0]})) +else: + print(json.dumps({"ok": False, "error": "unknown action"})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + IAiService service = new PythonSidecarAiService(config); + + var health = await service.HealthAsync(); + Assert(health.Enabled, "Expected enabled=true for python-sidecar health."); + Assert(health.Healthy, "Expected healthy=true from fake sidecar health."); + + var one = await service.SummarizeEntryAsync("hello"); + Assert(one == "ENTRY::hello", "Unexpected summarize_entry response."); + + var all = await service.SummarizeAllAsync(["a", "b", "c"]); + Assert(all == "ALL::3", "Unexpected summarize_all response."); + + var chat = await service.ChatAsync("hello"); + Assert(chat == "CHAT::hello", "Unexpected chat response."); + + var vector = await service.EmbedAsync("hello"); + Assert(vector.Count == 3, "Unexpected embed vector length."); + Assert(Math.Abs(vector[0] - 5d) < 0.0001d, "Unexpected embed vector first value."); + Assert(Math.Abs(vector[1] - 2.5d) < 0.0001d, "Unexpected embed vector second value."); + Assert(Math.Abs(vector[2] + 1.0d) < 0.0001d, "Unexpected embed vector third value."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestPythonSidecarAiServiceErrorAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_ai_sidecar_error.py"); + File.WriteAllText(scriptPath, """ +import json, sys +_ = json.loads(sys.stdin.readline()) +print(json.dumps({"ok": False, "error": "simulated failure"})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + IAiService service = new PythonSidecarAiService(config); + + try + { + _ = await service.SummarizeEntryAsync("hello"); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("simulated failure", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException("Expected summarize_entry to surface sidecar error."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestPythonSidecarSpeechServiceNoDevicesAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_speech_sidecar_nodes.py"); + File.WriteAllText(scriptPath, """ +import json, sys +request = json.loads(sys.stdin.readline()) +action = request.get("action", "") +if action == "speech.devices.list": + print(json.dumps({"ok": True, "data": {"devices": [], "warning": "no devices"}})) +elif action == "speech.transcribe": + payload = request.get("payload") or {} + print(json.dumps({"ok": True, "data": {"text": payload.get("text", ""), "engine": payload.get("engine", "whisper")}})) +else: + print(json.dumps({"ok": False, "error": "unknown action"})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + ISpeechBridgeService service = new PythonSidecarSpeechService(config); + + var devices = await service.ListDevicesAsync(); + Assert(devices.Devices.Count == 0, "Expected empty devices list."); + Assert((devices.Warning ?? "").Contains("no devices", StringComparison.OrdinalIgnoreCase), "Expected no-devices warning."); + + var transcript = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture text", Engine: "whisper")); + Assert(transcript.Text == "fixture text", "Expected passthrough transcript text."); + Assert(transcript.Engine == "whisper", "Expected passthrough transcript engine."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestPythonSidecarSpeechServiceErrorAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_speech_sidecar_error.py"); + File.WriteAllText(scriptPath, """ +import json, sys +request = json.loads(sys.stdin.readline()) +action = request.get("action", "") +if action == "speech.transcribe": + print(json.dumps({"ok": False, "error": "engine unavailable"})) +else: + print(json.dumps({"ok": True, "data": {"devices": []}})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + ISpeechBridgeService service = new PythonSidecarSpeechService(config); + + try + { + _ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", Engine: "faster-whisper")); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("engine unavailable", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException("Expected speech transcribe to surface sidecar engine error."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestPythonSidecarSpeechServiceTimeoutAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_speech_sidecar_timeout.py"); + File.WriteAllText(scriptPath, """ +import json, sys, time +request = json.loads(sys.stdin.readline()) +payload = request.get("payload") or {} +sleep_ms = int(payload.get("simulate_delay_ms") or 0) +time.sleep(max(0, sleep_ms) / 1000.0) +print(json.dumps({"ok": True, "data": {"text": "", "engine": "whisper"}})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 100); + ISpeechBridgeService service = new PythonSidecarSpeechService(config); + + try + { + _ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", SimulateDelayMs: 500)); + } + catch (TimeoutException) + { + return; + } + + throw new InvalidOperationException("Expected speech transcribe timeout path."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntryVaultLoadAllEmptyAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + + try + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "vault.load_all", + payload = new + { + password = "vault-pass-123", + vaultDirectory = vaultDir, + dataDirectory = dataDir, + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for empty vault directory load."); + Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault directory."); + Assert(Directory.Exists(dataDir), "Expected data directory to be created by load workflow."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntryVaultClearDataDirectoryAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + File.WriteAllText(Path.Combine(dataDir, "tmp.md"), "x"); + + try + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "vault.clear_data_directory", + payload = new + { + dataDirectory = dataDir, + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for clear_data_directory."); + Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected clear_data_directory result=true."); + Assert(Directory.Exists(dataDir), "Expected data directory to exist after clear."); + Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Expected data directory to be empty after clear."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static Task TestSidecarVaultCliLoadAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + + try + { + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); + var exitCode = cli.RunVaultCommand(["load", "--password", "vault-pass-123", "--vault-dir", vaultDir, "--data-dir", dataDir]); + + Assert(exitCode == 0, "Expected vault load CLI command to succeed on empty vault directory."); + Assert(Directory.Exists(dataDir), "Expected data directory to be created by vault load CLI command."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestSidecarVaultCliSaveAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + File.WriteAllText(Path.Combine(dataDir, "2026-02-22.md"), "entry body"); + + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); + var exitCode = cli.RunVaultCommand(["save", "--password", "vault-pass-123", "--vault-dir", vaultDir, "--data-dir", dataDir]); + + Assert(exitCode == 0, "Expected vault save CLI command to succeed."); + Assert(File.Exists(Path.Combine(vaultDir, "2026-02.vault")), "Expected monthly vault file to be written by save CLI command."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestSidecarSearchCliFilteredAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + + try + { + WriteSearchFixtureFiles(dataDir); + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); + + var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand( + [ + "common", + "--data-dir", dataDir, + "--start-date", "2026-02-01", + "--end-date", "2026-02-28", + "--tag", "stress", + "--type", "!TRIGGER", + "--checked", "med taken", + "--section", "Summary" + ])); + + Assert(exitCode == 0, "Expected search CLI command to succeed."); + Assert(string.IsNullOrWhiteSpace(stderr), "Expected no stderr output for successful search CLI command."); + Assert(stdout.Contains("--- 2026-02-01 ---", StringComparison.Ordinal), "Expected matching entry header in search CLI output."); + Assert(!stdout.Contains("--- 2026-02-05 ---", StringComparison.Ordinal), "Unexpected non-matching entry in filtered search CLI output."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static Task TestSidecarSearchCliEmptyDataAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + + try + { + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); + var (exitCode, stdout, _) = CaptureConsole(() => cli.RunSearchCommand(["--data-dir", dataDir])); + + Assert(exitCode == 0, "Expected search CLI command to return success for empty data directory."); + Assert(stdout.Contains("No decrypted journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + +static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func<int> action) +{ + var originalOut = Console.Out; + var originalError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var exitCode = action(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } +} + +static JournalConfig BuildAiConfig(string sidecarScriptPath, int timeoutMs) +{ + var baseConfig = new JournalConfigService().Current; + var pythonExe = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE"); + if (string.IsNullOrWhiteSpace(pythonExe)) + pythonExe = "python"; + + return baseConfig with + { + AiProvider = "python-sidecar", + PythonExecutable = pythonExe, + PythonAiSidecarPath = sidecarScriptPath, + AiSidecarTimeoutMs = timeoutMs + }; +} + +static async Task TestTransportFixturesAsync() +{ + var fixtures = await LoadTransportFixturesAsync(); + Assert(fixtures.Count > 0, "Transport fixtures should not be empty."); + + foreach (var fixture in fixtures) + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync(fixture.Request); + + Assert(!response.Contains('\n') && !response.Contains('\r'), $"Fixture '{fixture.Name}' returned multiline output."); + + using var doc = JsonDocument.Parse(response); + var ok = doc.RootElement.GetProperty("ok").GetBoolean(); + Assert(ok == fixture.ExpectOk, $"Fixture '{fixture.Name}' expected ok={fixture.ExpectOk} but got ok={ok}."); + + if (fixture.ExpectOk) + { + Assert(doc.RootElement.TryGetProperty("data", out var data), $"Fixture '{fixture.Name}' expected data field."); + if (!string.IsNullOrWhiteSpace(fixture.DataKind)) + { + var expectedKind = ParseValueKind(fixture.DataKind!); + Assert(data.ValueKind == expectedKind, $"Fixture '{fixture.Name}' expected data kind {expectedKind} but got {data.ValueKind}."); + } + continue; + } + + Assert(doc.RootElement.TryGetProperty("error", out var error), $"Fixture '{fixture.Name}' expected error field."); + if (!string.IsNullOrWhiteSpace(fixture.ErrorContains)) + { + var message = error.GetString() ?? ""; + Assert(message.Contains(fixture.ErrorContains!, StringComparison.OrdinalIgnoreCase), $"Fixture '{fixture.Name}' expected error containing '{fixture.ErrorContains}'."); + } + } +} + +static async Task<List<TransportFixture>> LoadTransportFixturesAsync() +{ + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "transport_cases.json"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Transport fixture file not found: {path}"); + + var json = await File.ReadAllTextAsync(path); + return JsonSerializer.Deserialize<List<TransportFixture>>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? []; +} + +static JsonValueKind ParseValueKind(string value) => value.Trim().ToLowerInvariant() switch +{ + "array" => JsonValueKind.Array, + "object" => JsonValueKind.Object, + "null" => JsonValueKind.Null, + "string" => JsonValueKind.String, + "number" => JsonValueKind.Number, + "true" => JsonValueKind.True, + "false" => JsonValueKind.False, + _ => throw new InvalidOperationException($"Unsupported JsonValueKind '{value}' in transport fixture.") +}; + +static void Assert(bool condition, string message) +{ + if (!condition) + throw new InvalidOperationException(message); +} + +sealed class TransportFixture +{ + public string Name { get; init; } = ""; + public string Request { get; init; } = ""; + public bool ExpectOk { get; init; } + public string? DataKind { get; init; } + public string? ErrorContains { get; init; } +} diff --git a/README.md b/README.md index 1c3c62f..b07d408 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ 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 +│ │ ├── 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) @@ -17,10 +20,24 @@ backend/ │ │ └── UpdateFragmentDto Update (partial, all fields optional) │ ├── Repositories/ │ │ ├── IFragmentRepository.cs Interface (data access contract) -│ │ └── InMemoryFragmentRepository.cs In-memory implementation +│ │ ├── 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 +│ │ ├── 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 @@ -48,7 +65,7 @@ API (HTTP/JSON) ─────────┘ - **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. Swap `InMemoryFragmentRepository` for SQLite/EF Core later without touching anything above. +- **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. @@ -56,6 +73,7 @@ API (HTTP/JSON) ─────────┘ - **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 @@ -91,6 +109,52 @@ dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win- ## Sidecar Protocol The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, one JSON line out. +When run with no command-line args, this protocol mode is used by default. + +## Sidecar CLI + +`Journal.Sidecar` also supports direct vault and search CLI commands: + +```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 @@ -120,6 +184,24 @@ The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, o | `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 @@ -142,8 +224,11 @@ vault.unlock → IVaultService (future) vault.lock entries.list → IEntryService (future) entries.create -ai.analyze → IAiService (future) -ai.chat +ai.health → IAiService (implemented bridge) +ai.summarize_* → IAiService (implemented bridge) +ai.chat → IAiService (implemented bridge) +ai.embed → IAiService (implemented bridge) +db.status → IJournalDatabaseService (in-progress DB parity) search.query → ISearchService (future) ``` @@ -162,5 +247,18 @@ services.AddFragmentServices(); ``` This registers: -- `IFragmentRepository` → `InMemoryFragmentRepository` (singleton — one shared store) +- `IFragmentRepository` → `FileFragmentRepository` (singleton — persisted fragment store) - `IFragmentService` → `FragmentService` (transient — fresh instance per request) + +## Fragment Store Location + +`FileFragmentRepository` persists data to: + +- default: `.journal-sidecar/fragments.json` under current working directory +- override: `JOURNAL_FRAGMENT_STORE_PATH` environment variable + +## Legacy Vault Compatibility Note + +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. diff --git a/scripts/dotnet-min.ps1 b/scripts/dotnet-min.ps1 new file mode 100644 index 0000000..9b36e3b --- /dev/null +++ b/scripts/dotnet-min.ps1 @@ -0,0 +1,62 @@ +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$DotnetArgs +) + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +# Keep dotnet and NuGet artifacts local to the repo for easy cleanup. +$env:DOTNET_CLI_HOME = Join-Path $repoRoot ".dotnet_home" +$env:NUGET_PACKAGES = Join-Path $repoRoot ".nuget\packages" +$env:NUGET_HTTP_CACHE_PATH = Join-Path $repoRoot ".nuget\http-cache" + +# Keep setup minimal and non-interactive. +$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1" +$env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = "0" +$env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "0" +$env:DOTNET_CLI_TELEMETRY_OPTOUT = "1" + +# Clear proxy env vars for this process. The host machine currently points them +# to 127.0.0.1:9, which breaks NuGet restore. +Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue +Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue +Remove-Item Env:ALL_PROXY -ErrorAction SilentlyContinue +Remove-Item Env:http_proxy -ErrorAction SilentlyContinue +Remove-Item Env:https_proxy -ErrorAction SilentlyContinue +Remove-Item Env:all_proxy -ErrorAction SilentlyContinue +Remove-Item Env:GIT_HTTP_PROXY -ErrorAction SilentlyContinue +Remove-Item Env:GIT_HTTPS_PROXY -ErrorAction SilentlyContinue + +# Prefer offline cert revocation checks to reduce flaky TLS behavior on constrained hosts. +$env:NUGET_CERT_REVOCATION_MODE = "offline" + +New-Item -ItemType Directory -Force -Path $env:DOTNET_CLI_HOME | Out-Null +New-Item -ItemType Directory -Force -Path $env:NUGET_PACKAGES | Out-Null +New-Item -ItemType Directory -Force -Path $env:NUGET_HTTP_CACHE_PATH | Out-Null + +if (-not $DotnetArgs -or $DotnetArgs.Count -eq 0) { + Write-Host "Usage: ./scripts/dotnet-min.ps1 <dotnet args>" + Write-Host "Example: ./scripts/dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj" + exit 2 +} + +$firstArg = $DotnetArgs[0].ToLowerInvariant() +$effectiveArgs = @($DotnetArgs) + +if ($firstArg -in @("restore", "build", "run", "test", "publish", "pack")) { + if (-not ($effectiveArgs -contains "-p:RestoreIgnoreFailedSources=true")) { + $effectiveArgs += "-p:RestoreIgnoreFailedSources=true" + } + if (-not ($effectiveArgs -contains "-p:NuGetAudit=false")) { + $effectiveArgs += "-p:NuGetAudit=false" + } +} + +if ($firstArg -eq "restore") { + if (-not ($effectiveArgs -contains "--ignore-failed-sources")) { + $effectiveArgs += "--ignore-failed-sources" + } +} + +& dotnet @effectiveArgs +exit $LASTEXITCODE diff --git a/scripts/nuget-export-cache.ps1 b/scripts/nuget-export-cache.ps1 new file mode 100644 index 0000000..4c9cc61 --- /dev/null +++ b/scripts/nuget-export-cache.ps1 @@ -0,0 +1,57 @@ +param( + [string]$OutputZip = "nuget-cache-export.zip", + [switch]$IncludeDotnetHome +) + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$outputPath = if ([System.IO.Path]::IsPathRooted($OutputZip)) { $OutputZip } else { Join-Path $repoRoot $OutputZip } +$outputDir = Split-Path -Parent $outputPath +if (-not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null +} + +Write-Host "Priming restore cache..." +& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Sidecar/Journal.Sidecar.csproj" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Api/Journal.Api.csproj" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.SmokeTests/Journal.SmokeTests.csproj" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$staging = Join-Path $repoRoot ".nuget-export-staging" +if (Test-Path $staging) { + Remove-Item -Recurse -Force $staging +} +New-Item -ItemType Directory -Force -Path $staging | Out-Null + +$nugetRoot = Join-Path $repoRoot ".nuget" +if (-not (Test-Path $nugetRoot)) { + Write-Error "No .nuget directory found under $repoRoot" + exit 1 +} + +Copy-Item -Recurse -Force -Path $nugetRoot -Destination (Join-Path $staging ".nuget") +if ($IncludeDotnetHome) { + $dotnetHome = Join-Path $repoRoot ".dotnet_home" + if (Test-Path $dotnetHome) { + Copy-Item -Recurse -Force -Path $dotnetHome -Destination (Join-Path $staging ".dotnet_home") + } +} + +$manifest = @( + "exported_utc=$([DateTime]::UtcNow.ToString("o"))" + "repo_root=$repoRoot" + "include_dotnet_home=$($IncludeDotnetHome.IsPresent)" + "note=Copy this zip to target machine and run scripts/nuget-import-cache.ps1" +) +$manifest | Set-Content -Encoding UTF8 -Path (Join-Path $staging "nuget-cache-manifest.txt") + +if (Test-Path $outputPath) { + Remove-Item -Force $outputPath +} + +Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $outputPath -Force +Remove-Item -Recurse -Force $staging + +Write-Host "NuGet cache export created at: $outputPath" + diff --git a/scripts/nuget-import-cache.ps1 b/scripts/nuget-import-cache.ps1 new file mode 100644 index 0000000..aced5e8 --- /dev/null +++ b/scripts/nuget-import-cache.ps1 @@ -0,0 +1,25 @@ +param( + [string]$InputZip = "nuget-cache-export.zip" +) + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$inputPath = if ([System.IO.Path]::IsPathRooted($InputZip)) { $InputZip } else { Join-Path $repoRoot $InputZip } + +if (-not (Test-Path $inputPath)) { + Write-Error "Input zip not found: $inputPath" + exit 1 +} + +Write-Host "Importing cache from: $inputPath" +Expand-Archive -Path $inputPath -DestinationPath $repoRoot -Force + +Write-Host "Running restore with local cache..." +& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Sidecar/Journal.Sidecar.csproj" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Api/Journal.Api.csproj" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.SmokeTests/Journal.SmokeTests.csproj" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "Cache import complete." +