- Move standalone fragment storage from unencrypted SQLite to the existing encrypted SQLCipher database (journal_cache.db) - Add IDatabaseSessionService/DatabaseSessionService for shared encrypted connection management after authentication - Update fragments table schema: nullable entry_id, add guid column - Reorganize flat Services/ directory (28 files) into 9 domain modules: Ai, Config, Database, Entries, Fragments, Logging, Sidecar, Speech, Vault - Update all namespace declarations and using statements across all projects - Update REFACTORING_SUMMARY.md with all changes Co-Authored-By: Warp <agent@warp.dev>
382 lines
12 KiB
C#
382 lines
12 KiB
C#
using System.Text;
|
|
using Journal.Core.Dtos;
|
|
using Journal.Core.Services.Config;
|
|
using Journal.Core.Services.Entries;
|
|
using Journal.Core.Services.Vault;
|
|
|
|
namespace Journal.Core.Services.Sidecar;
|
|
|
|
public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
|
|
{
|
|
private readonly IVaultStorageService _vaultStorage = vaultStorage;
|
|
private readonly IEntrySearchService _entrySearch = entrySearch;
|
|
private readonly IJournalConfigService _config = config;
|
|
|
|
public async Task<int> RunAsync(string[] args, Entry entry)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(args);
|
|
ArgumentNullException.ThrowIfNull(entry);
|
|
|
|
if (args.Length == 0)
|
|
{
|
|
await entry.RunAsync();
|
|
return 0;
|
|
}
|
|
|
|
if (IsHelp(args[0]))
|
|
{
|
|
PrintUsage();
|
|
return 0;
|
|
}
|
|
|
|
if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase))
|
|
return RunVaultCommand([.. args.Skip(1)]);
|
|
if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase))
|
|
return RunSearchCommand([.. args.Skip(1)]);
|
|
|
|
Console.Error.WriteLine($"Unknown command: {args[0]}");
|
|
PrintUsage();
|
|
return 2;
|
|
}
|
|
|
|
public int RunVaultCommand(string[] args)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(args);
|
|
if (args.Length == 0 || IsHelp(args[0]))
|
|
{
|
|
PrintVaultUsage();
|
|
return 2;
|
|
}
|
|
|
|
var action = args[0].Trim().ToLowerInvariant();
|
|
if (action is not ("load" or "save"))
|
|
{
|
|
Console.Error.WriteLine($"Unknown vault action: {args[0]}");
|
|
PrintVaultUsage();
|
|
return 2;
|
|
}
|
|
|
|
if (!TryParseVaultOptions([.. args.Skip(1)], out var options, out var parseError))
|
|
{
|
|
Console.Error.WriteLine(parseError);
|
|
PrintVaultUsage();
|
|
return 2;
|
|
}
|
|
|
|
var password = options.Password;
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
password = PromptPassword();
|
|
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
{
|
|
Console.Error.WriteLine("Vault password cannot be empty.");
|
|
return 2;
|
|
}
|
|
|
|
var (vaultDirectory, dataDirectory) = ResolveDirectories(options.VaultDirectory, options.DataDirectory);
|
|
|
|
try
|
|
{
|
|
if (action == "load")
|
|
{
|
|
var ok = _vaultStorage.LoadAllVaults(password, vaultDirectory, dataDirectory);
|
|
if (!ok)
|
|
{
|
|
Console.Error.WriteLine("Incorrect password.");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine($"Vault loaded. Decrypted files are in {dataDirectory}");
|
|
return 0;
|
|
}
|
|
|
|
_vaultStorage.RebuildAllVaults(password, vaultDirectory, dataDirectory);
|
|
Console.WriteLine($"Vault saved from decrypted files in {dataDirectory}");
|
|
return 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Vault command failed: {ex.Message}");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
public int RunSearchCommand(string[] args)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(args);
|
|
if (args.Length > 0 && IsHelp(args[0]))
|
|
{
|
|
PrintSearchUsage();
|
|
return 0;
|
|
}
|
|
|
|
if (!TryParseSearchOptions(args, out var options, out var parseError))
|
|
{
|
|
Console.Error.WriteLine(parseError);
|
|
PrintSearchUsage();
|
|
return 2;
|
|
}
|
|
|
|
var (_, dataDirectory) = ResolveDirectories(vaultOverride: null, options.DataDirectory);
|
|
if (!Directory.Exists(dataDirectory) || Directory.GetFiles(dataDirectory, "*.md").Length == 0)
|
|
{
|
|
Console.WriteLine("No decrypted journal entries found. Please load the vault first: journal vault load");
|
|
return 0;
|
|
}
|
|
|
|
try
|
|
{
|
|
var request = new EntrySearchRequestDto(
|
|
DataDirectory: dataDirectory,
|
|
Query: options.Query,
|
|
Section: options.Section,
|
|
StartDate: options.StartDate,
|
|
EndDate: options.EndDate,
|
|
Tags: options.Tags,
|
|
Types: options.Types,
|
|
Checked: options.Checked,
|
|
Unchecked: options.Unchecked);
|
|
|
|
var results = _entrySearch.SearchEntriesAsync(request).GetAwaiter().GetResult();
|
|
if (results.Count == 0)
|
|
{
|
|
Console.WriteLine("No entries found matching the criteria.");
|
|
return 0;
|
|
}
|
|
|
|
foreach (var result in results)
|
|
{
|
|
Console.WriteLine($"--- {result.Date} ---");
|
|
Console.WriteLine(result.RawContent);
|
|
Console.WriteLine();
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Search command failed: {ex.Message}");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private static bool TryParseVaultOptions(string[] args, out VaultOptions options, out string error)
|
|
{
|
|
var parsed = new VaultOptions();
|
|
for (var i = 0; i < args.Length; i++)
|
|
{
|
|
var token = args[i];
|
|
if (IsHelp(token))
|
|
{
|
|
options = parsed;
|
|
error = "";
|
|
return false;
|
|
}
|
|
|
|
if (i + 1 >= args.Length)
|
|
{
|
|
options = parsed;
|
|
error = $"Missing value for option '{token}'.";
|
|
return false;
|
|
}
|
|
|
|
var value = args[i + 1];
|
|
switch (token)
|
|
{
|
|
case "--password":
|
|
case "-p":
|
|
parsed.Password = value;
|
|
break;
|
|
case "--vault-dir":
|
|
parsed.VaultDirectory = value;
|
|
break;
|
|
case "--data-dir":
|
|
parsed.DataDirectory = value;
|
|
break;
|
|
default:
|
|
options = parsed;
|
|
error = $"Unknown option '{token}'.";
|
|
return false;
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
options = parsed;
|
|
error = "";
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseSearchOptions(string[] args, out SearchOptions options, out string error)
|
|
{
|
|
var parsed = new SearchOptions();
|
|
for (var i = 0; i < args.Length; i++)
|
|
{
|
|
var token = args[i];
|
|
if (IsHelp(token))
|
|
{
|
|
options = parsed;
|
|
error = "";
|
|
return false;
|
|
}
|
|
|
|
if (!token.StartsWith("-", StringComparison.Ordinal))
|
|
{
|
|
if (parsed.Query is null)
|
|
{
|
|
parsed.Query = token;
|
|
continue;
|
|
}
|
|
|
|
options = parsed;
|
|
error = $"Unexpected positional argument '{token}'.";
|
|
return false;
|
|
}
|
|
|
|
if (i + 1 >= args.Length)
|
|
{
|
|
options = parsed;
|
|
error = $"Missing value for option '{token}'.";
|
|
return false;
|
|
}
|
|
|
|
var value = args[i + 1];
|
|
switch (token)
|
|
{
|
|
case "--data-dir":
|
|
parsed.DataDirectory = value;
|
|
break;
|
|
case "--tag":
|
|
case "-t":
|
|
parsed.Tags.Add(value);
|
|
break;
|
|
case "--type":
|
|
case "-y":
|
|
parsed.Types.Add(value);
|
|
break;
|
|
case "--start-date":
|
|
case "-s":
|
|
parsed.StartDate = value;
|
|
break;
|
|
case "--end-date":
|
|
case "-e":
|
|
parsed.EndDate = value;
|
|
break;
|
|
case "--section":
|
|
case "-sec":
|
|
parsed.Section = value;
|
|
break;
|
|
case "--checked":
|
|
case "-chk":
|
|
parsed.Checked.Add(value);
|
|
break;
|
|
case "--unchecked":
|
|
case "-uchk":
|
|
parsed.Unchecked.Add(value);
|
|
break;
|
|
default:
|
|
options = parsed;
|
|
error = $"Unknown option '{token}'.";
|
|
return false;
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
options = parsed;
|
|
error = "";
|
|
return true;
|
|
}
|
|
|
|
private (string VaultDirectory, string DataDirectory) ResolveDirectories(string? vaultOverride, string? dataOverride)
|
|
{
|
|
var envVault = Environment.GetEnvironmentVariable("JOURNAL_VAULT_DIR");
|
|
var envData = Environment.GetEnvironmentVariable("JOURNAL_DATA_DIR");
|
|
var defaults = _config.Current;
|
|
|
|
var vault = FirstNonEmpty(vaultOverride, envVault) ?? defaults.VaultDirectory;
|
|
var data = FirstNonEmpty(dataOverride, envData) ?? defaults.DataDirectory;
|
|
return (Path.GetFullPath(vault), Path.GetFullPath(data));
|
|
}
|
|
|
|
private static string? FirstNonEmpty(params string?[] values) =>
|
|
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
|
|
|
private static string PromptPassword()
|
|
{
|
|
if (Console.IsInputRedirected)
|
|
return Console.ReadLine() ?? "";
|
|
|
|
Console.Write("Vault password: ");
|
|
var builder = new StringBuilder();
|
|
while (true)
|
|
{
|
|
var keyInfo = Console.ReadKey(intercept: true);
|
|
if (keyInfo.Key == ConsoleKey.Enter)
|
|
{
|
|
Console.WriteLine();
|
|
break;
|
|
}
|
|
|
|
if (keyInfo.Key == ConsoleKey.Backspace)
|
|
{
|
|
if (builder.Length > 0)
|
|
builder.Length--;
|
|
continue;
|
|
}
|
|
|
|
if (!char.IsControl(keyInfo.KeyChar))
|
|
builder.Append(keyInfo.KeyChar);
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static bool IsHelp(string token) =>
|
|
string.Equals(token, "--help", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, "-h", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, "help", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static void PrintUsage()
|
|
{
|
|
Console.WriteLine("Usage:");
|
|
Console.WriteLine(" Journal.Sidecar # sidecar stdin/stdout mode");
|
|
Console.WriteLine(" Journal.Sidecar vault load [--password <value>] [--vault-dir <path>] [--data-dir <path>]");
|
|
Console.WriteLine(" Journal.Sidecar vault save [--password <value>] [--vault-dir <path>] [--data-dir <path>]");
|
|
Console.WriteLine(" Journal.Sidecar search [query] [--tag <value>] [--type <value>] [--start-date <yyyy-MM-dd>] [--end-date <yyyy-MM-dd>] [--section <title>] [--checked <text>] [--unchecked <text>] [--data-dir <path>]");
|
|
}
|
|
|
|
private static void PrintVaultUsage()
|
|
{
|
|
Console.WriteLine("Vault usage:");
|
|
Console.WriteLine(" Journal.Sidecar vault load [--password <value>] [--vault-dir <path>] [--data-dir <path>]");
|
|
Console.WriteLine(" Journal.Sidecar vault save [--password <value>] [--vault-dir <path>] [--data-dir <path>]");
|
|
}
|
|
|
|
private static void PrintSearchUsage()
|
|
{
|
|
Console.WriteLine("Search usage:");
|
|
Console.WriteLine(" Journal.Sidecar search [query] [--tag <value>] [--type <value>] [--start-date <yyyy-MM-dd>] [--end-date <yyyy-MM-dd>] [--section <title>] [--checked <text>] [--unchecked <text>] [--data-dir <path>]");
|
|
}
|
|
|
|
private sealed class VaultOptions
|
|
{
|
|
public string? Password { get; set; }
|
|
public string? VaultDirectory { get; set; }
|
|
public string? DataDirectory { get; set; }
|
|
}
|
|
|
|
private sealed class SearchOptions
|
|
{
|
|
public string? Query { get; set; }
|
|
public string? DataDirectory { get; set; }
|
|
public string? StartDate { get; set; }
|
|
public string? EndDate { get; set; }
|
|
public string? Section { get; set; }
|
|
public List<string> Tags { get; } = [];
|
|
public List<string> Types { get; } = [];
|
|
public List<string> Checked { get; } = [];
|
|
public List<string> Unchecked { get; } = [];
|
|
}
|
|
}
|