495 lines
24 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Lists;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault;
namespace Journal.Core;
public class Entry(
IFragmentService fragments,
IEntrySearchService entrySearch,
IVaultStorageService vaultStorage,
IJournalDatabaseService database,
IDatabaseSessionService databaseSession,
IJournalConfigService config,
IAiService ai,
ISpeechBridgeService speech,
IEntryFileService entryFiles,
IListService lists,
ITodoService todos,
CommandLogger logger)
{
private readonly IFragmentService _fragments = fragments;
private readonly IEntrySearchService _entrySearch = entrySearch;
private readonly IVaultStorageService _vaultStorage = vaultStorage;
private readonly IJournalDatabaseService _database = database;
private readonly IDatabaseSessionService _databaseSession = databaseSession;
private readonly IJournalConfigService _config = config;
private readonly IAiService _ai = ai;
private readonly ISpeechBridgeService _speech = speech;
private readonly IEntryFileService _entryFiles = entryFiles;
private readonly IListService _lists = lists;
private readonly ITodoService _todos = todos;
private readonly CommandLogger _logger = logger;
private static readonly HashSet<string> VaultSyncActions = new(StringComparer.Ordinal)
{
"entries.save",
"entries.delete",
"templates.save",
"templates.delete",
"fragments.create",
"fragments.update",
"fragments.delete",
"lists.create",
"lists.update",
"lists.delete",
"todos.create",
"todos.update",
"todos.delete",
"todos.items.create",
"todos.items.update",
"todos.items.delete"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public async Task RunAsync()
{
string? line;
while ((line = Console.ReadLine()) is not null)
{
var response = await HandleCommandAsync(line);
Console.WriteLine(response);
}
}
public async Task<string> HandleCommandAsync(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Error("Invalid command");
Command? cmd;
try
{
cmd = JsonSerializer.Deserialize<Command>(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();
CommandLogger.LogStart(action, correlationId, cmd.Payload);
object? result;
try
{
switch (action)
{
case "fragments.list":
result = _fragments.GetAll();
break;
case "fragments.get":
if (!Guid.TryParse(cmd.Id, out var getId))
return Error("Invalid or missing id");
result = _fragments.GetById(getId);
break;
case "fragments.create":
var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload);
if (createDto is null)
return Error("Missing or invalid payload");
result = _fragments.Create(createDto);
break;
case "fragments.update":
if (!Guid.TryParse(cmd.Id, out var updateId))
return Error("Invalid or missing id");
var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload);
if (updateDto is null)
return Error("Missing or invalid payload");
result = _fragments.Update(updateId, updateDto);
break;
case "fragments.delete":
if (!Guid.TryParse(cmd.Id, out var deleteId))
return Error("Invalid or missing id");
result = _fragments.Remove(deleteId);
break;
case "fragments.search":
result = _fragments.Search(cmd.Type, cmd.Tag);
break;
// ── Lists ────────────────────────────────────────
case "lists.list":
result = _lists.GetAll();
break;
case "lists.get":
if (!Guid.TryParse(cmd.Id, out var getListId))
return Error("Invalid or missing id");
result = _lists.GetById(getListId);
break;
case "lists.create":
var createListDto = DeserializePayload<CreateListDto>(cmd.Payload);
if (createListDto is null)
return Error("Missing or invalid payload");
result = _lists.Create(createListDto);
break;
case "lists.update":
if (!Guid.TryParse(cmd.Id, out var updateListId))
return Error("Invalid or missing id");
var updateListDto = DeserializePayload<UpdateListDto>(cmd.Payload);
if (updateListDto is null)
return Error("Missing or invalid payload");
result = _lists.Update(updateListId, updateListDto);
break;
case "lists.delete":
if (!Guid.TryParse(cmd.Id, out var deleteListId))
return Error("Invalid or missing id");
result = _lists.Remove(deleteListId);
break;
// ── Todos ────────────────────────────────────────
case "todos.list":
result = _todos.GetAllLists();
break;
case "todos.get":
if (!Guid.TryParse(cmd.Id, out var getTodoListId))
return Error("Invalid or missing id");
result = _todos.GetListById(getTodoListId);
break;
case "todos.create":
var createTodoListDto = DeserializePayload<CreateTodoListDto>(cmd.Payload);
if (createTodoListDto is null)
return Error("Missing or invalid payload");
result = _todos.CreateList(createTodoListDto);
break;
case "todos.update":
if (!Guid.TryParse(cmd.Id, out var updateTodoListId))
return Error("Invalid or missing id");
var updateTodoListDto = DeserializePayload<UpdateTodoListDto>(cmd.Payload);
if (updateTodoListDto is null)
return Error("Missing or invalid payload");
result = _todos.UpdateList(updateTodoListId, updateTodoListDto);
break;
case "todos.delete":
if (!Guid.TryParse(cmd.Id, out var deleteTodoListId))
return Error("Invalid or missing id");
result = _todos.RemoveList(deleteTodoListId);
break;
case "todos.items.create":
var createItemDto = DeserializePayload<CreateTodoItemDto>(cmd.Payload);
if (createItemDto is null)
return Error("Missing or invalid payload");
result = _todos.CreateItem(createItemDto);
break;
case "todos.items.update":
if (!Guid.TryParse(cmd.Id, out var updateItemId))
return Error("Invalid or missing id");
var updateItemDto = DeserializePayload<UpdateTodoItemDto>(cmd.Payload);
if (updateItemDto is null)
return Error("Missing or invalid payload");
result = _todos.UpdateItem(updateItemId, updateItemDto);
break;
case "todos.items.delete":
if (!Guid.TryParse(cmd.Id, out var deleteItemId))
return Error("Invalid or missing id");
result = _todos.RemoveItem(deleteItemId);
break;
case "search.entries":
var searchPayload = DeserializePayload<SearchEntriesPayload>(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<EntryListPayload>(cmd.Payload);
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
? listPayload.DataDirectory
: _config.Current.DataDirectory;
result = _entryFiles.ListEntries(listDataDirectory);
break;
case "templates.list":
var templateListPayload = DeserializePayload<EntryTemplateListPayload>(cmd.Payload);
var templateListDirectory = !string.IsNullOrWhiteSpace(templateListPayload?.DataDirectory)
? templateListPayload.DataDirectory
: _config.Current.DataDirectory;
result = _entryFiles.ListTemplates(templateListDirectory);
break;
case "entries.load":
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
break;
case "templates.load":
var loadTemplatePayload = DeserializePayload<EntryTemplateLoadPayload>(cmd.Payload);
if (loadTemplatePayload is null || string.IsNullOrWhiteSpace(loadTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadTemplate(loadTemplatePayload.FilePath);
break;
case "entries.save":
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
return Error("Missing or invalid payload");
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
break;
case "templates.save":
var saveTemplatePayload = DeserializePayload<EntryTemplateSavePayload>(cmd.Payload);
if (saveTemplatePayload is null || string.IsNullOrWhiteSpace(saveTemplatePayload.Name))
return Error("Missing or invalid payload");
result = _entryFiles.SaveTemplate(saveTemplatePayload, _config.Current.DataDirectory);
break;
case "entries.delete":
var deleteEntryPayload = DeserializePayload<EntryDeletePayload>(cmd.Payload);
if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath);
break;
case "templates.delete":
var deleteTemplatePayload = DeserializePayload<EntryTemplateDeletePayload>(cmd.Payload);
if (deleteTemplatePayload is null || string.IsNullOrWhiteSpace(deleteTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteTemplate(deleteTemplatePayload.FilePath);
break;
case "config.get":
result = _config.Current;
break;
case "ai.health":
result = await _ai.HealthAsync();
break;
case "ai.summarize_entry":
var summarizeEntryPayload = DeserializePayload<AiSummarizeEntryPayload>(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<AiSummarizeAllPayload>(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<AiChatPayload>(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<AiEmbedPayload>(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<SpeechTranscribePayload>(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<VaultInitializePayload>(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<VaultPayload>(cmd.Payload);
if (loadPayload is null)
return Error("Missing or invalid payload");
var loaded = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
if (loaded)
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory);
result = loaded;
break;
case "vault.save_current_month":
var saveCurrentPayload = DeserializePayload<VaultPayload>(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<VaultPayload>(cmd.Payload);
if (rebuildPayload is null)
return Error("Missing or invalid payload");
_databaseSession.CloseConnection();
_vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory);
result = true;
break;
case "vault.clear_data_directory":
var clearPayload = DeserializePayload<ClearDataPayload>(cmd.Payload);
if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory))
return Error("Missing or invalid payload");
if (_databaseSession is IDisposable disposableSession)
disposableSession.Dispose();
_vaultStorage.ClearDataDirectory(clearPayload.DataDirectory);
result = true;
break;
case "db.status":
var dbStatusPayload = DeserializePayload<DatabasePayload>(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<DatabasePayload>(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<DatabasePayload>(cmd.Payload);
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
return Error("Missing or invalid payload");
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
_databaseSession.SetPassword(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
break;
default:
CommandLogger.LogFailure(action, correlationId, "unknown_action");
return Error($"Unknown action: {action}");
}
}
catch (JsonException)
{
CommandLogger.LogFailure(action, correlationId, "invalid_payload_json");
return Error("Missing or invalid payload");
}
catch (ValidationException ex)
{
CommandLogger.LogFailure(action, correlationId, "validation", ex.Message);
return Error(ex.Message);
}
catch (ArgumentException ex)
{
CommandLogger.LogFailure(action, correlationId, "argument", ex.Message);
return Error(ex.Message);
}
catch (TimeoutException ex)
{
CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message);
return Error(ex.Message);
}
catch (InvalidOperationException ex)
{
CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message);
return Error(ex.Message);
}
catch (FileNotFoundException ex)
{
CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message);
return Error(ex.Message);
}
catch
{
CommandLogger.LogFailure(action, correlationId, "internal_error");
return Error("Internal error");
}
TryAutoSyncVault(action, correlationId);
CommandLogger.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 static T? DeserializePayload<T>(JsonElement? payload)
{
if (payload is null)
return default;
return payload.Value.Deserialize<T>(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 void TryAutoSyncVault(string action, string correlationId)
{
if (!VaultSyncActions.Contains(action))
return;
if (!_databaseSession.TryGetSession(out var password, out var sessionDataDirectory))
return;
try
{
var config = _config.Current;
var dataDirectory = string.IsNullOrWhiteSpace(sessionDataDirectory)
? config.DataDirectory
: sessionDataDirectory;
_databaseSession.CloseConnection();
_vaultStorage.RebuildAllVaults(password, config.VaultDirectory, dataDirectory);
}
catch (Exception ex)
{
CommandLogger.LogFailure(action, correlationId, "vault_auto_sync_failed", ex.Message);
}
}
}