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 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
@ -324,8 +343,10 @@ public class Entry(
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);
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);
@ -415,6 +436,7 @@ public class Entry(
return Error("Internal error");
}
TryAutoSyncVault(action, correlationId);
CommandLogger.LogSuccess(action, correlationId);
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.");
}
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()
{
lock (_lock)

View File

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

View File

@ -432,5 +432,145 @@ internal static partial class Program
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 times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync),
("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 templates.save auto-syncs vault and survives reload", TestEntryTemplateSaveAutoSyncsVaultAsync),
("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),

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"
}
]
}