- Add edit/delete icon buttons to SidePanel items for entries, todos, lists, and fragments - Move fragment edit/delete controls from FragmentEditor main panel to SidePanel - Add externalEditRequested prop to FragmentEditor for parent-controlled edit mode - Add fragment delete handling in +page.svelte performDelete flow - Support custom entry filenames via FileName parameter in EntrySavePayload - Fix vault persistence for custom-named entries (non-date-formatted .md files) - Add VaultStorageService SaveCustomEntries helper for _custom_entries.vault - Add entries.delete backend command and wire through EntryFileService - Remove Write/Preview toggle from MarkdownEditor (previewOnly controlled by parent) - Add smoke tests for vault custom entry roundtrip and entry save with custom filename Co-Authored-By: Oz <oz-agent@warp.dev>
424 lines
21 KiB
C#
424 lines
21 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 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 "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 "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 "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 "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");
|
|
result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
|
|
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory);
|
|
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");
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|