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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
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