feat: Introduce core command handling, better handling between web gateway and .exe

This commit is contained in:
stan44 2026-02-27 15:52:14 -06:00
parent 8fd8451e1d
commit 8b766a54f2
6 changed files with 218 additions and 2 deletions

View File

@ -42,6 +42,25 @@ public class Entry(
private readonly IListService _lists = lists; private readonly IListService _lists = lists;
private readonly ITodoService _todos = todos; private readonly ITodoService _todos = todos;
private readonly CommandLogger _logger = logger; 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() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
@ -324,8 +343,10 @@ public class Entry(
var loadPayload = DeserializePayload<VaultPayload>(cmd.Payload); var loadPayload = DeserializePayload<VaultPayload>(cmd.Payload);
if (loadPayload is null) if (loadPayload is null)
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory); var loaded = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory); if (loaded)
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory);
result = loaded;
break; break;
case "vault.save_current_month": case "vault.save_current_month":
var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload); var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload);
@ -415,6 +436,7 @@ public class Entry(
return Error("Internal error"); return Error("Internal error");
} }
TryAutoSyncVault(action, correlationId);
CommandLogger.LogSuccess(action, correlationId); CommandLogger.LogSuccess(action, correlationId);
return JsonSerializer.Serialize(new { ok = true, data = result }); return JsonSerializer.Serialize(new { ok = true, data = result });
} }
@ -445,4 +467,28 @@ public class Entry(
throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time."); 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);
}
}
} }

View File

