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; using Journal.Core.Services; 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, 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 HandleCommandAsync(line); Console.WriteLine(response); } } public async Task HandleCommandAsync(string json) { if (string.IsNullOrWhiteSpace(json)) return Error("Invalid command"); Command? cmd; try { cmd = JsonSerializer.Deserialize(json, JsonOptions); } 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); }