feat: Introduce core command handling, better handling between web gateway and .exe
This commit is contained in:
parent
8fd8451e1d
commit
8b766a54f2
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
10
sdt-workspace.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user