@ -37,6 +37,23 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
} }
} }
public bool TryGetSession(out string password, out string? dataDirectory)
{
lock (_lock)
{
if (string.IsNullOrWhiteSpace(_password))
{
password = "";
dataDirectory = null;
return false;
}
password = _password;
dataDirectory = _dataDirectory;
return true;
}
}
public SqliteConnection GetConnection() public SqliteConnection GetConnection()
{ {
lock (_lock) lock (_lock)

View File

@ -6,6 +6,7 @@ public interface IDatabaseSessionService
{ {
bool IsUnlocked { get; } bool IsUnlocked { get; }
void SetPassword(string password, string? dataDirectory = null); void SetPassword(string password, string? dataDirectory = null);
bool TryGetSession(out string password, out string? dataDirectory);
SqliteConnection GetConnection(); SqliteConnection GetConnection();
void CloseConnection(); void CloseConnection();
} }

View File

@ -432,5 +432,145 @@ internal static partial class Program
return Task.CompletedTask; return Task.CompletedTask;
} }
static async Task TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync()
{
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");
File.WriteAllBytes(Path.Combine(vaultDir, "2026-02.vault"), encrypted);
var entry = NewEntry();
var loadRequest = JsonSerializer.Serialize(new
{
action = "vault.load_all",
payload = new
{
password = "wrong-password",
vaultDirectory = vaultDir,
dataDirectory = dataDir
}
});
var loadResponse = await entry.HandleCommandAsync(loadRequest);
using var loadDoc = JsonDocument.Parse(loadResponse);
Assert(loadDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.load_all response envelope to be ok=true.");
Assert(!loadDoc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=false with wrong password.");
var listRequest = JsonSerializer.Serialize(new
{
action = "lists.list"
});
var listResponse = await entry.HandleCommandAsync(listRequest);
using var listDoc = JsonDocument.Parse(listResponse);
Assert(!listDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected lists.list to fail while locked.");
var error = listDoc.RootElement.GetProperty("error").GetString() ?? "";
Assert(error.Contains("database is locked", StringComparison.OrdinalIgnoreCase), "Expected locked-session error after failed vault.load_all.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryTemplateSaveAutoSyncsVaultAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-entry-vault-sync-smoke", Guid.NewGuid().ToString("N"));
var projectRoot = Path.Combine(root, "project");
var appDirectory = Path.Combine(projectRoot, "journal");
var vaultDir = Path.Combine(appDirectory, "vault");
var dataDir = Path.Combine(appDirectory, "data");
Directory.CreateDirectory(vaultDir);
Directory.CreateDirectory(dataDir);
var previousProjectRoot = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT");
var previousDataDir = Environment.GetEnvironmentVariable("JOURNAL_DATA_DIR");
var previousVaultDir = Environment.GetEnvironmentVariable("JOURNAL_VAULT_DIR");
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", projectRoot);
Environment.SetEnvironmentVariable("JOURNAL_DATA_DIR", dataDir);
Environment.SetEnvironmentVariable("JOURNAL_VAULT_DIR", vaultDir);
try
{
var entry = NewEntry();
var password = "vault-pass-123";
var unlockRequest = JsonSerializer.Serialize(new
{
action = "vault.load_all",
payload = new
{
password,
vaultDirectory = vaultDir,
dataDirectory = dataDir
}
});
var unlockResponse = await entry.HandleCommandAsync(unlockRequest);
using var unlockDoc = JsonDocument.Parse(unlockResponse);
Assert(unlockDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.load_all envelope to succeed.");
Assert(unlockDoc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault.");
var saveTemplateRequest = JsonSerializer.Serialize(new
{
action = "templates.save",
payload = new
{
name = "Weekly Review",
content = "## Wins\n- shipped feature",
dataDirectory = dataDir
}
});
var saveTemplateResponse = await entry.HandleCommandAsync(saveTemplateRequest);
using var saveTemplateDoc = JsonDocument.Parse(saveTemplateResponse);
Assert(saveTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected templates.save to succeed.");
var customVaultPath = Path.Combine(vaultDir, "_custom_entries.vault");
Assert(File.Exists(customVaultPath), "Expected template save to auto-sync custom entries vault.");
var entries = ReadVaultEntryTexts(customVaultPath, password);
Assert(entries.ContainsKey("Weekly Review.template.md"), "Expected template file in custom vault archive.");
var clearRequest = JsonSerializer.Serialize(new
{
action = "vault.clear_data_directory",
payload = new
{
dataDirectory = dataDir
}
});
var clearResponse = await entry.HandleCommandAsync(clearRequest);
using var clearDoc = JsonDocument.Parse(clearResponse);
Assert(clearDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.clear_data_directory to succeed.");
var reloadResponse = await entry.HandleCommandAsync(unlockRequest);
using var reloadDoc = JsonDocument.Parse(reloadResponse);
Assert(reloadDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected second vault.load_all envelope to succeed.");
Assert(reloadDoc.RootElement.GetProperty("data").GetBoolean(), "Expected second vault.load_all data=true.");
var restoredTemplatePath = Path.Combine(dataDir, "Weekly Review.template.md");
Assert(File.Exists(restoredTemplatePath), "Expected template to be restored from vault after reload.");
}
finally
{
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", previousProjectRoot);
Environment.SetEnvironmentVariable("JOURNAL_DATA_DIR", previousDataDir);
Environment.SetEnvironmentVariable("JOURNAL_VAULT_DIR", previousVaultDir);
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
} }

View File

@ -69,7 +69,9 @@ internal static partial class Program
("Python sidecar speech service surfaces unavailable engine errors", TestPythonSidecarSpeechServiceErrorAsync), ("Python sidecar speech service surfaces unavailable engine errors", TestPythonSidecarSpeechServiceErrorAsync),
("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync), ("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync),
("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync), ("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync),
("Entry vault.load_all wrong password keeps database session locked", TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync),
("Entry vault.clear_data_directory removes files", TestEntryVaultClearDataDirectoryAsync), ("Entry vault.clear_data_directory removes files", TestEntryVaultClearDataDirectoryAsync),
("Entry templates.save auto-syncs vault and survives reload", TestEntryTemplateSaveAutoSyncsVaultAsync),
("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync), ("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync),
("Sidecar vault CLI save writes monthly vault with --password", TestSidecarVaultCliSaveAsync), ("Sidecar vault CLI save writes monthly vault with --password", TestSidecarVaultCliSaveAsync),
("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync), ("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync),

10
sdt-workspace.json Normal file
View File

@ -0,0 +1,10 @@
{
"name": "Stan's Dev Projects",
"projects": [
{
"name": "Project Journal",
"description": "Encrypted journal — Tauri desktop + C#/.NET backend + SvelteKit UI",
"path": "journal"
}
]
}