diff --git a/NEW/src/JdeScoping.Host/appsettings.json b/NEW/src/JdeScoping.Host/appsettings.json
index 8e442bf..48be1f6 100644
--- a/NEW/src/JdeScoping.Host/appsettings.json
+++ b/NEW/src/JdeScoping.Host/appsettings.json
@@ -145,9 +145,18 @@
"RequiredKeys": [
"RsaPrivateKey",
"ExcelExport:CriteriaSheetPassword",
- "ExcelExport:DataSheetPassword"
+ "ExcelExport:DataSheetPassword",
+ "JdeUser",
+ "JdePassword",
+ "GiwUser",
+ "GiwPassword",
+ "CmsUser",
+ "CmsPassword"
]
},
+ "Pipelines": {
+ "ConfigPath": "Pipelines/pipelines.json"
+ },
"WorkProcessor": {
"Enabled": true,
"WorkInterval": "00:00:05",
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs
index 8dd9840..e864a73 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs
@@ -23,55 +23,33 @@ public class StoreUseCases
}
///
- /// Creates a new store with either key file or password authentication.
+ /// Creates a new store with key file authentication.
///
/// The path where the store will be created.
- /// The path to the key file, or null for password-based authentication.
- /// The password for authentication, or null for key file-based authentication.
- public void CreateStore(string storePath, string? keyFilePath, string? password)
+ /// The path to the key file.
+ public void CreateStore(string storePath, string keyFilePath)
{
- _logger.LogInformation("Creating store at {StorePath}", storePath);
+ if (string.IsNullOrEmpty(keyFilePath))
+ throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath));
- if (!string.IsNullOrEmpty(keyFilePath))
- {
- _storeManager.CreateStore(storePath, keyFilePath);
- _logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
- }
- else if (!string.IsNullOrEmpty(password))
- {
- _storeManager.CreateStoreWithPassword(storePath, password);
- _logger.LogInformation("Password-protected store created");
- }
- else
- {
- throw new ArgumentException("Either key file path or password must be provided.");
- }
+ _logger.LogInformation("Creating store at {StorePath}", storePath);
+ _storeManager.CreateStore(storePath, keyFilePath);
+ _logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
}
///
- /// Opens an existing store with either key file or password authentication.
+ /// Opens an existing store with key file authentication.
///
/// The path to the existing store.
- /// The path to the key file, or null for password-based authentication.
- /// The password for authentication, or null for key file-based authentication.
- public void OpenStore(string storePath, string? keyFilePath, string? password)
+ /// The path to the key file.
+ public void OpenStore(string storePath, string keyFilePath)
{
- _logger.LogInformation("Opening store at {StorePath}", storePath);
+ if (string.IsNullOrEmpty(keyFilePath))
+ throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath));
- if (!string.IsNullOrEmpty(keyFilePath))
- {
- _storeManager.OpenStore(storePath, keyFilePath);
- _logger.LogDebug("Store opened with key file");
- }
- else if (!string.IsNullOrEmpty(password))
- {
- _storeManager.OpenStoreWithPassword(storePath, password);
- _logger.LogDebug("Store opened with password");
- }
- else
- {
- throw new ArgumentException("Either key file path or password must be provided.");
- }
+ _logger.LogInformation("Opening store at {StorePath}", storePath);
+ _storeManager.OpenStore(storePath, keyFilePath);
+ _logger.LogDebug("Store opened with key file");
}
///
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs
index 076458e..7c12543 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs
@@ -41,6 +41,16 @@ public class ConfigModel
/// Gets or sets the connection strings for external data sources.
///
public Dictionary ConnectionStrings { get; set; } = new();
+
+ ///
+ /// Gets or sets the secure store configuration.
+ ///
+ public SecureStoreSection SecureStore { get; set; } = new();
+
+ ///
+ /// Gets or sets the pipelines configuration.
+ ///
+ public PipelinesSection Pipelines { get; set; } = new();
}
public class DataSyncSection
@@ -230,3 +240,45 @@ public class ExcelExportSection
///
public string TimezoneAbbreviation { get; set; } = "CT";
}
+
+///
+/// Configuration section for the secure store.
+///
+public class SecureStoreSection
+{
+ ///
+ /// Gets or sets the path to the secure store file.
+ ///
+ public string StorePath { get; set; } = "data/secrets.json";
+
+ ///
+ /// Gets or sets the path to the key file for decryption.
+ ///
+ public string KeyFilePath { get; set; } = "data/secrets.key";
+
+ ///
+ /// Gets or sets the environment variable name for the master key.
+ ///
+ public string MasterKeyEnvVar { get; set; } = "SCOPINGTOOL_MASTER_KEY";
+
+ ///
+ /// Gets or sets a value indicating whether to auto-create the store if it doesn't exist.
+ ///
+ public bool AutoCreateStore { get; set; } = true;
+
+ ///
+ /// Gets or sets the list of required secret keys that must exist in the store.
+ ///
+ public List RequiredKeys { get; set; } = [];
+}
+
+///
+/// Configuration section for pipelines.
+///
+public class PipelinesSection
+{
+ ///
+ /// Gets or sets the path to the pipelines configuration file.
+ ///
+ public string ConfigPath { get; set; } = "Pipelines/pipelines.json";
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs
index 3df6570..93fd47a 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs
@@ -39,6 +39,29 @@ public class AvaloniaDialogService : IDialogService
return folders.Count > 0 ? folders[0].Path.LocalPath : null;
}
+ ///
+ public async Task ShowFilePickerAsync(string? title = null)
+ {
+ var window = _getMainWindow();
+ if (window == null)
+ return null;
+
+ var files = await window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = title ?? "Select File",
+ AllowMultiple = false,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("JSON Files")
+ {
+ Patterns = new[] { "*.json" }
+ }
+ }
+ });
+
+ return files.Count > 0 ? files[0].Path.LocalPath : null;
+ }
+
///
public async Task ShowMessageAsync(string title, string message)
{
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs
index bce7db0..5d0a5b1 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs
@@ -13,6 +13,13 @@ public interface IDialogService
/// The selected folder path, or null if cancelled.
Task ShowFolderPickerAsync(string? title = null);
+ ///
+ /// Shows a file picker dialog filtered for JSON files.
+ ///
+ /// Optional title for the dialog.
+ /// The selected file path, or null if cancelled.
+ Task ShowFilePickerAsync(string? title = null);
+
///
/// Shows a message dialog.
///
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs
index 5bc6747..b8e2987 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs
@@ -27,13 +27,6 @@ public interface ISecureStoreManager
/// Path for the key file (.key).
void CreateStore(string storePath, string keyFilePath);
- ///
- /// Creates a new store secured with a password.
- ///
- /// Path for the new store file (.json).
- /// Password to encrypt the store.
- void CreateStoreWithPassword(string storePath, string password);
-
///
/// Opens an existing store using a key file.
///
@@ -41,13 +34,6 @@ public interface ISecureStoreManager
/// Path to the key file (.key).
void OpenStore(string storePath, string keyFilePath);
- ///
- /// Opens an existing store using a password.
- ///
- /// Path to the store file (.json).
- /// Password to decrypt the store.
- void OpenStoreWithPassword(string storePath, string password);
-
///
/// Closes the currently open store without saving.
///
@@ -84,6 +70,13 @@ public interface ISecureStoreManager
/// The secret key to remove.
void RemoveSecret(string key);
+ ///
+ /// Ensures all required keys exist in the store, creating blank values for any missing keys.
+ ///
+ /// List of keys that must exist.
+ /// List of keys that were added.
+ IReadOnlyList EnsureRequiredKeys(IEnumerable requiredKeys);
+
///
/// Generates a new key file for use with store encryption.
///
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs
index 2f27838..10ca764 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs
@@ -72,29 +72,6 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
}
- ///
- public void CreateStoreWithPassword(string storePath, string password)
- {
- ThrowIfDisposed();
- _logger.LogInformation("Creating password-protected store at {StorePath}", storePath);
- CloseStoreInternal();
-
- if (string.IsNullOrEmpty(password))
- throw new ArgumentException("Password cannot be empty.", nameof(password));
-
- EnsureDirectory(storePath);
-
- _secretsManager = SecretsManager.CreateStore();
- _secretsManager.LoadKeyFromPassword(password);
-
- _currentStorePath = storePath;
- _keys.Clear();
- _hasUnsavedChanges = true;
-
- Save();
- _logger.LogInformation("Password-protected store created");
- }
-
///
public void OpenStore(string storePath, string keyFilePath)
{
@@ -117,28 +94,6 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_logger.LogDebug("Store opened with key file, contains {KeyCount} keys", _keys.Count);
}
- ///
- public void OpenStoreWithPassword(string storePath, string password)
- {
- ThrowIfDisposed();
- _logger.LogInformation("Opening store at {StorePath} with password", storePath);
- CloseStoreInternal();
-
- if (!File.Exists(storePath))
- throw new FileNotFoundException("Store file not found.", storePath);
-
- if (string.IsNullOrEmpty(password))
- throw new ArgumentException("Password cannot be empty.", nameof(password));
-
- _secretsManager = SecretsManager.LoadStore(storePath);
- _secretsManager.LoadKeyFromPassword(password);
-
- _currentStorePath = storePath;
- LoadKeysMetadata();
- _hasUnsavedChanges = false;
- _logger.LogDebug("Store opened with password, contains {KeyCount} keys", _keys.Count);
- }
-
///
public void CloseStore()
{
@@ -237,6 +192,33 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_hasUnsavedChanges = true;
}
+ ///
+ public IReadOnlyList EnsureRequiredKeys(IEnumerable requiredKeys)
+ {
+ ThrowIfDisposed();
+
+ if (_secretsManager == null)
+ throw new InvalidOperationException("No store is currently open.");
+
+ var addedKeys = new List();
+ foreach (var key in requiredKeys)
+ {
+ if (!_keys.Contains(key))
+ {
+ _logger.LogInformation("Adding missing required key: {Key}", key);
+ SetSecret(key, string.Empty);
+ addedKeys.Add(key);
+ }
+ }
+
+ if (addedKeys.Count > 0)
+ {
+ Save();
+ }
+
+ return addedKeys.AsReadOnly();
+ }
+
///
public void GenerateKeyFile(string path)
{
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs
index 5b32dfd..9b99de5 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs
@@ -9,45 +9,25 @@ public class ExcelExportFormViewModel : ViewModelBase
{
private readonly ExcelExportSection _model;
private readonly Action _onChanged;
+ private string _selectedTimezone;
+
+ ///
+ /// Gets the list of available system timezones (IANA format for cross-platform compatibility).
+ ///
+ public static IReadOnlyList AvailableTimezones { get; } = TimeZoneInfo
+ .GetSystemTimeZones()
+ .Select(tz => tz.Id)
+ .OrderBy(id => id)
+ .ToList()
+ .AsReadOnly();
public ExcelExportFormViewModel(ExcelExportSection model, Action onChanged)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
- }
- ///
- /// Gets or sets the password for protecting the criteria worksheet.
- ///
- public string CriteriaSheetPassword
- {
- get => _model.CriteriaSheetPassword;
- set
- {
- if (_model.CriteriaSheetPassword != value)
- {
- _model.CriteriaSheetPassword = value;
- OnPropertyChanged();
- _onChanged();
- }
- }
- }
-
- ///
- /// Gets or sets the password for protecting the data worksheet.
- ///
- public string DataSheetPassword
- {
- get => _model.DataSheetPassword;
- set
- {
- if (_model.DataSheetPassword != value)
- {
- _model.DataSheetPassword = value;
- OnPropertyChanged();
- _onChanged();
- }
- }
+ // Initialize selected timezone from model (model defaults to "America/Chicago")
+ _selectedTimezone = _model.TimezoneId;
}
///
@@ -119,36 +99,20 @@ public class ExcelExportFormViewModel : ViewModelBase
}
///
- /// Gets or sets the time zone identifier for date/time conversions.
+ /// Gets or sets the selected timezone from the dropdown.
///
- public string TimezoneId
+ public string SelectedTimezone
{
- get => _model.TimezoneId;
+ get => _selectedTimezone;
set
{
- if (_model.TimezoneId != value)
+ if (_selectedTimezone != value)
{
+ _selectedTimezone = value;
_model.TimezoneId = value;
OnPropertyChanged();
_onChanged();
}
}
}
-
- ///
- /// Gets or sets the time zone abbreviation for display purposes.
- ///
- public string TimezoneAbbreviation
- {
- get => _model.TimezoneAbbreviation;
- set
- {
- if (_model.TimezoneAbbreviation != value)
- {
- _model.TimezoneAbbreviation = value;
- OnPropertyChanged();
- _onChanged();
- }
- }
- }
}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs
new file mode 100644
index 0000000..f8812f1
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs
@@ -0,0 +1,40 @@
+namespace JdeScoping.ConfigManager.ViewModels.Forms;
+
+///
+/// View model for the SecureStore info panel shown when the store node is selected.
+///
+public class SecureStoreInfoFormViewModel : ViewModelBase
+{
+ ///
+ /// Gets the instruction text to display.
+ ///
+ public string InstructionText => "Select a secret from the tree to view or edit its value.";
+
+ ///
+ /// Gets the store path for display.
+ ///
+ public string StorePath { get; }
+
+ ///
+ /// Gets the key file path for display.
+ ///
+ public string KeyFilePath { get; }
+
+ ///
+ /// Gets the number of secrets in the store.
+ ///
+ public int SecretCount { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path to the secure store file.
+ /// The path to the key file.
+ /// The number of secrets in the store.
+ public SecureStoreInfoFormViewModel(string storePath, string keyFilePath, int secretCount)
+ {
+ StorePath = storePath;
+ KeyFilePath = keyFilePath;
+ SecretCount = secretCount;
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs
deleted file mode 100644
index 47f57bb..0000000
--- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System.Windows.Input;
-
-namespace JdeScoping.ConfigManager.ViewModels.Forms;
-
-///
-/// ViewModel for displaying a locked secure store with unlock capability.
-///
-public class SecureStoreLockedFormViewModel : ViewModelBase
-{
- private readonly Action _onUnlockRequested;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the secure store.
- /// The full path to the secure store file.
- /// The last modified date of the store file.
- /// The action to invoke when unlock is requested.
- public SecureStoreLockedFormViewModel(
- string storeName,
- string storePath,
- DateTime? lastModified,
- Action onUnlockRequested)
- {
- StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName));
- StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
- LastModified = lastModified;
- _onUnlockRequested = onUnlockRequested ?? throw new ArgumentNullException(nameof(onUnlockRequested));
-
- UnlockCommand = new RelayCommand(_onUnlockRequested);
- }
-
- ///
- /// Gets the name of the secure store.
- ///
- public string StoreName { get; }
-
- ///
- /// Gets the full path to the secure store file.
- ///
- public string StorePath { get; }
-
- ///
- /// Gets the last modified date of the store file.
- ///
- public DateTime? LastModified { get; }
-
- ///
- /// Gets the command to unlock the store.
- ///
- public ICommand UnlockCommand { get; }
-}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs
deleted file mode 100644
index 9b41796..0000000
--- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System.Windows.Input;
-
-namespace JdeScoping.ConfigManager.ViewModels.Forms;
-
-///
-/// ViewModel for displaying an unlocked secure store.
-///
-public class SecureStoreUnlockedFormViewModel : ViewModelBase
-{
- private readonly Action _onLockRequested;
- private readonly Action _onAddSecretRequested;
- private readonly Action _onSaveRequested;
- private bool _hasUnsavedChanges;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the secure store.
- /// The full path to the secure store file.
- /// The number of secrets in the store.
- /// Whether the store has unsaved changes.
- /// The action to invoke when lock is requested.
- /// The action to invoke when adding a new secret is requested.
- /// The action to invoke when save is requested.
- public SecureStoreUnlockedFormViewModel(
- string storeName,
- string storePath,
- int secretCount,
- bool hasUnsavedChanges,
- Action onLockRequested,
- Action onAddSecretRequested,
- Action onSaveRequested)
- {
- StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName));
- StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
- SecretCount = secretCount;
- _hasUnsavedChanges = hasUnsavedChanges;
- _onLockRequested = onLockRequested ?? throw new ArgumentNullException(nameof(onLockRequested));
- _onAddSecretRequested = onAddSecretRequested ?? throw new ArgumentNullException(nameof(onAddSecretRequested));
- _onSaveRequested = onSaveRequested ?? throw new ArgumentNullException(nameof(onSaveRequested));
-
- LockCommand = new RelayCommand(_onLockRequested);
- AddSecretCommand = new RelayCommand(_onAddSecretRequested);
- SaveCommand = new RelayCommand(_onSaveRequested, () => HasUnsavedChanges);
- }
-
- ///
- /// Gets the name of the secure store.
- ///
- public string StoreName { get; }
-
- ///
- /// Gets the full path to the secure store file.
- ///
- public string StorePath { get; }
-
- ///
- /// Gets the number of secrets in the store.
- ///
- public int SecretCount { get; }
-
- ///
- /// Gets or sets whether the store has unsaved changes.
- ///
- public bool HasUnsavedChanges
- {
- get => _hasUnsavedChanges;
- set
- {
- if (SetProperty(ref _hasUnsavedChanges, value))
- {
- // Notify that SaveCommand's CanExecute may have changed
- (SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
- }
- }
- }
-
- ///
- /// Gets the command to lock the store.
- ///
- public ICommand LockCommand { get; }
-
- ///
- /// Gets the command to add a new secret.
- ///
- public ICommand AddSecretCommand { get; }
-
- ///
- /// Gets the command to save the store.
- ///
- public ICommand SaveCommand { get; }
-}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
index d923825..4d1fef2 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
@@ -37,10 +37,6 @@ public class MainWindowViewModel : ViewModelBase
private ConfigModel? _appSettings;
private PipelinesConfigModel? _pipelines;
- // SecureStore state tracking
- private readonly Dictionary _openStores = new();
- private TreeNodeViewModel? _selectedStoreNode;
-
///
/// Gets or sets the currently loaded configuration folder path.
///
@@ -149,26 +145,11 @@ public class MainWindowViewModel : ViewModelBase
///
public ICommand AddExistingStoreCommand { get; }
- ///
- /// Gets the command for unlocking a secure store.
- ///
- public ICommand UnlockStoreCommand { get; }
-
- ///
- /// Gets the command for locking a secure store.
- ///
- public ICommand LockStoreCommand { get; }
-
///
/// Gets the command for saving a secure store.
///
public ICommand SaveStoreCommand { get; }
- ///
- /// Gets the command for locking all open secure stores.
- ///
- public ICommand LockAllStoresCommand { get; }
-
///
/// Gets the command for generating a new key file.
///
@@ -246,10 +227,7 @@ public class MainWindowViewModel : ViewModelBase
// SecureStore commands
NewStoreCommand = new AsyncRelayCommand(NewStoreAsync);
AddExistingStoreCommand = new AsyncRelayCommand(AddExistingStoreAsync);
- UnlockStoreCommand = new AsyncRelayCommand(UnlockStoreAsync, CanUnlockStore);
- LockStoreCommand = new RelayCommand(LockStore, CanLockStore);
SaveStoreCommand = new AsyncRelayCommand(SaveStoreAsync, CanSaveStore);
- LockAllStoresCommand = new RelayCommand(LockAllStores, () => _openStores.Count > 0);
GenerateKeyFileCommand = new AsyncRelayCommand(GenerateKeyFileAsync);
AddSecretCommand = new AsyncRelayCommand(AddSecretAsync, CanAddSecret);
DeleteSecretCommand = new AsyncRelayCommand(DeleteSecretAsync, CanDeleteSecret);
@@ -300,69 +278,114 @@ public class MainWindowViewModel : ViewModelBase
if (folder != null)
{
await LoadConfigAsync(folder);
- await EnsureDefaultSecureStoreAsync(folder);
}
}
///
- /// Ensures a default secure store exists and is loaded.
- /// Creates one if it doesn't exist.
+ /// Initializes the SecureStore automatically on config load.
+ /// Creates the store if it doesn't exist and AutoCreateStore is true.
+ /// Opens the store and ensures all required keys exist.
///
- private async Task EnsureDefaultSecureStoreAsync(string configFolder)
+ private async Task InitializeSecureStoreAsync()
{
- var defaultStorePath = Path.Combine(configFolder, "default.secrets.json");
- var defaultKeyPath = Path.Combine(configFolder, "default.secrets.key");
+ if (_appSettings?.SecureStore == null)
+ return;
+
+ if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
+ return;
+
+ var secureStoreConfig = _appSettings.SecureStore;
+
+ // Resolve paths relative to config folder
+ var storePath = Path.IsPathRooted(secureStoreConfig.StorePath)
+ ? secureStoreConfig.StorePath
+ : Path.Combine(ConfigFolderPath, secureStoreConfig.StorePath);
+
+ var keyFilePath = Path.IsPathRooted(secureStoreConfig.KeyFilePath)
+ ? secureStoreConfig.KeyFilePath
+ : Path.Combine(ConfigFolderPath, secureStoreConfig.KeyFilePath);
try
{
- // Create default store if it doesn't exist
- if (!File.Exists(defaultStorePath))
+ if (!File.Exists(storePath))
{
- _logger?.LogInformation("Creating default secure store at {Path}", defaultStorePath);
+ if (!secureStoreConfig.AutoCreateStore)
+ {
+ _logger?.LogWarning("SecureStore not found and AutoCreateStore is false");
+ return;
+ }
- _secureStoreManager.CreateStore(defaultStorePath, defaultKeyPath);
+ // Create new store with keyfile
+ _logger?.LogInformation("Creating SecureStore at {StorePath}", storePath);
+ _secureStoreManager.CreateStore(storePath, keyFilePath);
- // Add some example secrets
- _secureStoreManager.SetSecret("jde-password", "");
- _secureStoreManager.SetSecret("cms-password", "");
- _secureStoreManager.SetSecret("lotfinder-password", "");
- _secureStoreManager.Save();
- _secureStoreManager.CloseStore();
+ // Update appsettings.json with actual paths
+ secureStoreConfig.StorePath = GetRelativePath(ConfigFolderPath, storePath);
+ secureStoreConfig.KeyFilePath = GetRelativePath(ConfigFolderPath, keyFilePath);
+ await SaveAppSettingsAsync();
+ }
+ else
+ {
+ // Open existing store
+ if (!File.Exists(keyFilePath))
+ {
+ if (_dialogService != null)
+ {
+ await _dialogService.ShowMessageAsync(
+ "SecureStore Error",
+ $"Key file not found: {keyFilePath}\n\nThe SecureStore cannot be opened without its key file.");
+ }
+ return;
+ }
- // Rebuild tree to show the new store
- BuildTreeNodes();
+ _secureStoreManager.OpenStore(storePath, keyFilePath);
}
- // Auto-unlock the default store if key file exists
- if (File.Exists(defaultStorePath) && File.Exists(defaultKeyPath))
+ // Ensure all required keys exist
+ if (secureStoreConfig.RequiredKeys?.Count > 0)
{
- // Find the default store node in the tree
- var secureStoresFolder = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStoresFolder);
- var defaultStoreNode = secureStoresFolder?.Children.FirstOrDefault(n =>
- n.StorePath != null && n.StorePath.EndsWith("default.secrets.json"));
-
- if (defaultStoreNode != null)
+ var addedKeys = _secureStoreManager.EnsureRequiredKeys(secureStoreConfig.RequiredKeys);
+ if (addedKeys.Count > 0)
{
- _secureStoreManager.OpenStore(defaultStorePath, defaultKeyPath);
- defaultStoreNode.IsUnlocked = true;
- _openStores[defaultStorePath] = defaultStoreNode;
- RefreshStoreChildren(defaultStoreNode);
- defaultStoreNode.IsExpanded = true;
-
- _logger?.LogInformation("Auto-unlocked default secure store");
+ _logger?.LogInformation("Added {Count} missing required keys", addedKeys.Count);
}
}
}
catch (Exception ex)
{
- _logger?.LogWarning(ex, "Failed to initialize default secure store");
+ _logger?.LogError(ex, "Failed to initialize SecureStore");
+ if (_dialogService != null)
+ {
+ await _dialogService.ShowMessageAsync(
+ "SecureStore Error",
+ $"Failed to initialize SecureStore:\n\n{ex.Message}");
+ }
}
-
- await Task.CompletedTask;
}
///
- /// Opens a folder picker dialog to select a configuration folder.
+ /// Gets the relative path from a base path to a full path.
+ ///
+ private static string GetRelativePath(string basePath, string fullPath)
+ {
+ var baseUri = new Uri(basePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
+ var fullUri = new Uri(fullPath);
+ return Uri.UnescapeDataString(baseUri.MakeRelativeUri(fullUri).ToString().Replace('/', Path.DirectorySeparatorChar));
+ }
+
+ ///
+ /// Saves the appsettings.json file.
+ ///
+ private async Task SaveAppSettingsAsync()
+ {
+ if (_appSettings == null) return;
+
+ var appSettingsPath = Path.Combine(ConfigFolderPath, "appsettings.json");
+ await _configFileService.SaveAppSettingsAsync(appSettingsPath, _appSettings);
+ }
+
+ ///
+ /// Opens a file picker dialog to select a configuration file.
///
private async Task OpenFolderAsync()
{
@@ -372,10 +395,14 @@ public class MainWindowViewModel : ViewModelBase
return;
}
- var folder = await _dialogService.ShowFolderPickerAsync("Select Configuration Folder");
- if (folder != null)
+ var filePath = await _dialogService.ShowFilePickerAsync("Select Configuration File");
+ if (filePath != null)
{
- await LoadConfigAsync(folder);
+ var folder = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(folder))
+ {
+ await LoadConfigAsync(folder);
+ }
}
}
@@ -390,15 +417,20 @@ public class MainWindowViewModel : ViewModelBase
ConfigFolderPath = folderPath;
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
- var pipelinesPath = Path.Combine(folderPath, "Pipelines", "pipelines.json");
-
_appSettings = await _configFileService.LoadAppSettingsAsync(appSettingsPath);
+ // Use config-driven pipeline path
+ var pipelinesConfigPath = _appSettings?.Pipelines?.ConfigPath ?? "Pipelines/pipelines.json";
+ var pipelinesPath = Path.Combine(folderPath, pipelinesConfigPath);
+
if (File.Exists(pipelinesPath))
{
_pipelines = await _configFileService.LoadPipelinesAsync(pipelinesPath);
}
+ // Initialize SecureStore (auto-create if needed, open, sync required keys)
+ await InitializeSecureStoreAsync();
+
BuildTreeNodes();
Validate();
@@ -438,48 +470,55 @@ public class MainWindowViewModel : ViewModelBase
}
TreeNodes.Add(pipelinesFolder);
- // Secure Stores folder
- var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "🔑", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
- DiscoverSecureStores(secureStoresFolder);
- TreeNodes.Add(secureStoresFolder);
+ // Secure Store node (single store, secrets as direct children)
+ var secureStoreNode = CreateSecureStoreNode();
+ if (secureStoreNode != null)
+ {
+ TreeNodes.Add(secureStoreNode);
+ }
}
///
- /// Discovers existing secure store files in the configuration folder.
+ /// Creates the SecureStore tree node with secrets as direct children.
///
- /// The parent tree node to add discovered stores to.
- private void DiscoverSecureStores(TreeNodeViewModel parentNode)
+ private TreeNodeViewModel? CreateSecureStoreNode()
{
+ if (_appSettings?.SecureStore == null)
+ return null;
+
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
- return;
+ return null;
- try
+ var storePath = Path.IsPathRooted(_appSettings.SecureStore.StorePath)
+ ? _appSettings.SecureStore.StorePath
+ : Path.Combine(ConfigFolderPath, _appSettings.SecureStore.StorePath);
+
+ var keyFilePath = Path.IsPathRooted(_appSettings.SecureStore.KeyFilePath)
+ ? _appSettings.SecureStore.KeyFilePath
+ : Path.Combine(ConfigFolderPath, _appSettings.SecureStore.KeyFilePath);
+
+ var storeNode = new TreeNodeViewModel("Secure Store", "🔑", TreeNodeType.SecureStore)
{
- // Look for *.secrets.json files in the config folder
- var secretsFiles = Directory.GetFiles(ConfigFolderPath, "*.secrets.json", SearchOption.TopDirectoryOnly);
+ StorePath = storePath,
+ KeyFilePath = keyFilePath,
+ SectionKey = storePath,
+ IsExpanded = true
+ };
- foreach (var filePath in secretsFiles)
+ // Add secrets as direct children if store is open
+ if (_secureStoreManager.IsStoreOpen)
+ {
+ foreach (var key in _secureStoreManager.GetKeys().OrderBy(k => k))
{
- var fileName = Path.GetFileName(filePath);
- var storeName = Path.GetFileNameWithoutExtension(fileName);
- if (storeName.EndsWith(".secrets"))
- storeName = storeName[..^8]; // Remove ".secrets" suffix for display
-
- var storeNode = new TreeNodeViewModel(storeName, "🔒", TreeNodeType.SecureStore)
+ var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
{
- StorePath = filePath,
- SectionKey = filePath,
- IsUnlocked = false
+ SecretKey = key
};
- parentNode.Children.Add(storeNode);
+ storeNode.Children.Add(secretNode);
}
+ }
- _logger?.LogDebug("Discovered {Count} secure store files", secretsFiles.Length);
- }
- catch (Exception ex)
- {
- _logger?.LogWarning(ex, "Failed to discover secure store files");
- }
+ return storeNode;
}
///
@@ -492,34 +531,18 @@ public class MainWindowViewModel : ViewModelBase
if (_selectedNode == null)
{
SelectedFormViewModel = null;
- _selectedStoreNode = null;
return;
}
// Handle SecureStore-related node types first
switch (_selectedNode.NodeType)
{
- case TreeNodeType.SecureStoresFolder:
- SelectedFormViewModel = null; // Show empty state or instructions
- _selectedStoreNode = null;
- return;
-
case TreeNodeType.SecureStore:
- _selectedStoreNode = _selectedNode;
- if (_selectedNode.IsUnlocked)
- {
- SelectedFormViewModel = CreateUnlockedStoreFormViewModel(_selectedNode);
- }
- else
- {
- SelectedFormViewModel = CreateLockedStoreFormViewModel(_selectedNode);
- }
+ SelectedFormViewModel = CreateSecureStoreInfoFormViewModel();
RaiseSecureStoreCommandsCanExecuteChanged();
return;
case TreeNodeType.Secret:
- // Find the parent store node
- _selectedStoreNode = FindParentStoreNode(_selectedNode);
SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode);
RaiseSecureStoreCommandsCanExecuteChanged();
return;
@@ -529,11 +552,9 @@ public class MainWindowViewModel : ViewModelBase
if (_appSettings == null)
{
SelectedFormViewModel = null;
- _selectedStoreNode = null;
return;
}
- _selectedStoreNode = null;
SelectedFormViewModel = _selectedNode.SectionKey switch
{
"DataSync" => new DataSyncFormViewModel(_appSettings.DataSync, MarkAsChanged),
@@ -552,39 +573,15 @@ public class MainWindowViewModel : ViewModelBase
}
///
- /// Creates a form view model for a locked secure store.
+ /// Creates a form view model for the SecureStore info panel.
///
- private SecureStoreLockedFormViewModel CreateLockedStoreFormViewModel(TreeNodeViewModel storeNode)
+ private SecureStoreInfoFormViewModel CreateSecureStoreInfoFormViewModel()
{
- DateTime? lastModified = null;
- if (!string.IsNullOrEmpty(storeNode.StorePath) && File.Exists(storeNode.StorePath))
- {
- lastModified = File.GetLastWriteTime(storeNode.StorePath);
- }
+ var storePath = _appSettings?.SecureStore?.StorePath ?? "Unknown";
+ var keyFilePath = _appSettings?.SecureStore?.KeyFilePath ?? "Unknown";
+ var secretCount = _secureStoreManager.IsStoreOpen ? _secureStoreManager.GetKeys().Count : 0;
- return new SecureStoreLockedFormViewModel(
- storeNode.Name,
- storeNode.StorePath ?? string.Empty,
- lastModified,
- () => _ = UnlockStoreAsync());
- }
-
- ///
- /// Creates a form view model for an unlocked secure store.
- ///
- private SecureStoreUnlockedFormViewModel CreateUnlockedStoreFormViewModel(TreeNodeViewModel storeNode)
- {
- var secretCount = storeNode.Children.Count;
- var hasUnsavedChanges = _secureStoreManager.HasUnsavedChanges;
-
- return new SecureStoreUnlockedFormViewModel(
- storeNode.Name,
- storeNode.StorePath ?? string.Empty,
- secretCount,
- hasUnsavedChanges,
- () => LockStore(),
- () => _ = AddSecretAsync(),
- () => _ = SaveStoreAsync());
+ return new SecureStoreInfoFormViewModel(storePath, keyFilePath, secretCount);
}
///
@@ -628,34 +625,12 @@ public class MainWindowViewModel : ViewModelBase
}
}
- ///
- /// Finds the parent store node for a secret node.
- ///
- private TreeNodeViewModel? FindParentStoreNode(TreeNodeViewModel secretNode)
- {
- foreach (var rootNode in TreeNodes)
- {
- if (rootNode.NodeType == TreeNodeType.SecureStoresFolder)
- {
- foreach (var storeNode in rootNode.Children)
- {
- if (storeNode.Children.Contains(secretNode))
- return storeNode;
- }
- }
- }
- return null;
- }
-
///
/// Raises CanExecuteChanged for all SecureStore commands.
///
private void RaiseSecureStoreCommandsCanExecuteChanged()
{
- (UnlockStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
- (LockStoreCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
- (LockAllStoresCommand as RelayCommand)?.RaiseCanExecuteChanged();
(AddSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
}
@@ -705,7 +680,8 @@ public class MainWindowViewModel : ViewModelBase
// Save pipelines if loaded
if (_pipelines != null)
{
- var pipelinesPath = Path.Combine(ConfigFolderPath, "Pipelines", "pipelines.json");
+ var pipelinesConfigPath = _appSettings?.Pipelines?.ConfigPath ?? "Pipelines/pipelines.json";
+ var pipelinesPath = Path.Combine(ConfigFolderPath, pipelinesConfigPath);
if (File.Exists(pipelinesPath))
{
await _backupService.CreateBackupAsync(pipelinesPath);
@@ -932,33 +908,12 @@ public class MainWindowViewModel : ViewModelBase
#region SecureStore Commands
- ///
- /// Determines whether a store can be unlocked.
- ///
- private bool CanUnlockStore()
- {
- return _selectedStoreNode != null
- && _selectedStoreNode.NodeType == TreeNodeType.SecureStore
- && !_selectedStoreNode.IsUnlocked;
- }
-
- ///
- /// Determines whether a store can be locked.
- ///
- private bool CanLockStore()
- {
- return _selectedStoreNode != null
- && _selectedStoreNode.NodeType == TreeNodeType.SecureStore
- && _selectedStoreNode.IsUnlocked;
- }
-
///
/// Determines whether the current store can be saved.
///
private bool CanSaveStore()
{
- return _selectedStoreNode != null
- && _selectedStoreNode.IsUnlocked
+ return _secureStoreManager.IsStoreOpen
&& _secureStoreManager.HasUnsavedChanges;
}
@@ -967,8 +922,7 @@ public class MainWindowViewModel : ViewModelBase
///
private bool CanAddSecret()
{
- return _selectedStoreNode != null
- && _selectedStoreNode.IsUnlocked;
+ return _secureStoreManager.IsStoreOpen;
}
///
@@ -978,8 +932,7 @@ public class MainWindowViewModel : ViewModelBase
{
return _selectedNode != null
&& _selectedNode.NodeType == TreeNodeType.Secret
- && _selectedStoreNode != null
- && _selectedStoreNode.IsUnlocked;
+ && _secureStoreManager.IsStoreOpen;
}
///
@@ -1018,105 +971,6 @@ public class MainWindowViewModel : ViewModelBase
"File picker for existing stores not yet implemented.");
}
- ///
- /// Unlocks the currently selected secure store.
- ///
- private async Task UnlockStoreAsync()
- {
- if (_selectedStoreNode == null || string.IsNullOrEmpty(_selectedStoreNode.StorePath))
- return;
-
- if (_dialogService == null)
- {
- _logger?.LogWarning("Dialog service is not available");
- return;
- }
-
- // Note: In a full implementation, this would show the UnlockStoreDialog
- // For now, we simulate with a simple confirmation
- var confirmed = await _dialogService.ShowConfirmationAsync(
- "Unlock Store",
- $"Enter credentials to unlock '{_selectedStoreNode.Name}'.\n\n(Dialog not yet implemented - this is a placeholder)");
-
- if (!confirmed)
- return;
-
- // In a real implementation, we would get the key file path or password from the dialog
- // For now, we look for a .key file with the same name
- var keyFilePath = Path.ChangeExtension(_selectedStoreNode.StorePath, ".key");
- if (!File.Exists(keyFilePath))
- {
- // Try alternate pattern: storename.secrets.key
- keyFilePath = _selectedStoreNode.StorePath.Replace(".secrets.json", ".secrets.key");
- }
-
- if (!File.Exists(keyFilePath))
- {
- await _dialogService.ShowMessageAsync(
- SecureStoreStrings.ErrorTitle,
- $"Key file not found. Expected at:\n{keyFilePath}");
- return;
- }
-
- try
- {
- _secureStoreManager.OpenStore(_selectedStoreNode.StorePath, keyFilePath);
- _selectedStoreNode.IsUnlocked = true;
- _openStores[_selectedStoreNode.StorePath] = _selectedStoreNode;
-
- // Populate secret children
- RefreshStoreChildren(_selectedStoreNode);
- _selectedStoreNode.IsExpanded = true;
-
- // Refresh the form view
- OnSelectedNodeChanged();
-
- _logger?.LogInformation("Store unlocked: {StorePath}", _selectedStoreNode.StorePath);
- }
- catch (Exception ex)
- {
- _logger?.LogError(ex, "Failed to unlock store: {StorePath}", _selectedStoreNode.StorePath);
- await _dialogService.ShowMessageAsync(
- SecureStoreStrings.ErrorTitle,
- string.Format(SecureStoreStrings.FailedToOpenStoreFormat, ex.Message));
- }
- }
-
- ///
- /// Locks the currently selected secure store.
- ///
- private void LockStore()
- {
- if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
- return;
-
- LockStoreInternal(_selectedStoreNode);
- OnSelectedNodeChanged();
- }
-
- ///
- /// Internal method to lock a store and clean up.
- ///
- private void LockStoreInternal(TreeNodeViewModel storeNode)
- {
- if (storeNode.StorePath != null)
- {
- _openStores.Remove(storeNode.StorePath);
- }
-
- // Check if this is the currently open store in the manager
- if (_secureStoreManager.IsStoreOpen &&
- _secureStoreManager.CurrentStorePath == storeNode.StorePath)
- {
- _secureStoreManager.CloseStore();
- }
-
- storeNode.IsUnlocked = false;
- storeNode.Children.Clear();
-
- _logger?.LogInformation("Store locked: {StorePath}", storeNode.StorePath);
- }
-
///
/// Saves the currently open secure store.
///
@@ -1147,21 +1001,6 @@ public class MainWindowViewModel : ViewModelBase
}
}
- ///
- /// Locks all open secure stores.
- ///
- private void LockAllStores()
- {
- var openStoresCopy = _openStores.Values.ToList();
- foreach (var storeNode in openStoresCopy)
- {
- LockStoreInternal(storeNode);
- }
-
- OnSelectedNodeChanged();
- _logger?.LogInformation("All stores locked");
- }
-
///
/// Generates a new key file.
///
@@ -1184,7 +1023,7 @@ public class MainWindowViewModel : ViewModelBase
///
private async Task AddSecretAsync()
{
- if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
+ if (!_secureStoreManager.IsStoreOpen)
return;
if (_dialogService == null)
@@ -1227,13 +1066,14 @@ public class MainWindowViewModel : ViewModelBase
{
_secureStoreManager.RemoveSecret(_selectedNode.SecretKey);
- // Remove from tree
- if (_selectedStoreNode != null)
+ // Find the Secure Store node and remove the secret from its children
+ var secureStoreNode = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStore);
+ if (secureStoreNode != null)
{
- _selectedStoreNode.Children.Remove(_selectedNode);
+ secureStoreNode.Children.Remove(_selectedNode);
// Select the parent store node
- SelectedNode = _selectedStoreNode;
+ SelectedNode = secureStoreNode;
}
_logger?.LogInformation("Secret deleted: {Key}", _selectedNode.SecretKey);
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs
index 0a4415a..0ebff19 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs
@@ -7,8 +7,7 @@ public enum TreeNodeType
Folder,
SettingsSection,
Pipeline,
- SecureStoresFolder, // The "Secure Stores" folder
- SecureStore, // Individual store files
+ SecureStore, // The single secure store node
Secret // Individual secrets within a store
}
@@ -29,7 +28,6 @@ public class TreeNodeViewModel : ViewModelBase
private bool _isExpanded;
private bool _isSelected;
private ValidationState _validationState = ValidationState.Unknown;
- private bool _isUnlocked;
///
/// Gets the name of the tree node.
@@ -51,39 +49,18 @@ public class TreeNodeViewModel : ViewModelBase
///
public string? SectionKey { get; init; }
- ///
- /// Gets or sets whether this secure store is currently unlocked.
- /// Only applicable for SecureStore node types.
- ///
- public bool IsUnlocked
- {
- get => _isUnlocked;
- set
- {
- if (SetProperty(ref _isUnlocked, value))
- {
- OnPropertyChanged(nameof(LockIcon));
- OnPropertyChanged(nameof(IsLocked));
- }
- }
- }
-
- ///
- /// Gets whether this secure store is locked.
- ///
- public bool IsLocked => !IsUnlocked;
-
- ///
- /// Gets the lock icon for secure store nodes.
- ///
- public string LockIcon => IsUnlocked ? "🔓" : "🔒";
-
///
/// Gets or sets the full path to the secure store file.
/// Only applicable for SecureStore node types.
///
public string? StorePath { get; init; }
+ ///
+ /// Gets or sets the full path to the key file for the secure store.
+ /// Only applicable for SecureStore node types.
+ ///
+ public string? KeyFilePath { get; init; }
+
///
/// Gets or sets the secret key name.
/// Only applicable for Secret node types.
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml
index d307a6e..53dc858 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml
@@ -13,43 +13,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -92,31 +55,17 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml
new file mode 100644
index 0000000..94f0731
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs
similarity index 52%
rename from NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs
rename to NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs
index 140275e..d318338 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs
@@ -2,9 +2,9 @@ using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Forms;
-public partial class SecureStoreLockedFormView : UserControl
+public partial class SecureStoreInfoFormView : UserControl
{
- public SecureStoreLockedFormView()
+ public SecureStoreInfoFormView()
{
InitializeComponent();
}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml
deleted file mode 100644
index 8d0e5d6..0000000
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml
deleted file mode 100644
index 4d5aec3..0000000
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml
+++ /dev/null
@@ -1,125 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs
deleted file mode 100644
index 8b9e3d1..0000000
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Avalonia.Controls;
-
-namespace JdeScoping.ConfigManager.Views.Forms;
-
-public partial class SecureStoreUnlockedFormView : UserControl
-{
- public SecureStoreUnlockedFormView()
- {
- InitializeComponent();
- }
-}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs
index 21dc9b7..f2f5d18 100644
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs
@@ -59,31 +59,6 @@ public class SecureStoreManagerTests : IDisposable
File.Exists(keyPath).ShouldBeTrue();
}
- [Fact]
- public void CreateStoreWithPassword_CreatesStore()
- {
- // Arrange
- var storePath = Path.Combine(_testDirectory, "test.json");
-
- // Act
- _sut.CreateStoreWithPassword(storePath, "testpassword123");
-
- // Assert
- _sut.IsStoreOpen.ShouldBeTrue();
- _sut.CurrentStorePath.ShouldBe(storePath);
- File.Exists(storePath).ShouldBeTrue();
- }
-
- [Fact]
- public void CreateStoreWithPassword_WithEmptyPassword_ThrowsArgumentException()
- {
- // Arrange
- var storePath = Path.Combine(_testDirectory, "test.json");
-
- // Act & Assert
- Should.Throw(() => _sut.CreateStoreWithPassword(storePath, ""));
- }
-
[Fact]
public void OpenStore_WithValidKeyFile_OpensStore()
{
@@ -112,22 +87,6 @@ public class SecureStoreManagerTests : IDisposable
Should.Throw(() => _sut.OpenStore(storePath, keyPath));
}
- [Fact]
- public void OpenStoreWithPassword_OpensStore()
- {
- // Arrange
- var storePath = Path.Combine(_testDirectory, "test.json");
- var password = "testpassword123";
- _sut.CreateStoreWithPassword(storePath, password);
- _sut.CloseStore();
-
- // Act
- _sut.OpenStoreWithPassword(storePath, password);
-
- // Assert
- _sut.IsStoreOpen.ShouldBeTrue();
- }
-
[Fact]
public void CloseStore_ClosesOpenStore()
{
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs
index b8a2321..508b37a 100644
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs
@@ -11,28 +11,45 @@ public class ExcelExportFormViewModelTests
// Arrange
var model = new ExcelExportSection
{
- CriteriaSheetPassword = "criteriaPass123",
- DataSheetPassword = "dataPass456",
MaxRowsPerSheet = 500000,
DefaultDateFormat = "MM/dd/yyyy",
DebugWriteToFile = true,
DebugOutputDirectory = "/tmp/debug",
- TimezoneId = "America/New_York",
- TimezoneAbbreviation = "ET"
+ TimezoneId = "America/Los_Angeles"
};
// Act
var sut = new ExcelExportFormViewModel(model, () => { });
// Assert
- sut.CriteriaSheetPassword.ShouldBe("criteriaPass123");
- sut.DataSheetPassword.ShouldBe("dataPass456");
sut.MaxRowsPerSheet.ShouldBe(500000);
sut.DefaultDateFormat.ShouldBe("MM/dd/yyyy");
sut.DebugWriteToFile.ShouldBeTrue();
sut.DebugOutputDirectory.ShouldBe("/tmp/debug");
- sut.TimezoneId.ShouldBe("America/New_York");
- sut.TimezoneAbbreviation.ShouldBe("ET");
+ sut.SelectedTimezone.ShouldBe("America/Los_Angeles");
+ }
+
+ [Fact]
+ public void Constructor_UsesModelTimezone()
+ {
+ // Arrange - model defaults to "America/Chicago"
+ var model = new ExcelExportSection();
+
+ // Act
+ var sut = new ExcelExportFormViewModel(model, () => { });
+
+ // Assert
+ sut.SelectedTimezone.ShouldBe("America/Chicago");
+ }
+
+ [Fact]
+ public void AvailableTimezones_ContainsSystemTimezones()
+ {
+ // Act & Assert
+ ExcelExportFormViewModel.AvailableTimezones.ShouldNotBeEmpty();
+ // Check for common IANA timezones
+ ExcelExportFormViewModel.AvailableTimezones.ShouldContain("America/Chicago");
+ ExcelExportFormViewModel.AvailableTimezones.ShouldContain("UTC");
}
[Fact]
@@ -49,16 +66,30 @@ public class ExcelExportFormViewModelTests
model.MaxRowsPerSheet.ShouldBe(750000);
}
+ [Fact]
+ public void SelectedTimezone_UpdatesModelTimezoneId()
+ {
+ // Arrange - model defaults to "America/Chicago"
+ var model = new ExcelExportSection();
+ var sut = new ExcelExportFormViewModel(model, () => { });
+
+ // Act - change to a different timezone
+ sut.SelectedTimezone = "America/New_York";
+
+ // Assert
+ model.TimezoneId.ShouldBe("America/New_York");
+ }
+
[Fact]
public void PropertyChange_InvokesOnChanged()
{
// Arrange
- var model = new ExcelExportSection();
+ var model = new ExcelExportSection(); // Default TimezoneId is "America/Chicago"
var changedInvoked = false;
var sut = new ExcelExportFormViewModel(model, () => changedInvoked = true);
- // Act
- sut.TimezoneId = "Europe/London";
+ // Act - change to a different timezone than the default
+ sut.SelectedTimezone = "America/Denver";
// Assert
changedInvoked.ShouldBeTrue();
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs
deleted file mode 100644
index 180dc59..0000000
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using JdeScoping.ConfigManager.ViewModels.Forms;
-
-namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
-
-public class SecureStoreLockedFormViewModelTests
-{
- [Fact]
- public void Constructor_SetsPropertiesCorrectly()
- {
- // Arrange
- var lastModified = DateTime.Now;
-
- // Act
- var sut = new SecureStoreLockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- lastModified,
- () => { });
-
- // Assert
- sut.StoreName.ShouldBe("test.secrets");
- sut.StorePath.ShouldBe("/path/to/test.secrets");
- sut.LastModified.ShouldBe(lastModified);
- }
-
- [Fact]
- public void Constructor_WithNullLastModified_SetsNullLastModified()
- {
- // Arrange & Act
- var sut = new SecureStoreLockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- null,
- () => { });
-
- // Assert
- sut.LastModified.ShouldBeNull();
- }
-
- [Fact]
- public void UnlockCommand_InvokesCallback()
- {
- // Arrange
- var unlockCalled = false;
- var sut = new SecureStoreLockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- null,
- () => unlockCalled = true);
-
- // Act
- sut.UnlockCommand.Execute(null);
-
- // Assert
- unlockCalled.ShouldBeTrue();
- }
-
- [Fact]
- public void UnlockCommand_CanExecute_ReturnsTrue()
- {
- // Arrange
- var sut = new SecureStoreLockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- null,
- () => { });
-
- // Act & Assert
- sut.UnlockCommand.CanExecute(null).ShouldBeTrue();
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullStoreName()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreLockedFormViewModel(null!, "/path", null, () => { }));
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullStorePath()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreLockedFormViewModel("test", null!, null, () => { }));
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullUnlockCallback()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreLockedFormViewModel("test", "/path", null, null!));
- }
-
- [Fact]
- public void Properties_AreReadOnly()
- {
- // Arrange
- var sut = new SecureStoreLockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- DateTime.Now,
- () => { });
-
- // Assert - Verify properties are get-only (no setters)
- var storeNameProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StoreName));
- var storePathProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StorePath));
- var lastModifiedProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.LastModified));
-
- storeNameProperty!.CanWrite.ShouldBeFalse();
- storePathProperty!.CanWrite.ShouldBeFalse();
- lastModifiedProperty!.CanWrite.ShouldBeFalse();
- }
-}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs
deleted file mode 100644
index 3c0d284..0000000
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs
+++ /dev/null
@@ -1,327 +0,0 @@
-using JdeScoping.ConfigManager.ViewModels.Forms;
-
-namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
-
-public class SecureStoreUnlockedFormViewModelTests
-{
- [Fact]
- public void Constructor_SetsPropertiesCorrectly()
- {
- // Arrange & Act
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 5,
- false,
- () => { },
- () => { },
- () => { });
-
- // Assert
- sut.StoreName.ShouldBe("test.secrets");
- sut.StorePath.ShouldBe("/path/to/test.secrets");
- sut.SecretCount.ShouldBe(5);
- sut.HasUnsavedChanges.ShouldBeFalse();
- }
-
- [Fact]
- public void Constructor_WithUnsavedChanges_SetsHasUnsavedChanges()
- {
- // Arrange & Act
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 3,
- true,
- () => { },
- () => { },
- () => { });
-
- // Assert
- sut.HasUnsavedChanges.ShouldBeTrue();
- }
-
- [Fact]
- public void HasUnsavedChanges_RaisesPropertyChanged()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => { },
- () => { });
-
- var propertyChangedRaised = false;
- sut.PropertyChanged += (s, e) =>
- {
- if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges))
- propertyChangedRaised = true;
- };
-
- // Act
- sut.HasUnsavedChanges = true;
-
- // Assert
- propertyChangedRaised.ShouldBeTrue();
- }
-
- [Fact]
- public void HasUnsavedChanges_DoesNotRaisePropertyChanged_WhenValueUnchanged()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => { },
- () => { });
-
- var propertyChangedRaised = false;
- sut.PropertyChanged += (s, e) =>
- {
- if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges))
- propertyChangedRaised = true;
- };
-
- // Act
- sut.HasUnsavedChanges = false; // Same as initial value
-
- // Assert
- propertyChangedRaised.ShouldBeFalse();
- }
-
- [Fact]
- public void LockCommand_InvokesCallback()
- {
- // Arrange
- var lockCalled = false;
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => lockCalled = true,
- () => { },
- () => { });
-
- // Act
- sut.LockCommand.Execute(null);
-
- // Assert
- lockCalled.ShouldBeTrue();
- }
-
- [Fact]
- public void AddSecretCommand_InvokesCallback()
- {
- // Arrange
- var addSecretCalled = false;
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => addSecretCalled = true,
- () => { });
-
- // Act
- sut.AddSecretCommand.Execute(null);
-
- // Assert
- addSecretCalled.ShouldBeTrue();
- }
-
- [Fact]
- public void SaveCommand_InvokesCallback()
- {
- // Arrange
- var saveCalled = false;
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- true, // Must have unsaved changes for save to be enabled
- () => { },
- () => { },
- () => saveCalled = true);
-
- // Act
- sut.SaveCommand.Execute(null);
-
- // Assert
- saveCalled.ShouldBeTrue();
- }
-
- [Fact]
- public void SaveCommand_CanExecute_ReturnsFalse_WhenNoUnsavedChanges()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => { },
- () => { });
-
- // Act & Assert
- sut.SaveCommand.CanExecute(null).ShouldBeFalse();
- }
-
- [Fact]
- public void SaveCommand_CanExecute_ReturnsTrue_WhenHasUnsavedChanges()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- true,
- () => { },
- () => { },
- () => { });
-
- // Act & Assert
- sut.SaveCommand.CanExecute(null).ShouldBeTrue();
- }
-
- [Fact]
- public void SaveCommand_CanExecute_Updates_WhenHasUnsavedChangesChanges()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => { },
- () => { });
-
- // Initial state - can't save
- sut.SaveCommand.CanExecute(null).ShouldBeFalse();
-
- // Act
- sut.HasUnsavedChanges = true;
-
- // Assert
- sut.SaveCommand.CanExecute(null).ShouldBeTrue();
- }
-
- [Fact]
- public void SaveCommand_RaisesCanExecuteChanged_WhenHasUnsavedChangesChanges()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => { },
- () => { });
-
- var canExecuteChangedRaised = false;
- sut.SaveCommand.CanExecuteChanged += (s, e) => canExecuteChangedRaised = true;
-
- // Act
- sut.HasUnsavedChanges = true;
-
- // Assert
- canExecuteChangedRaised.ShouldBeTrue();
- }
-
- [Fact]
- public void LockCommand_CanExecute_ReturnsTrue()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => { },
- () => { });
-
- // Act & Assert
- sut.LockCommand.CanExecute(null).ShouldBeTrue();
- }
-
- [Fact]
- public void AddSecretCommand_CanExecute_ReturnsTrue()
- {
- // Arrange
- var sut = new SecureStoreUnlockedFormViewModel(
- "test.secrets",
- "/path/to/test.secrets",
- 0,
- false,
- () => { },
- () => { },
- () => { });
-
- // Act & Assert
- sut.AddSecretCommand.CanExecute(null).ShouldBeTrue();
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullStoreName()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreUnlockedFormViewModel(null!, "/path", 0, false, () => { }, () => { }, () => { }));
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullStorePath()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreUnlockedFormViewModel("test", null!, 0, false, () => { }, () => { }, () => { }));
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullLockCallback()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, null!, () => { }, () => { }));
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullAddSecretCallback()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, null!, () => { }));
- }
-
- [Fact]
- public void Constructor_ThrowsOnNullSaveCallback()
- {
- // Act & Assert
- Should.Throw(() =>
- new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, () => { }, null!));
- }
-
- [Fact]
- public void ReadOnlyProperties_CannotBeModified()
- {
- // Assert - Verify StoreName, StorePath, and SecretCount are get-only
- var storeNameProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StoreName));
- var storePathProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StorePath));
- var secretCountProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.SecretCount));
-
- storeNameProperty!.CanWrite.ShouldBeFalse();
- storePathProperty!.CanWrite.ShouldBeFalse();
- secretCountProperty!.CanWrite.ShouldBeFalse();
- }
-}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs
index a3c74b1..e0fdd5b 100644
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs
@@ -157,7 +157,7 @@ public class MainWindowViewModelTests
// Assert
sut.SelectedFormViewModel.ShouldBeOfType();
- ((ExcelExportFormViewModel)sut.SelectedFormViewModel!).TimezoneId.ShouldBe("America/New_York");
+ ((ExcelExportFormViewModel)sut.SelectedFormViewModel!).SelectedTimezone.ShouldBe("America/New_York");
}
[Fact]
@@ -281,9 +281,11 @@ public class MainWindowViewModelTests
sut.LoadConfigForTesting(config, null);
// Assert
- sut.TreeNodes.Count.ShouldBe(3); // Settings, Pipelines, and Secure Stores folders
+ // Without a configured/open SecureStore, only Settings and Pipelines appear
+ sut.TreeNodes.Count.ShouldBe(2); // Settings, Pipelines (no Secure Store when not configured)
sut.TreeNodes[0].Name.ShouldBe("Settings");
sut.TreeNodes[0].Children.Count.ShouldBe(6); // DataSync, DataAccess, Auth, Ldap, Search, ExcelExport
+ sut.TreeNodes[1].Name.ShouldBe("Pipelines");
}
[Fact]
@@ -309,6 +311,65 @@ public class MainWindowViewModelTests
sut.TreeNodes[1].Children.Count.ShouldBe(2);
}
+ [Fact]
+ public async Task OpenFolderCommand_UsesFilePicker_AndDerivesFolder()
+ {
+ // Arrange
+ var expectedFilePath = "/path/to/folder/appsettings.json";
+ var expectedFolder = "/path/to/folder";
+ var config = new ConfigModel();
+
+ // Ensure auto-discovery doesn't load config
+ _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null);
+ _dialogService.ShowFilePickerAsync(Arg.Any())
+ .Returns(expectedFilePath);
+ _configFileService.LoadAppSettingsAsync(Arg.Any(), Arg.Any())
+ .Returns(config);
+
+ var sut = CreateViewModel();
+ // Wait for constructor async init to complete
+ await Task.Delay(50);
+ _configFileService.ClearReceivedCalls();
+
+ // Act
+ sut.OpenFolderCommand.Execute(null);
+ // Give async command time to complete
+ await Task.Delay(100);
+
+ // Assert
+ await _dialogService.Received(1).ShowFilePickerAsync("Select Configuration File");
+ await _configFileService.Received(1).LoadAppSettingsAsync(
+ Arg.Is(s => s.Contains(expectedFolder)),
+ Arg.Any());
+ sut.ConfigFolderPath.ShouldBe(expectedFolder);
+ }
+
+ [Fact]
+ public async Task OpenFolderCommand_WhenCancelled_DoesNotLoadConfig()
+ {
+ // Arrange
+ // Ensure auto-discovery doesn't load config
+ _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null);
+ _dialogService.ShowFilePickerAsync(Arg.Any())
+ .Returns((string?)null);
+
+ var sut = CreateViewModel();
+ var originalPath = sut.ConfigFolderPath;
+ // Wait for constructor async init to complete
+ await Task.Delay(50);
+ _configFileService.ClearReceivedCalls();
+
+ // Act
+ sut.OpenFolderCommand.Execute(null);
+ // Give async command time to complete
+ await Task.Delay(100);
+
+ // Assert
+ await _dialogService.Received(1).ShowFilePickerAsync(Arg.Any());
+ await _configFileService.DidNotReceive().LoadAppSettingsAsync(Arg.Any(), Arg.Any());
+ sut.ConfigFolderPath.ShouldBe(originalPath);
+ }
+
private MainWindowViewModel CreateViewModel()
{
return new MainWindowViewModel(
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs
index 947e3ae..031011b 100644
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs
@@ -222,7 +222,6 @@ public class TreeNodeViewModelTests
#region SecureStore Node Type Tests
[Theory]
- [InlineData(TreeNodeType.SecureStoresFolder)]
[InlineData(TreeNodeType.SecureStore)]
[InlineData(TreeNodeType.Secret)]
public void Constructor_WithSecureStoreNodeTypes_SetsNodeTypeCorrectly(TreeNodeType nodeType)
@@ -234,119 +233,6 @@ public class TreeNodeViewModelTests
sut.NodeType.ShouldBe(nodeType);
}
- [Fact]
- public void IsUnlocked_DefaultsToFalse()
- {
- // Arrange & Act
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
-
- // Assert
- sut.IsUnlocked.ShouldBeFalse();
- }
-
- [Fact]
- public void IsUnlocked_WhenSet_RaisesPropertyChanged()
- {
- // Arrange
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
- var propertyChangedRaised = false;
- sut.PropertyChanged += (s, e) =>
- {
- if (e.PropertyName == nameof(TreeNodeViewModel.IsUnlocked))
- propertyChangedRaised = true;
- };
-
- // Act
- sut.IsUnlocked = true;
-
- // Assert
- propertyChangedRaised.ShouldBeTrue();
- }
-
- [Fact]
- public void IsUnlocked_WhenSet_RaisesPropertyChangedForLockIcon()
- {
- // Arrange
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
- var lockIconChanged = false;
- sut.PropertyChanged += (s, e) =>
- {
- if (e.PropertyName == nameof(TreeNodeViewModel.LockIcon))
- lockIconChanged = true;
- };
-
- // Act
- sut.IsUnlocked = true;
-
- // Assert
- lockIconChanged.ShouldBeTrue();
- }
-
- [Fact]
- public void LockIcon_WhenLocked_ReturnsLockedIcon()
- {
- // Arrange & Act
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
-
- // Assert
- sut.LockIcon.ShouldBe("🔒");
- }
-
- [Fact]
- public void LockIcon_WhenUnlocked_ReturnsUnlockedIcon()
- {
- // Arrange
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
-
- // Act
- sut.IsUnlocked = true;
-
- // Assert
- sut.LockIcon.ShouldBe("🔓");
- }
-
- [Fact]
- public void IsLocked_WhenUnlocked_ReturnsFalse()
- {
- // Arrange
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
-
- // Act
- sut.IsUnlocked = true;
-
- // Assert
- sut.IsLocked.ShouldBeFalse();
- }
-
- [Fact]
- public void IsLocked_WhenLocked_ReturnsTrue()
- {
- // Arrange & Act
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
-
- // Assert
- sut.IsLocked.ShouldBeTrue();
- }
-
- [Fact]
- public void IsUnlocked_WhenSet_RaisesPropertyChangedForIsLocked()
- {
- // Arrange
- var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
- var isLockedChanged = false;
- sut.PropertyChanged += (s, e) =>
- {
- if (e.PropertyName == nameof(TreeNodeViewModel.IsLocked))
- isLockedChanged = true;
- };
-
- // Act
- sut.IsUnlocked = true;
-
- // Assert
- isLockedChanged.ShouldBeTrue();
- }
-
[Fact]
public void StorePath_CanBeSetViaInitializer()
{
diff --git a/PLANS/2025-01-22-regex-transformer-design.md b/PLANS/2025-01-22-regex-transformer-design.md
new file mode 100644
index 0000000..46f0f4b
--- /dev/null
+++ b/PLANS/2025-01-22-regex-transformer-design.md
@@ -0,0 +1,216 @@
+# Regex Transformer Design
+
+**Date:** 2025-01-22
+**Status:** Approved
+**Author:** Claude + User collaboration
+
+## Overview
+
+Add a new `RegexTransformer` to the DataSync ETL pipeline that transforms string values in a column using regular expressions. Includes a custom editor for the ConfigManager with a live test/preview feature.
+
+## Requirements
+
+1. **Two transformation modes:**
+ - **Find & Replace** - Replace matched text with replacement string
+ - **Match & Extract** - Extract first capture group from pattern
+
+2. **Single column per transformer** - Each transformer operates on one column; add multiple transformers for multiple columns
+
+3. **Configurable non-match behavior:**
+ - Keep original value (default)
+ - Return null
+ - Return empty string
+
+4. **Case-insensitive option** - Optional flag for case-insensitive matching
+
+5. **Test/preview in editor** - Users can test their regex pattern against sample input before saving
+
+## Architecture
+
+### Core Transformer
+
+**File:** `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`
+
+```csharp
+public enum NonMatchBehavior
+{
+ KeepOriginal,
+ ReturnNull,
+ ReturnEmpty
+}
+
+public class RegexTransformer : DataTransformerBase
+{
+ public RegexTransformer(
+ string columnName,
+ string pattern,
+ string? replacement = null, // null = Match & Extract mode
+ bool ignoreCase = false,
+ NonMatchBehavior nonMatchBehavior = NonMatchBehavior.KeepOriginal)
+}
+```
+
+**Behavior:**
+- Extends `DataTransformerBase`
+- In `OnInitialize()`: finds column ordinal, compiles regex
+- Overrides `GetValue()`: transforms target column, passes through others
+- Mode determined by `replacement` parameter: non-null = Find & Replace, null = Match & Extract
+
+**Transformation logic:**
+1. If not target column → pass through
+2. If null/DBNull → pass through
+3. Find & Replace mode: `regex.Replace(value, replacement)`
+4. Match & Extract mode: return `match.Groups[1].Value` if match, else apply `NonMatchBehavior`
+
+### Configuration Model
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs`
+
+Add enum:
+```csharp
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum NonMatchBehavior
+{
+ KeepOriginal,
+ ReturnNull,
+ ReturnEmpty
+}
+```
+
+Add properties to `TransformerModel`:
+```csharp
+public string? ColumnName { get; set; }
+public string? Pattern { get; set; }
+public string? Replacement { get; set; }
+public bool IgnoreCase { get; set; }
+public NonMatchBehavior NonMatchBehavior { get; set; } = NonMatchBehavior.KeepOriginal;
+```
+
+**JSON example:**
+```json
+{
+ "Type": "Regex",
+ "ColumnName": "BatchID",
+ "Pattern": "^IIS_",
+ "Replacement": "",
+ "IgnoreCase": true,
+ "NonMatchBehavior": "KeepOriginal"
+}
+```
+
+### ViewModel
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs`
+
+Add `RegexTransformerViewModel` class:
+
+**Core properties (bound to config):**
+- `ColumnName` (string)
+- `Pattern` (string)
+- `Replacement` (string)
+- `IgnoreCase` (bool)
+- `NonMatchBehavior` (enum)
+
+**Mode properties (computed):**
+- `IsFindReplaceMode` / `IsMatchExtractMode` - for radio button binding
+- `PatternHelpText` - changes based on mode
+
+**Test feature properties:**
+- `TestInput` (string)
+- `TestResultValue` (string)
+- `TestResultLabel` (string) - "Output" or "No Match"
+- `TestResultIcon` (string) - "✓" or "—"
+- `TestResultBackground` (string) - green or orange
+- `HasTestResult` / `HasTestError` (bool)
+- `TestErrorMessage` (string)
+
+**Command:**
+- `TestPatternCommand` - executes regex test
+
+### Editor View
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`
+
+Layout sections:
+1. **Header** - Title "Regex Transformer" + description
+2. **Column Name** - Text input with required indicator
+3. **Mode Selection** - Radio buttons: "Find & Replace" / "Match & Extract"
+4. **Pattern** - Monospace text input with dynamic help text
+5. **Replacement** - Monospace text input (visible only in Find & Replace mode)
+6. **Options Row** - Case Insensitive checkbox + Non-Match Behavior dropdown
+7. **Test Section** - Bordered area with:
+ - Sample input textbox
+ - Test button (blue)
+ - Result display with status icon (green checkmark / orange dash)
+ - Error display (red-tinted) for invalid patterns
+8. **Help Box** - Pattern examples
+
+**Design tokens (matching existing editors):**
+- Background: `#0D0F12`, `#151920`, `#232A35`
+- Text: `#E6EDF5` (bright), `#9BA8B8` (labels), `#5C6A7A` (dim)
+- Accent: `#3B82F6` (blue button), `#22C55E` (success), `#F59E0B` (warning), `#FF6B6B` (error)
+- Font: JetBrains Mono for code fields
+- Spacing: 16px between sections, 4px within field groups
+
+### Registration
+
+**TransformerFactory updates:**
+```csharp
+// Create() switch:
+"regex" => new RegexTransformerViewModel(model, onChanged),
+
+// CreateNew() switch:
+"regex" => new RegexTransformerViewModel(onChanged),
+
+// AvailableTypes:
+["ColumnDrop", "ColumnRename", "JdeDate", "Regex"]
+```
+
+**MainWindow.axaml DataTemplate:**
+```xml
+
+
+
+```
+
+## Testing Strategy
+
+### RegexTransformer Unit Tests
+
+| Test Case | Description |
+|-----------|-------------|
+| `FindReplace_RemovesPrefix` | `^IIS_` + `""` → `IIS_12345` becomes `12345` |
+| `FindReplace_ReplacesWithText` | `foo` + `bar` → `foo123` becomes `bar123` |
+| `FindReplace_UseCaptureGroups` | `(\d+)-(\d+)` + `$2-$1` swaps groups |
+| `MatchExtract_ExtractsFirstGroup` | `ID_(\d+)` extracts `12345` from `ID_12345` |
+| `MatchExtract_NoMatch_KeepOriginal` | Returns original when no match |
+| `MatchExtract_NoMatch_ReturnNull` | Returns DBNull when configured |
+| `MatchExtract_NoMatch_ReturnEmpty` | Returns `""` when configured |
+| `IgnoreCase_MatchesDifferentCase` | `^iis_` matches `IIS_12345` |
+| `NullValue_PassesThrough` | DBNull input returns DBNull |
+| `NonTargetColumn_Unchanged` | Other columns pass through |
+| `InvalidRegex_ThrowsOnInitialize` | Bad pattern throws meaningful exception |
+
+### RegexTransformerViewModel Unit Tests
+
+| Test Case | Description |
+|-----------|-------------|
+| `TestPattern_ValidRegex_ShowsResult` | Test button displays transformed output |
+| `TestPattern_InvalidRegex_ShowsError` | Bad pattern shows error message |
+| `ModeSwitch_UpdatesHelpText` | Help text changes with mode |
+| `ToModel_SerializesCorrectly` | ViewModel produces valid TransformerModel |
+| `FromModel_LoadsAllProperties` | Constructor populates from existing config |
+
+## Files Summary
+
+**Create:**
+- `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs`
+- `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs`
+- `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/RegexTransformerViewModelTests.cs`
+
+**Modify:**
+- `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs` - Add enum + properties
+- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs` - Add ViewModel + factory
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml` - Add DataTemplate
diff --git a/PLANS/2025-01-22-regex-transformer-implementation.md b/PLANS/2025-01-22-regex-transformer-implementation.md
new file mode 100644
index 0000000..4ccfded
--- /dev/null
+++ b/PLANS/2025-01-22-regex-transformer-implementation.md
@@ -0,0 +1,1521 @@
+# Regex Transformer Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add a RegexTransformer to the DataSync ETL pipeline that transforms string column values using regex, with a custom ConfigManager editor featuring live test/preview.
+
+**Architecture:** The transformer extends `DataTransformerBase` and overrides `GetValue()` to apply regex transformations. Supports two modes: Find & Replace (uses `Regex.Replace`) and Match & Extract (extracts first capture group). The ConfigManager gets a new `RegexTransformerViewModel` and `RegexEditorView` with integrated pattern testing.
+
+**Tech Stack:** .NET 10, System.Text.RegularExpressions, Avalonia UI, xUnit + NSubstitute
+
+**Design Doc:** `PLANS/2025-01-22-regex-transformer-design.md`
+
+---
+
+## Task 1: Add NonMatchBehavior Enum to PipelineModel
+
+**Files:**
+- Modify: `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs:181-215`
+
+**Step 1: Add the enum and new properties**
+
+Add after line 215 (after `TransformerModel` class closing brace), then add properties to `TransformerModel`:
+
+```csharp
+// Add this using at top of file:
+using System.Text.Json.Serialization;
+
+// Add this enum after TransformerModel class (line ~216):
+///
+/// Specifies behavior when a regex pattern does not match the input value.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum NonMatchBehavior
+{
+ /// Keep the original value unchanged.
+ KeepOriginal,
+ /// Return null/DBNull.
+ ReturnNull,
+ /// Return an empty string.
+ ReturnEmpty
+}
+
+// Add these properties inside TransformerModel class (after OutputColumn property, ~line 214):
+
+ ///
+ /// Gets or sets the column name for Regex transformer.
+ ///
+ public string? ColumnName { get; set; }
+
+ ///
+ /// Gets or sets the regex pattern for Regex transformer.
+ ///
+ public string? Pattern { get; set; }
+
+ ///
+ /// Gets or sets the replacement string for Regex transformer (null = Match & Extract mode).
+ ///
+ public string? Replacement { get; set; }
+
+ ///
+ /// Gets or sets whether regex matching is case-insensitive.
+ ///
+ public bool IgnoreCase { get; set; }
+
+ ///
+ /// Gets or sets the behavior when regex pattern does not match.
+ ///
+ public NonMatchBehavior NonMatchBehavior { get; set; } = NonMatchBehavior.KeepOriginal;
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs
+git commit -m "$(cat <<'EOF'
+feat(configmanager): add NonMatchBehavior enum and regex properties to TransformerModel
+
+Add configuration model support for the new Regex transformer including:
+- NonMatchBehavior enum with JSON string serialization
+- ColumnName, Pattern, Replacement, IgnoreCase, NonMatchBehavior properties
+EOF
+)"
+```
+
+---
+
+## Task 2: Create RegexTransformer with First Test (Find & Replace)
+
+**Files:**
+- Create: `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`
+- Create: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs`
+
+**Step 1: Write the first failing test**
+
+Create test file:
+
+```csharp
+using System.Data;
+using JdeScoping.DataSync.Etl.Transformers;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Transformers;
+
+public class RegexTransformerTests
+{
+ [Fact]
+ public void FindReplace_RemovesPrefix()
+ {
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "BatchID", "Name" },
+ values: new object[] { "IIS_12345", "Test" });
+
+ var transformer = new RegexTransformer(
+ columnName: "BatchID",
+ pattern: "^IIS_",
+ replacement: "");
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal("12345", reader.GetValue(0));
+ Assert.Equal("Test", reader.GetValue(1)); // Other column unchanged
+ }
+
+ private static IDataReader CreateMockReader(string[] columns, object[] values)
+ {
+ var reader = Substitute.For();
+ reader.FieldCount.Returns(columns.Length);
+ for (int i = 0; i < columns.Length; i++)
+ {
+ var index = i;
+ reader.GetName(index).Returns(columns[index]);
+ reader.GetOrdinal(columns[index]).Returns(index);
+ reader.GetFieldType(index).Returns(values[index]?.GetType() ?? typeof(object));
+ reader.GetValue(index).Returns(values[index]);
+ reader.IsDBNull(index).Returns(values[index] == null || values[index] == DBNull.Value);
+ }
+ return reader;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests.FindReplace_RemovesPrefix" -v n`
+Expected: FAIL - "The type or namespace name 'RegexTransformer' could not be found"
+
+**Step 3: Write minimal implementation**
+
+Create `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`:
+
+```csharp
+using System.Data;
+using System.Text.RegularExpressions;
+
+namespace JdeScoping.DataSync.Etl.Transformers;
+
+///
+/// Specifies behavior when a regex pattern does not match the input value.
+///
+public enum NonMatchBehavior
+{
+ /// Keep the original value unchanged.
+ KeepOriginal,
+ /// Return null/DBNull.
+ ReturnNull,
+ /// Return an empty string.
+ ReturnEmpty
+}
+
+///
+/// A data transformer that applies regex transformations to string values in a column.
+/// Supports two modes: Find & Replace (when replacement is provided) and Match & Extract
+/// (when replacement is null, extracts first capture group).
+///
+public class RegexTransformer : DataTransformerBase
+{
+ private readonly string _columnName;
+ private readonly string _pattern;
+ private readonly string? _replacement;
+ private readonly bool _ignoreCase;
+ private readonly NonMatchBehavior _nonMatchBehavior;
+
+ private Regex? _regex;
+ private int _columnOrdinal = -1;
+
+ ///
+ public override string TransformerName => $"Regex:{_columnName}";
+
+ ///
+ /// Creates a new RegexTransformer.
+ ///
+ /// The column to transform.
+ /// The regex pattern.
+ /// Replacement string for Find & Replace mode, or null for Match & Extract mode.
+ /// Whether to use case-insensitive matching.
+ /// Behavior when pattern does not match.
+ public RegexTransformer(
+ string columnName,
+ string pattern,
+ string? replacement = null,
+ bool ignoreCase = false,
+ NonMatchBehavior nonMatchBehavior = NonMatchBehavior.KeepOriginal)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(columnName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(pattern);
+
+ _columnName = columnName;
+ _pattern = pattern;
+ _replacement = replacement;
+ _ignoreCase = ignoreCase;
+ _nonMatchBehavior = nonMatchBehavior;
+ }
+
+ ///
+ protected override void OnInitialize(IDataReader source)
+ {
+ _columnOrdinal = source.GetOrdinal(_columnName);
+
+ var options = RegexOptions.Compiled;
+ if (_ignoreCase)
+ options |= RegexOptions.IgnoreCase;
+
+ _regex = new Regex(_pattern, options);
+ }
+
+ ///
+ public override object GetValue(int ordinal, IDataReader source)
+ {
+ var value = source.GetValue(ordinal);
+
+ // Only transform the target column
+ if (ordinal != _columnOrdinal)
+ return value;
+
+ // Pass through null/DBNull
+ if (value == null || value == DBNull.Value)
+ return value;
+
+ var stringValue = value.ToString() ?? string.Empty;
+
+ // Find & Replace mode (replacement is not null)
+ if (_replacement != null)
+ {
+ return _regex!.Replace(stringValue, _replacement);
+ }
+
+ // Match & Extract mode (replacement is null)
+ var match = _regex!.Match(stringValue);
+ if (match.Success && match.Groups.Count > 1)
+ {
+ return match.Groups[1].Value;
+ }
+
+ // No match - apply NonMatchBehavior
+ return _nonMatchBehavior switch
+ {
+ NonMatchBehavior.ReturnNull => DBNull.Value,
+ NonMatchBehavior.ReturnEmpty => string.Empty,
+ _ => value // KeepOriginal
+ };
+ }
+
+ ///
+ public override Type GetFieldType(int ordinal, IDataReader source)
+ {
+ // Target column always returns string
+ if (ordinal == _columnOrdinal)
+ return typeof(string);
+ return source.GetFieldType(ordinal);
+ }
+}
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests.FindReplace_RemovesPrefix" -v n`
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs
+git commit -m "$(cat <<'EOF'
+feat(datasync): add RegexTransformer with Find & Replace mode
+
+Initial implementation supporting:
+- Find & Replace mode with regex pattern and replacement string
+- Case-insensitive option
+- NonMatchBehavior enum for handling non-matches
+EOF
+)"
+```
+
+---
+
+## Task 3: Add Match & Extract Mode Tests
+
+**Files:**
+- Modify: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs`
+
+**Step 1: Write the failing test for Match & Extract**
+
+Add to test class:
+
+```csharp
+[Fact]
+public void MatchExtract_ExtractsFirstCaptureGroup()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "Code" },
+ values: new object[] { "ID_12345" });
+
+ var transformer = new RegexTransformer(
+ columnName: "Code",
+ pattern: @"ID_(\d+)",
+ replacement: null); // null = Match & Extract mode
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal("12345", reader.GetValue(0));
+}
+
+[Fact]
+public void MatchExtract_NoMatch_KeepOriginal()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "Code" },
+ values: new object[] { "UNKNOWN" });
+
+ var transformer = new RegexTransformer(
+ columnName: "Code",
+ pattern: @"ID_(\d+)",
+ replacement: null,
+ nonMatchBehavior: NonMatchBehavior.KeepOriginal);
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal("UNKNOWN", reader.GetValue(0));
+}
+
+[Fact]
+public void MatchExtract_NoMatch_ReturnNull()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "Code" },
+ values: new object[] { "UNKNOWN" });
+
+ var transformer = new RegexTransformer(
+ columnName: "Code",
+ pattern: @"ID_(\d+)",
+ replacement: null,
+ nonMatchBehavior: NonMatchBehavior.ReturnNull);
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal(DBNull.Value, reader.GetValue(0));
+}
+
+[Fact]
+public void MatchExtract_NoMatch_ReturnEmpty()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "Code" },
+ values: new object[] { "UNKNOWN" });
+
+ var transformer = new RegexTransformer(
+ columnName: "Code",
+ pattern: @"ID_(\d+)",
+ replacement: null,
+ nonMatchBehavior: NonMatchBehavior.ReturnEmpty);
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal(string.Empty, reader.GetValue(0));
+}
+```
+
+**Step 2: Run tests to verify they pass**
+
+Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests" -v n`
+Expected: All 5 tests PASS
+
+**Step 3: Commit**
+
+```bash
+git add NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs
+git commit -m "$(cat <<'EOF'
+test(datasync): add Match & Extract mode tests for RegexTransformer
+
+Tests cover:
+- Extracting first capture group
+- NonMatchBehavior: KeepOriginal, ReturnNull, ReturnEmpty
+EOF
+)"
+```
+
+---
+
+## Task 4: Add Edge Case Tests
+
+**Files:**
+- Modify: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs`
+
+**Step 1: Add edge case tests**
+
+Add to test class:
+
+```csharp
+[Fact]
+public void FindReplace_UseCaptureGroups()
+{
+ // Arrange - swap two numbers
+ var source = CreateMockReader(
+ columns: new[] { "Value" },
+ values: new object[] { "123-456" });
+
+ var transformer = new RegexTransformer(
+ columnName: "Value",
+ pattern: @"(\d+)-(\d+)",
+ replacement: "$2-$1");
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal("456-123", reader.GetValue(0));
+}
+
+[Fact]
+public void IgnoreCase_MatchesDifferentCase()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "BatchID" },
+ values: new object[] { "IIS_12345" });
+
+ var transformer = new RegexTransformer(
+ columnName: "BatchID",
+ pattern: "^iis_", // lowercase pattern
+ replacement: "",
+ ignoreCase: true);
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal("12345", reader.GetValue(0));
+}
+
+[Fact]
+public void NullValue_PassesThrough()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "BatchID" },
+ values: new object[] { DBNull.Value });
+
+ var transformer = new RegexTransformer(
+ columnName: "BatchID",
+ pattern: "^IIS_",
+ replacement: "");
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal(DBNull.Value, reader.GetValue(0));
+}
+
+[Fact]
+public void NonTargetColumn_Unchanged()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "BatchID", "OtherColumn" },
+ values: new object[] { "IIS_12345", "IIS_Should_Not_Change" });
+
+ var transformer = new RegexTransformer(
+ columnName: "BatchID",
+ pattern: "^IIS_",
+ replacement: "");
+
+ // Act
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ // Assert
+ Assert.Equal("12345", reader.GetValue(0));
+ Assert.Equal("IIS_Should_Not_Change", reader.GetValue(1));
+}
+
+[Fact]
+public void InvalidRegex_ThrowsOnTransform()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "Value" },
+ values: new object[] { "test" });
+
+ var transformer = new RegexTransformer(
+ columnName: "Value",
+ pattern: "[invalid(regex",
+ replacement: "");
+
+ // Act & Assert
+ var ex = Assert.Throws(() => transformer.Transform(source));
+ Assert.Contains("Invalid", ex.Message, StringComparison.OrdinalIgnoreCase);
+}
+
+[Fact]
+public void ColumnNotFound_ThrowsOnTransform()
+{
+ // Arrange
+ var source = CreateMockReader(
+ columns: new[] { "Value" },
+ values: new object[] { "test" });
+ source.GetOrdinal("NonExistent").Returns(_ => throw new IndexOutOfRangeException("Column not found"));
+
+ var transformer = new RegexTransformer(
+ columnName: "NonExistent",
+ pattern: "test",
+ replacement: "");
+
+ // Act & Assert
+ Assert.Throws(() => transformer.Transform(source));
+}
+```
+
+**Step 2: Run all transformer tests**
+
+Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests" -v n`
+Expected: All 11 tests PASS
+
+**Step 3: Commit**
+
+```bash
+git add NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs
+git commit -m "$(cat <<'EOF'
+test(datasync): add edge case tests for RegexTransformer
+
+Tests cover:
+- Capture group substitution in replacement
+- Case-insensitive matching
+- Null/DBNull passthrough
+- Non-target columns unchanged
+- Invalid regex pattern handling
+- Column not found handling
+EOF
+)"
+```
+
+---
+
+## Task 5: Create RegexTransformerViewModel
+
+**Files:**
+- Modify: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs`
+
+**Step 1: Add the ViewModel class**
+
+Add after `JdeDateTransformerViewModel` class (before `TransformerFactory`):
+
+```csharp
+///
+/// View model for Regex transformer.
+///
+public class RegexTransformerViewModel : TransformerStepViewModelBase
+{
+ private string _columnName = string.Empty;
+ private string _pattern = string.Empty;
+ private string? _replacement = string.Empty;
+ private bool _isFindReplaceMode = true;
+ private bool _ignoreCase;
+ private NonMatchBehavior _nonMatchBehavior = NonMatchBehavior.KeepOriginal;
+
+ // Test feature fields
+ private string _testInput = string.Empty;
+ private string _testResultValue = string.Empty;
+ private string _testResultLabel = string.Empty;
+ private string _testResultIcon = string.Empty;
+ private string _testResultBackground = string.Empty;
+ private bool _hasTestResult;
+ private bool _hasTestError;
+ private string _testErrorMessage = string.Empty;
+
+ public RegexTransformerViewModel(TransformerModel model, Action onChanged) : base(onChanged)
+ {
+ _columnName = model.ColumnName ?? string.Empty;
+ _pattern = model.Pattern ?? string.Empty;
+ _replacement = model.Replacement;
+ _isFindReplaceMode = model.Replacement != null;
+ _ignoreCase = model.IgnoreCase;
+ _nonMatchBehavior = model.NonMatchBehavior;
+ TestPatternCommand = new RelayCommand(ExecuteTestPattern);
+ }
+
+ public RegexTransformerViewModel(Action onChanged) : base(onChanged)
+ {
+ TestPatternCommand = new RelayCommand(ExecuteTestPattern);
+ }
+
+ public override string TransformerType => "Regex";
+ public override string DisplayName => "Regex Transform";
+ public override string Icon => ""; // mdi-regex
+ public override string Summary => !string.IsNullOrEmpty(_columnName)
+ ? $"{_columnName}: {(_isFindReplaceMode ? "Replace" : "Extract")}"
+ : "Configure...";
+
+ /// Gets or sets the column name to transform.
+ public string ColumnName
+ {
+ get => _columnName;
+ set
+ {
+ if (SetProperty(ref _columnName, value ?? string.Empty))
+ {
+ OnPropertyChanged(nameof(Summary));
+ NotifyChanged();
+ }
+ }
+ }
+
+ /// Gets or sets the regex pattern.
+ public string Pattern
+ {
+ get => _pattern;
+ set
+ {
+ if (SetProperty(ref _pattern, value ?? string.Empty))
+ {
+ ClearTestResult();
+ NotifyChanged();
+ }
+ }
+ }
+
+ /// Gets or sets the replacement string (Find & Replace mode).
+ public string? Replacement
+ {
+ get => _replacement;
+ set
+ {
+ if (SetProperty(ref _replacement, value))
+ {
+ ClearTestResult();
+ NotifyChanged();
+ }
+ }
+ }
+
+ /// Gets or sets whether Find & Replace mode is active.
+ public bool IsFindReplaceMode
+ {
+ get => _isFindReplaceMode;
+ set
+ {
+ if (SetProperty(ref _isFindReplaceMode, value))
+ {
+ OnPropertyChanged(nameof(IsMatchExtractMode));
+ OnPropertyChanged(nameof(PatternHelpText));
+ OnPropertyChanged(nameof(Summary));
+ ClearTestResult();
+ NotifyChanged();
+ }
+ }
+ }
+
+ /// Gets or sets whether Match & Extract mode is active.
+ public bool IsMatchExtractMode
+ {
+ get => !_isFindReplaceMode;
+ set => IsFindReplaceMode = !value;
+ }
+
+ /// Gets the help text for the pattern field based on current mode.
+ public string PatternHelpText => _isFindReplaceMode
+ ? "Pattern to search for in the column value"
+ : "Pattern with capture group - first group (parentheses) will be extracted";
+
+ /// Gets or sets whether matching is case-insensitive.
+ public bool IgnoreCase
+ {
+ get => _ignoreCase;
+ set
+ {
+ if (SetProperty(ref _ignoreCase, value))
+ {
+ ClearTestResult();
+ NotifyChanged();
+ }
+ }
+ }
+
+ /// Gets or sets the behavior when pattern doesn't match.
+ public NonMatchBehavior NonMatchBehavior
+ {
+ get => _nonMatchBehavior;
+ set
+ {
+ if (SetProperty(ref _nonMatchBehavior, value))
+ {
+ ClearTestResult();
+ NotifyChanged();
+ }
+ }
+ }
+
+ // Test feature properties
+ public string TestInput
+ {
+ get => _testInput;
+ set => SetProperty(ref _testInput, value ?? string.Empty);
+ }
+
+ public string TestResultValue
+ {
+ get => _testResultValue;
+ set => SetProperty(ref _testResultValue, value);
+ }
+
+ public string TestResultLabel
+ {
+ get => _testResultLabel;
+ set => SetProperty(ref _testResultLabel, value);
+ }
+
+ public string TestResultIcon
+ {
+ get => _testResultIcon;
+ set => SetProperty(ref _testResultIcon, value);
+ }
+
+ public string TestResultBackground
+ {
+ get => _testResultBackground;
+ set => SetProperty(ref _testResultBackground, value);
+ }
+
+ public bool HasTestResult
+ {
+ get => _hasTestResult;
+ set => SetProperty(ref _hasTestResult, value);
+ }
+
+ public bool HasTestError
+ {
+ get => _hasTestError;
+ set => SetProperty(ref _hasTestError, value);
+ }
+
+ public string TestErrorMessage
+ {
+ get => _testErrorMessage;
+ set => SetProperty(ref _testErrorMessage, value);
+ }
+
+ public ICommand TestPatternCommand { get; }
+
+ private void ExecuteTestPattern()
+ {
+ ClearTestResult();
+
+ if (string.IsNullOrEmpty(_pattern))
+ {
+ HasTestError = true;
+ TestErrorMessage = "Pattern is required";
+ return;
+ }
+
+ try
+ {
+ var options = _ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
+ var regex = new Regex(_pattern, options);
+
+ string result;
+ bool matched;
+
+ if (_isFindReplaceMode)
+ {
+ result = regex.Replace(_testInput, _replacement ?? string.Empty);
+ matched = regex.IsMatch(_testInput);
+ }
+ else
+ {
+ var match = regex.Match(_testInput);
+ if (match.Success && match.Groups.Count > 1)
+ {
+ result = match.Groups[1].Value;
+ matched = true;
+ }
+ else
+ {
+ matched = false;
+ result = _nonMatchBehavior switch
+ {
+ NonMatchBehavior.ReturnNull => "(null)",
+ NonMatchBehavior.ReturnEmpty => "(empty)",
+ _ => _testInput
+ };
+ }
+ }
+
+ HasTestResult = true;
+ TestResultValue = result;
+ TestResultLabel = matched ? "Output" : "No Match";
+ TestResultIcon = matched ? "✓" : "—";
+ TestResultBackground = matched ? "#22C55E" : "#F59E0B";
+ }
+ catch (RegexParseException ex)
+ {
+ HasTestError = true;
+ TestErrorMessage = ex.Message;
+ }
+ }
+
+ private void ClearTestResult()
+ {
+ HasTestResult = false;
+ HasTestError = false;
+ TestResultValue = string.Empty;
+ TestResultLabel = string.Empty;
+ TestErrorMessage = string.Empty;
+ }
+
+ public override TransformerModel ToModel() => new()
+ {
+ Type = TransformerType,
+ ColumnName = _columnName,
+ Pattern = _pattern,
+ Replacement = _isFindReplaceMode ? _replacement : null,
+ IgnoreCase = _ignoreCase,
+ NonMatchBehavior = _nonMatchBehavior
+ };
+}
+```
+
+**Step 2: Add using statement**
+
+Add at top of file:
+```csharp
+using System.Text.RegularExpressions;
+using JdeScoping.ConfigManager.Models;
+```
+
+**Step 3: Verify it compiles**
+
+Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj`
+Expected: Build succeeded
+
+**Step 4: Commit**
+
+```bash
+git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs
+git commit -m "$(cat <<'EOF'
+feat(configmanager): add RegexTransformerViewModel
+
+Implements ViewModel for Regex transformer editor with:
+- Column, Pattern, Replacement, IgnoreCase, NonMatchBehavior properties
+- Mode toggle between Find & Replace and Match & Extract
+- Live test/preview functionality with error handling
+EOF
+)"
+```
+
+---
+
+## Task 6: Update TransformerFactory
+
+**Files:**
+- Modify: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs:284-318`
+
+**Step 1: Update factory switch statements and AvailableTypes**
+
+In `TransformerFactory.Create()` method, add case:
+```csharp
+"regex" => new RegexTransformerViewModel(model, onChanged),
+```
+
+In `TransformerFactory.CreateNew()` method, add case:
+```csharp
+"regex" => new RegexTransformerViewModel(onChanged),
+```
+
+Update `AvailableTypes`:
+```csharp
+public static IReadOnlyList AvailableTypes => ["ColumnDrop", "ColumnRename", "JdeDate", "Regex"];
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs
+git commit -m "$(cat <<'EOF'
+feat(configmanager): register Regex transformer in TransformerFactory
+
+Add Regex to:
+- Create() factory method
+- CreateNew() factory method
+- AvailableTypes list
+EOF
+)"
+```
+
+---
+
+## Task 7: Create RegexEditorView XAML
+
+**Files:**
+- Create: `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`
+- Create: `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs`
+
+**Step 1: Create the XAML file**
+
+Create `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ KeepOriginal
+ ReturnNull
+ ReturnEmpty
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Step 2: Create the code-behind file**
+
+Create `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs`:
+
+```csharp
+using Avalonia.Controls;
+
+namespace JdeScoping.ConfigManager.Views.Editors;
+
+public partial class RegexEditorView : UserControl
+{
+ public RegexEditorView()
+ {
+ InitializeComponent();
+ }
+}
+```
+
+**Step 3: Verify it compiles**
+
+Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj`
+Expected: Build succeeded
+
+**Step 4: Commit**
+
+```bash
+git add NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs
+git commit -m "$(cat <<'EOF'
+feat(configmanager): add RegexEditorView Avalonia UI
+
+Implements editor with:
+- Column name input
+- Mode toggle (Find & Replace / Match & Extract)
+- Pattern and Replacement inputs
+- Case insensitive checkbox
+- NonMatchBehavior dropdown
+- Live test/preview section with result display
+- Pattern examples help box
+EOF
+)"
+```
+
+---
+
+## Task 8: Register DataTemplate in MainWindow
+
+**Files:**
+- Modify: `NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml:58-60`
+
+**Step 1: Add DataTemplate for RegexTransformerViewModel**
+
+Add after line 60 (after JdeDateTransformerViewModel template):
+
+```xml
+
+
+
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml
+git commit -m "$(cat <<'EOF'
+feat(configmanager): register RegexEditorView DataTemplate in MainWindow
+EOF
+)"
+```
+
+---
+
+## Task 9: Add ViewModel Unit Tests
+
+**Files:**
+- Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/RegexTransformerViewModelTests.cs`
+
+**Step 1: Create test file**
+
+```csharp
+using JdeScoping.ConfigManager.Models;
+using JdeScoping.ConfigManager.ViewModels.PipelineSteps;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels;
+
+public class RegexTransformerViewModelTests
+{
+ [Fact]
+ public void Constructor_FromModel_LoadsAllProperties()
+ {
+ // Arrange
+ var model = new TransformerModel
+ {
+ Type = "Regex",
+ ColumnName = "BatchID",
+ Pattern = "^IIS_",
+ Replacement = "",
+ IgnoreCase = true,
+ NonMatchBehavior = NonMatchBehavior.ReturnEmpty
+ };
+
+ // Act
+ var vm = new RegexTransformerViewModel(model, () => { });
+
+ // Assert
+ Assert.Equal("BatchID", vm.ColumnName);
+ Assert.Equal("^IIS_", vm.Pattern);
+ Assert.Equal("", vm.Replacement);
+ Assert.True(vm.IsFindReplaceMode);
+ Assert.True(vm.IgnoreCase);
+ Assert.Equal(NonMatchBehavior.ReturnEmpty, vm.NonMatchBehavior);
+ }
+
+ [Fact]
+ public void Constructor_FromModel_MatchExtractMode_WhenReplacementNull()
+ {
+ // Arrange
+ var model = new TransformerModel
+ {
+ Type = "Regex",
+ ColumnName = "Code",
+ Pattern = @"(\d+)",
+ Replacement = null
+ };
+
+ // Act
+ var vm = new RegexTransformerViewModel(model, () => { });
+
+ // Assert
+ Assert.False(vm.IsFindReplaceMode);
+ Assert.True(vm.IsMatchExtractMode);
+ }
+
+ [Fact]
+ public void ToModel_SerializesCorrectly_FindReplaceMode()
+ {
+ // Arrange
+ var vm = new RegexTransformerViewModel(() => { })
+ {
+ ColumnName = "BatchID",
+ Pattern = "^IIS_",
+ Replacement = "",
+ IsFindReplaceMode = true,
+ IgnoreCase = true,
+ NonMatchBehavior = NonMatchBehavior.KeepOriginal
+ };
+
+ // Act
+ var model = vm.ToModel();
+
+ // Assert
+ Assert.Equal("Regex", model.Type);
+ Assert.Equal("BatchID", model.ColumnName);
+ Assert.Equal("^IIS_", model.Pattern);
+ Assert.Equal("", model.Replacement);
+ Assert.True(model.IgnoreCase);
+ Assert.Equal(NonMatchBehavior.KeepOriginal, model.NonMatchBehavior);
+ }
+
+ [Fact]
+ public void ToModel_SerializesCorrectly_MatchExtractMode()
+ {
+ // Arrange
+ var vm = new RegexTransformerViewModel(() => { })
+ {
+ ColumnName = "Code",
+ Pattern = @"(\d+)",
+ IsFindReplaceMode = false
+ };
+
+ // Act
+ var model = vm.ToModel();
+
+ // Assert
+ Assert.Null(model.Replacement); // null indicates Match & Extract mode
+ }
+
+ [Fact]
+ public void TestPatternCommand_ValidPattern_ShowsResult()
+ {
+ // Arrange
+ var vm = new RegexTransformerViewModel(() => { })
+ {
+ Pattern = "^IIS_",
+ Replacement = "",
+ IsFindReplaceMode = true,
+ TestInput = "IIS_12345"
+ };
+
+ // Act
+ vm.TestPatternCommand.Execute(null);
+
+ // Assert
+ Assert.True(vm.HasTestResult);
+ Assert.False(vm.HasTestError);
+ Assert.Equal("12345", vm.TestResultValue);
+ Assert.Equal("Output", vm.TestResultLabel);
+ Assert.Equal("✓", vm.TestResultIcon);
+ }
+
+ [Fact]
+ public void TestPatternCommand_InvalidPattern_ShowsError()
+ {
+ // Arrange
+ var vm = new RegexTransformerViewModel(() => { })
+ {
+ Pattern = "[invalid(regex",
+ Replacement = "",
+ TestInput = "test"
+ };
+
+ // Act
+ vm.TestPatternCommand.Execute(null);
+
+ // Assert
+ Assert.False(vm.HasTestResult);
+ Assert.True(vm.HasTestError);
+ Assert.NotEmpty(vm.TestErrorMessage);
+ }
+
+ [Fact]
+ public void TestPatternCommand_MatchExtract_NoMatch_ShowsNonMatchBehavior()
+ {
+ // Arrange
+ var vm = new RegexTransformerViewModel(() => { })
+ {
+ Pattern = @"(\d+)",
+ IsFindReplaceMode = false,
+ NonMatchBehavior = NonMatchBehavior.ReturnNull,
+ TestInput = "NoNumbers"
+ };
+
+ // Act
+ vm.TestPatternCommand.Execute(null);
+
+ // Assert
+ Assert.True(vm.HasTestResult);
+ Assert.Equal("No Match", vm.TestResultLabel);
+ Assert.Equal("(null)", vm.TestResultValue);
+ }
+
+ [Fact]
+ public void ModeSwitch_UpdatesPatternHelpText()
+ {
+ // Arrange
+ var vm = new RegexTransformerViewModel(() => { })
+ {
+ IsFindReplaceMode = true
+ };
+ var findReplaceHelp = vm.PatternHelpText;
+
+ // Act
+ vm.IsFindReplaceMode = false;
+ var matchExtractHelp = vm.PatternHelpText;
+
+ // Assert
+ Assert.NotEqual(findReplaceHelp, matchExtractHelp);
+ Assert.Contains("capture group", matchExtractHelp, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Summary_ShowsColumnAndMode()
+ {
+ // Arrange & Act
+ var vm = new RegexTransformerViewModel(() => { })
+ {
+ ColumnName = "BatchID",
+ IsFindReplaceMode = true
+ };
+
+ // Assert
+ Assert.Contains("BatchID", vm.Summary);
+ Assert.Contains("Replace", vm.Summary);
+ }
+
+ [Fact]
+ public void PropertyChange_NotifiesChanged()
+ {
+ // Arrange
+ var changedCalled = false;
+ var vm = new RegexTransformerViewModel(() => changedCalled = true);
+
+ // Act
+ vm.ColumnName = "NewColumn";
+
+ // Assert
+ Assert.True(changedCalled);
+ }
+}
+```
+
+**Step 2: Run tests**
+
+Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj --filter "FullyQualifiedName~RegexTransformerViewModelTests" -v n`
+Expected: All 10 tests PASS
+
+**Step 3: Commit**
+
+```bash
+git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/RegexTransformerViewModelTests.cs
+git commit -m "$(cat <<'EOF'
+test(configmanager): add RegexTransformerViewModel unit tests
+
+Tests cover:
+- Loading from model
+- Serializing to model
+- Test pattern command (success and error cases)
+- Mode switching and help text
+- Summary display
+- Change notification
+EOF
+)"
+```
+
+---
+
+## Task 10: Run Full Test Suite and Final Verification
+
+**Files:** None (verification only)
+
+**Step 1: Run all transformer tests**
+
+Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~TransformerTests" -v n`
+Expected: All tests PASS
+
+**Step 2: Run all ConfigManager tests**
+
+Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj -v n`
+Expected: All tests PASS
+
+**Step 3: Build entire solution**
+
+Run: `dotnet build NEW/JdeScoping.slnx`
+Expected: Build succeeded with no errors
+
+**Step 4: Final commit (if any uncommitted changes)**
+
+```bash
+git status
+# If clean, no action needed
+```
+
+---
+
+## Summary
+
+**Tasks completed:**
+1. Added `NonMatchBehavior` enum and regex properties to `TransformerModel`
+2. Created `RegexTransformer` class with Find & Replace mode
+3. Added Match & Extract mode tests
+4. Added edge case tests (capture groups, case-insensitive, null handling)
+5. Created `RegexTransformerViewModel` with test functionality
+6. Updated `TransformerFactory` to register Regex transformer
+7. Created `RegexEditorView` Avalonia UI
+8. Registered DataTemplate in `MainWindow.axaml`
+9. Added ViewModel unit tests
+10. Verified full test suite passes
+
+**Files created:**
+- `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs`
+- `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs`
+- `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/RegexTransformerViewModelTests.cs`
+
+**Files modified:**
+- `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs`
+- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml`
diff --git a/PLANS/securestore-auto-init-plan.md b/PLANS/securestore-auto-init-plan.md
new file mode 100644
index 0000000..1e2eb04
--- /dev/null
+++ b/PLANS/securestore-auto-init-plan.md
@@ -0,0 +1,525 @@
+# Implementation Plan: SecureStore Auto-Initialization & Tree Flattening
+
+## Overview
+
+This plan implements automatic SecureStore initialization on config load and flattens the tree hierarchy from "Secure Stores > secrets > [keys]" to "Secure Store > [keys]".
+
+## Design Decisions (from brainstorming)
+
+1. **Auto-creation**: Fully automatic on config folder load when `AutoCreateStore=true`
+2. **Path updates**: Write actual paths back to appsettings.json after creating files
+3. **Tree structure**: Single "Secure Store" node with secrets as direct children
+4. **Authentication**: Keyfile-only (remove password option entirely)
+5. **Lock concept**: Removed - store is always open while config folder is loaded
+6. **Icon**: Key icon (🔑) for "Secure Store" node
+7. **Right panel**: Show instructions "Select a secret to edit" when store node selected
+8. **Error handling**: Show error dialog if keyfile missing/corrupted
+
+---
+
+## Task 1: Update TreeNodeType enum
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs`
+
+**Changes:**
+- Remove `SecureStoresFolder` enum value (no longer needed)
+- Keep `SecureStore` for the single store node
+- Keep `Secret` for individual secrets
+
+**Before (lines 5-13):**
+```csharp
+public enum TreeNodeType
+{
+ Folder,
+ SettingsSection,
+ Pipeline,
+ SecureStoresFolder, // The "Secure Stores" folder
+ SecureStore, // Individual store files
+ Secret // Individual secrets within a store
+}
+```
+
+**After:**
+```csharp
+public enum TreeNodeType
+{
+ Folder,
+ SettingsSection,
+ Pipeline,
+ SecureStore, // The single secure store node
+ Secret // Individual secrets within a store
+}
+```
+
+---
+
+## Task 2: Remove lock-related properties from TreeNodeViewModel
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs`
+
+**Remove these properties (lines 55-79):**
+- `IsUnlocked` property and backing field
+- `IsLocked` computed property
+- `LockIcon` computed property
+
+**Keep:**
+- `StorePath` and `KeyFilePath` (needed for store identification)
+- `SecretKey` (needed for secret nodes)
+
+---
+
+## Task 3: Remove password-related methods from ISecureStoreManager
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs`
+
+**Remove these methods:**
+- `CreateStoreWithPassword(string storePath, string password)` (lines 30-35)
+- `OpenStoreWithPassword(string storePath, string password)` (lines 44-49)
+
+---
+
+## Task 4: Remove password-related methods from SecureStoreManager
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs`
+
+**Remove these methods:**
+- `CreateStoreWithPassword()` (lines 75-96)
+- `OpenStoreWithPassword()` (lines 120-140)
+
+---
+
+## Task 5: Add EnsureRequiredKeys method to ISecureStoreManager
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs`
+
+**Add new method:**
+```csharp
+///
+/// Ensures all required keys exist in the store, creating blank values for any missing keys.
+///
+/// List of keys that must exist.
+/// List of keys that were added.
+IReadOnlyList EnsureRequiredKeys(IEnumerable requiredKeys);
+```
+
+---
+
+## Task 6: Implement EnsureRequiredKeys in SecureStoreManager
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs`
+
+**Add implementation:**
+```csharp
+///
+public IReadOnlyList EnsureRequiredKeys(IEnumerable requiredKeys)
+{
+ ThrowIfDisposed();
+
+ if (_secretsManager == null)
+ throw new InvalidOperationException("No store is currently open.");
+
+ var addedKeys = new List();
+ foreach (var key in requiredKeys)
+ {
+ if (!_keys.Contains(key))
+ {
+ _logger.LogInformation("Adding missing required key: {Key}", key);
+ SetSecret(key, string.Empty);
+ addedKeys.Add(key);
+ }
+ }
+
+ if (addedKeys.Count > 0)
+ {
+ Save();
+ }
+
+ return addedKeys.AsReadOnly();
+}
+```
+
+---
+
+## Task 7: Delete locked/unlocked form files
+
+**Delete these files entirely:**
+
+ViewModels:
+- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs`
+- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs`
+
+Views:
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml`
+- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs`
+
+---
+
+## Task 8: Create SecureStoreInfoFormViewModel
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs` (new file)
+
+**Content:**
+```csharp
+namespace JdeScoping.ConfigManager.ViewModels.Forms;
+
+///
+/// View model for the SecureStore info panel shown when the store node is selected.
+///
+public class SecureStoreInfoFormViewModel : ViewModelBase
+{
+ ///
+ /// Gets the instruction text to display.
+ ///
+ public string InstructionText => "Select a secret from the tree to view or edit its value.";
+
+ ///
+ /// Gets the store path for display.
+ ///
+ public string StorePath { get; }
+
+ ///
+ /// Gets the key file path for display.
+ ///
+ public string KeyFilePath { get; }
+
+ ///
+ /// Gets the number of secrets in the store.
+ ///
+ public int SecretCount { get; }
+
+ public SecureStoreInfoFormViewModel(string storePath, string keyFilePath, int secretCount)
+ {
+ StorePath = storePath;
+ KeyFilePath = keyFilePath;
+ SecretCount = secretCount;
+ }
+}
+```
+
+---
+
+## Task 9: Create SecureStoreInfoFormView
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml` (new file)
+
+**Content:** Simple panel with instruction text and optional store info display.
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs` (new file)
+
+**Content:** Code-behind for the view.
+
+---
+
+## Task 10: Update MainWindowViewModel - Remove lock/unlock commands and state
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
+
+**Remove:**
+- `_openStores` dictionary (line 41)
+- `_selectedStoreNode` field (line 42)
+- `UnlockStoreCommand` and related methods
+- `LockStoreCommand` and related methods
+- `RaiseSecureStoreCommandsCanExecuteChanged()` method
+- `CreateLockedStoreFormViewModel()` method
+- `CreateUnlockedStoreFormViewModel()` method
+- `FindParentStoreNode()` method (if only used for lock state)
+
+---
+
+## Task 11: Update MainWindowViewModel - Add auto-initialization logic
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
+
+**Add new method `InitializeSecureStoreAsync()`:**
+
+```csharp
+///
+/// Initializes the SecureStore automatically on config load.
+/// Creates the store if it doesn't exist and AutoCreateStore is true.
+/// Opens the store and ensures all required keys exist.
+///
+private async Task InitializeSecureStoreAsync()
+{
+ if (_appSettings?.SecureStore == null)
+ return;
+
+ if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
+ return;
+
+ var secureStoreConfig = _appSettings.SecureStore;
+
+ // Resolve paths relative to config folder
+ var storePath = Path.IsPathRooted(secureStoreConfig.StorePath)
+ ? secureStoreConfig.StorePath
+ : Path.Combine(ConfigFolderPath, secureStoreConfig.StorePath);
+
+ var keyFilePath = Path.IsPathRooted(secureStoreConfig.KeyFilePath)
+ ? secureStoreConfig.KeyFilePath
+ : Path.Combine(ConfigFolderPath, secureStoreConfig.KeyFilePath);
+
+ try
+ {
+ if (!File.Exists(storePath))
+ {
+ if (!secureStoreConfig.AutoCreateStore)
+ {
+ _logger?.LogWarning("SecureStore not found and AutoCreateStore is false");
+ return;
+ }
+
+ // Create new store with keyfile
+ _logger?.LogInformation("Creating SecureStore at {StorePath}", storePath);
+ _secureStoreManager.CreateStore(storePath, keyFilePath);
+
+ // Update appsettings.json with actual paths
+ secureStoreConfig.StorePath = GetRelativePath(ConfigFolderPath, storePath);
+ secureStoreConfig.KeyFilePath = GetRelativePath(ConfigFolderPath, keyFilePath);
+ await SaveAppSettingsAsync();
+ }
+ else
+ {
+ // Open existing store
+ if (!File.Exists(keyFilePath))
+ {
+ await _dialogService!.ShowErrorAsync(
+ "SecureStore Error",
+ $"Key file not found: {keyFilePath}\n\nThe SecureStore cannot be opened without its key file.");
+ return;
+ }
+
+ _secureStoreManager.OpenStore(storePath, keyFilePath);
+ }
+
+ // Ensure all required keys exist
+ if (secureStoreConfig.RequiredKeys?.Count > 0)
+ {
+ var addedKeys = _secureStoreManager.EnsureRequiredKeys(secureStoreConfig.RequiredKeys);
+ if (addedKeys.Count > 0)
+ {
+ _logger?.LogInformation("Added {Count} missing required keys", addedKeys.Count);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to initialize SecureStore");
+ await _dialogService!.ShowErrorAsync(
+ "SecureStore Error",
+ $"Failed to initialize SecureStore:\n\n{ex.Message}");
+ }
+}
+
+private static string GetRelativePath(string basePath, string fullPath)
+{
+ var baseUri = new Uri(basePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
+ var fullUri = new Uri(fullPath);
+ return Uri.UnescapeDataString(baseUri.MakeRelativeUri(fullUri).ToString().Replace('/', Path.DirectorySeparatorChar));
+}
+```
+
+---
+
+## Task 12: Update MainWindowViewModel - Modify BuildTreeNodes
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
+
+**Replace the Secure Stores section in `BuildTreeNodes()` (around lines 464-467):**
+
+**Before:**
+```csharp
+// Secure Stores folder
+var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "🔑", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
+LoadConfiguredSecureStore(secureStoresFolder);
+TreeNodes.Add(secureStoresFolder);
+```
+
+**After:**
+```csharp
+// Secure Store node (single store, secrets as direct children)
+var secureStoreNode = CreateSecureStoreNode();
+if (secureStoreNode != null)
+{
+ TreeNodes.Add(secureStoreNode);
+}
+```
+
+**Add new method `CreateSecureStoreNode()`:**
+```csharp
+private TreeNodeViewModel? CreateSecureStoreNode()
+{
+ if (_appSettings?.SecureStore == null)
+ return null;
+
+ if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
+ return null;
+
+ var storePath = Path.IsPathRooted(_appSettings.SecureStore.StorePath)
+ ? _appSettings.SecureStore.StorePath
+ : Path.Combine(ConfigFolderPath, _appSettings.SecureStore.StorePath);
+
+ var keyFilePath = Path.IsPathRooted(_appSettings.SecureStore.KeyFilePath)
+ ? _appSettings.SecureStore.KeyFilePath
+ : Path.Combine(ConfigFolderPath, _appSettings.SecureStore.KeyFilePath);
+
+ var storeNode = new TreeNodeViewModel("Secure Store", "🔑", TreeNodeType.SecureStore)
+ {
+ StorePath = storePath,
+ KeyFilePath = keyFilePath,
+ SectionKey = storePath,
+ IsExpanded = true
+ };
+
+ // Add secrets as direct children if store is open
+ if (_secureStoreManager.IsStoreOpen)
+ {
+ foreach (var key in _secureStoreManager.GetKeys().OrderBy(k => k))
+ {
+ var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
+ {
+ SecretKey = key
+ };
+ storeNode.Children.Add(secretNode);
+ }
+ }
+
+ return storeNode;
+}
+```
+
+---
+
+## Task 13: Update MainWindowViewModel - Modify OnSelectedNodeChanged
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
+
+**Update the switch statement in `OnSelectedNodeChanged()` (around lines 530-557):**
+
+**Remove:**
+- `case TreeNodeType.SecureStoresFolder:` block
+- Lock/unlock logic in `case TreeNodeType.SecureStore:` block
+
+**Replace with:**
+```csharp
+case TreeNodeType.SecureStore:
+ SelectedFormViewModel = CreateSecureStoreInfoFormViewModel();
+ return;
+
+case TreeNodeType.Secret:
+ SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode);
+ return;
+```
+
+**Add method:**
+```csharp
+private SecureStoreInfoFormViewModel CreateSecureStoreInfoFormViewModel()
+{
+ var storePath = _appSettings?.SecureStore?.StorePath ?? "Unknown";
+ var keyFilePath = _appSettings?.SecureStore?.KeyFilePath ?? "Unknown";
+ var secretCount = _secureStoreManager.IsStoreOpen ? _secureStoreManager.GetKeys().Count : 0;
+
+ return new SecureStoreInfoFormViewModel(storePath, keyFilePath, secretCount);
+}
+```
+
+---
+
+## Task 14: Update MainWindowViewModel - Call InitializeSecureStoreAsync on load
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
+
+**Find the method that loads the config folder (likely `LoadConfigFolderAsync` or similar).**
+
+**Add call to `InitializeSecureStoreAsync()` after loading appsettings.json but before building tree nodes:**
+
+```csharp
+// After loading appsettings
+_appSettings = await _configFileService.LoadAppSettingsAsync(appSettingsPath, ct);
+
+// Initialize SecureStore (auto-create if needed, open, sync required keys)
+await InitializeSecureStoreAsync();
+
+// Then build tree nodes
+BuildTreeNodes();
+```
+
+---
+
+## Task 15: Remove LoadConfiguredSecureStore method
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
+
+**Delete the `LoadConfiguredSecureStore()` method entirely (lines 474-514).**
+
+This is replaced by `CreateSecureStoreNode()` and `InitializeSecureStoreAsync()`.
+
+---
+
+## Task 16: Update DataTemplates for form views
+
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml` (or wherever DataTemplates are defined)
+
+**Remove:**
+- DataTemplate for `SecureStoreLockedFormViewModel`
+- DataTemplate for `SecureStoreUnlockedFormViewModel`
+
+**Add:**
+- DataTemplate for `SecureStoreInfoFormViewModel`
+
+---
+
+## Task 17: Update tests
+
+**File:** `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs`
+
+**Update or remove tests related to:**
+- `SecureStoresFolder` node type
+- Lock/unlock commands
+- Password-based store operations
+
+**Add tests for:**
+- Auto-creation of SecureStore on config load
+- Sync of missing required keys
+- Flattened tree structure
+
+---
+
+## Task 18: Build and verify
+
+```bash
+dotnet build NEW/JdeScoping.slnx
+dotnet test NEW/JdeScoping.slnx
+```
+
+---
+
+## Execution Order
+
+1. Tasks 1-2: TreeNodeViewModel changes (enum, remove lock properties)
+2. Tasks 3-6: SecureStoreManager interface and implementation changes
+3. Tasks 7-9: Delete old form files, create new info form
+4. Tasks 10-15: MainWindowViewModel changes (largest task)
+5. Task 16: Update XAML DataTemplates
+6. Task 17: Update tests
+7. Task 18: Build and verify
+
+---
+
+## Risk Areas
+
+1. **Missing references**: Removing lock/unlock commands may break menu items or toolbar buttons
+2. **View DataTemplates**: Must update form selector to use new `SecureStoreInfoFormViewModel`
+3. **Test coverage**: Existing tests may expect lock/unlock behavior
+
+## Verification Checklist
+
+- [ ] App builds without errors
+- [ ] All tests pass
+- [ ] Opening a config folder without SecureStore creates it automatically
+- [ ] Opening a config folder with existing SecureStore opens it
+- [ ] Missing required keys are added as blank values
+- [ ] Tree shows "Secure Store" with secrets directly underneath
+- [ ] Clicking "Secure Store" shows instruction panel
+- [ ] Clicking a secret shows the secret editor
+- [ ] Error dialog shown when keyfile is missing