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 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 ] [--vault-dir ] [--data-dir ]"); Console.WriteLine(" Journal.Sidecar vault save [--password ] [--vault-dir ] [--data-dir ]"); Console.WriteLine(" Journal.Sidecar search [query] [--tag ] [--type ] [--start-date ] [--end-date ] [--section ] [--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; } = []; } }