docs: add XML documentation and ConfigManager implementation plans

Add comprehensive XML documentation (param/returns tags) across 132 source
files to improve IntelliSense and API discoverability. Include ConfigManager
design documents and implementation plans for phases 1-9.
This commit is contained in:
Joseph Doherty
2026-01-20 02:26:26 -05:00
parent c044337539
commit d49330e697
136 changed files with 9181 additions and 4 deletions
@@ -7,69 +7,226 @@ namespace JdeScoping.ConfigManager.Models;
/// </summary>
public class ConfigModel
{
/// <summary>
/// Gets or sets the data synchronization configuration.
/// </summary>
public DataSyncSection DataSync { get; set; } = new();
/// <summary>
/// Gets or sets the data access configuration.
/// </summary>
public DataAccessSection DataAccess { get; set; } = new();
/// <summary>
/// Gets or sets the authentication configuration.
/// </summary>
public AuthSection Auth { get; set; } = new();
/// <summary>
/// Gets or sets the LDAP directory configuration.
/// </summary>
public LdapSection Ldap { get; set; } = new();
/// <summary>
/// Gets or sets the search processing configuration.
/// </summary>
public SearchSection Search { get; set; } = new();
/// <summary>
/// Gets or sets the Excel export configuration.
/// </summary>
public ExcelExportSection ExcelExport { get; set; } = new();
/// <summary>
/// Gets or sets the connection strings for external data sources.
/// </summary>
public Dictionary<string, string> ConnectionStrings { get; set; } = new();
}
public class DataSyncSection
{
/// <summary>
/// Gets or sets the interval between successive data sync checks.
/// </summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Gets or sets the maximum degree of parallelism for sync operations.
/// </summary>
public int MaxDegreeOfParallelism { get; set; } = 4;
/// <summary>
/// Gets or sets the batch size for data sync operations.
/// </summary>
public int BatchSize { get; set; } = 50000;
/// <summary>
/// Gets or sets the batch size for bulk copy operations.
/// </summary>
public int BulkCopyBatchSize { get; set; } = 5000;
/// <summary>
/// Gets or sets the lookback multiplier for data sync delta calculations.
/// </summary>
public double LookbackMultiplier { get; set; } = 1.5;
/// <summary>
/// Gets or sets the number of days to retain synced data before purging.
/// </summary>
public int PurgeRetentionDays { get; set; } = 90;
/// <summary>
/// Gets or sets the timeout in seconds for sync operations.
/// </summary>
public int SyncTimeoutSeconds { get; set; } = 3600;
/// <summary>
/// Gets or sets a value indicating whether data synchronization is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}
public class DataAccessSection
{
/// <summary>
/// Gets or sets the default timeout in seconds for database queries.
/// </summary>
public int DefaultTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the timeout in seconds for lot usage queries.
/// </summary>
public int LotUsageTimeoutSeconds { get; set; } = 120;
/// <summary>
/// Gets or sets the timeout in seconds for MIS data queries.
/// </summary>
public int MisDataTimeoutSeconds { get; set; } = 300;
/// <summary>
/// Gets or sets the schema name for production data.
/// </summary>
public string ProductionSchema { get; set; } = "prod";
/// <summary>
/// Gets or sets the schema name for archive data.
/// </summary>
public string ArchiveSchema { get; set; } = "archive";
/// <summary>
/// Gets or sets the schema name for staging data.
/// </summary>
public string StageSchema { get; set; } = "stage";
/// <summary>
/// Gets or sets a value indicating whether detailed query logging is enabled.
/// </summary>
public bool EnableDetailedLogging { get; set; } = false;
}
public class AuthSection
{
/// <summary>
/// Gets or sets the name of the authentication cookie.
/// </summary>
public string CookieName { get; set; } = ".JdeScoping.Auth";
/// <summary>
/// Gets or sets the cookie expiration time in minutes.
/// </summary>
public int CookieExpirationMinutes { get; set; } = 480;
}
public class LdapSection
{
/// <summary>
/// Gets or sets the LDAP server URLs to connect to.
/// </summary>
public string[] ServerUrls { get; set; } = [];
/// <summary>
/// Gets or sets the distinguished name of the LDAP group for authorization.
/// </summary>
public string GroupDn { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the base distinguished name for LDAP searches.
/// </summary>
public string SearchBase { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the connection timeout in seconds for LDAP operations.
/// </summary>
public int ConnectionTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether to use fake authentication instead of LDAP.
/// </summary>
public bool UseFakeAuth { get; set; } = false;
/// <summary>
/// Gets or sets an array of user names that bypass group membership validation.
/// </summary>
public string[] AdminBypassUsers { get; set; } = [];
}
public class SearchSection
{
/// <summary>
/// Gets or sets the maximum number of result rows returned by a search.
/// </summary>
public int MaxResultRows { get; set; } = 100000;
/// <summary>
/// Gets or sets the timeout in seconds for search operations.
/// </summary>
public int TimeoutSeconds { get; set; } = 300;
/// <summary>
/// Gets or sets the maximum number of concurrent search operations allowed.
/// </summary>
public int MaxConcurrentSearches { get; set; } = 5;
}
public class ExcelExportSection
{
/// <summary>
/// Gets or sets the password for protecting the criteria worksheet.
/// </summary>
public string CriteriaSheetPassword { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the password for protecting the data worksheet.
/// </summary>
public string DataSheetPassword { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the maximum number of rows per Excel worksheet.
/// </summary>
public int MaxRowsPerSheet { get; set; } = 1000000;
/// <summary>
/// Gets or sets the default date format for Excel exports.
/// </summary>
public string DefaultDateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss";
/// <summary>
/// Gets or sets a value indicating whether to write debug output to files.
/// </summary>
public bool DebugWriteToFile { get; set; } = false;
/// <summary>
/// Gets or sets the directory path for debug output files.
/// </summary>
public string DebugOutputDirectory { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the time zone identifier for date/time conversions.
/// </summary>
public string TimezoneId { get; set; } = "America/Chicago";
/// <summary>
/// Gets or sets the time zone abbreviation for display purposes.
/// </summary>
public string TimezoneAbbreviation { get; set; } = "CT";
}
@@ -5,56 +5,144 @@ namespace JdeScoping.ConfigManager.Models;
/// </summary>
public class PipelinesConfigModel
{
/// <summary>
/// Gets or sets the pipeline settings.
/// </summary>
public PipelineSettings Settings { get; set; } = new();
/// <summary>
/// Gets or sets the default schedules for all pipelines.
/// </summary>
public ScheduleDefaults ScheduleDefaults { get; set; } = new();
/// <summary>
/// Gets or sets the collection of named pipelines.
/// </summary>
public Dictionary<string, PipelineModel> Pipelines { get; set; } = new();
}
public class PipelineSettings
{
/// <summary>
/// Gets or sets the timezone for scheduling operations.
/// </summary>
public string Timezone { get; set; } = "UTC";
}
public class ScheduleDefaults
{
/// <summary>
/// Gets or sets the default mass data refresh schedule.
/// </summary>
public ScheduleModel Mass { get; set; } = new() { Enabled = true, IntervalMinutes = 10080, PrePurge = true, ReIndex = true };
/// <summary>
/// Gets or sets the default daily data refresh schedule.
/// </summary>
public ScheduleModel Daily { get; set; } = new() { Enabled = true, IntervalMinutes = 1440 };
/// <summary>
/// Gets or sets the default hourly data refresh schedule.
/// </summary>
public ScheduleModel Hourly { get; set; } = new() { Enabled = true, IntervalMinutes = 60 };
}
public class PipelineModel
{
/// <summary>
/// Gets or sets the source configuration for data extraction.
/// </summary>
public PipelineSource Source { get; set; } = new();
/// <summary>
/// Gets or sets the schedule configurations for this pipeline.
/// </summary>
public PipelineSchedules Schedules { get; set; } = new();
/// <summary>
/// Gets or sets the destination configuration for data loading.
/// </summary>
public PipelineDestination Destination { get; set; } = new();
/// <summary>
/// Gets or sets optional scripts to execute after pipeline completion.
/// </summary>
public string[]? PostScripts { get; set; }
}
public class PipelineSource
{
/// <summary>
/// Gets or sets the source database connection name.
/// </summary>
public string Connection { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the query to extract data from the source.
/// </summary>
public string Query { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional mass query for full data extraction.
/// </summary>
public string? MassQuery { get; set; }
/// <summary>
/// Gets or sets the query parameters and their definitions.
/// </summary>
public Dictionary<string, ParameterDefinition> Parameters { get; set; } = new();
}
public class ParameterDefinition
{
/// <summary>
/// Gets or sets the parameter name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional parameter format string.
/// </summary>
public string? Format { get; set; }
/// <summary>
/// Gets or sets the optional parameter source or derivation logic.
/// </summary>
public string? Source { get; set; }
}
public class PipelineSchedules
{
/// <summary>
/// Gets or sets the mass refresh schedule for this pipeline.
/// </summary>
public ScheduleModel? Mass { get; set; }
/// <summary>
/// Gets or sets the daily refresh schedule for this pipeline.
/// </summary>
public ScheduleModel? Daily { get; set; }
/// <summary>
/// Gets or sets the hourly refresh schedule for this pipeline.
/// </summary>
public ScheduleModel? Hourly { get; set; }
}
public class PipelineDestination
{
/// <summary>
/// Gets or sets the destination table name.
/// </summary>
public string Table { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the columns used to match existing records for updates.
/// </summary>
public string[] MatchColumns { get; set; } = [];
/// <summary>
/// Gets or sets the columns to exclude from update operations.
/// </summary>
public string[] ExcludeFromUpdate { get; set; } = [];
}
@@ -5,8 +5,23 @@ namespace JdeScoping.ConfigManager.Models;
/// </summary>
public class ScheduleModel
{
/// <summary>
/// Gets or sets a value indicating whether the scheduled task is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the interval in minutes between scheduled task executions.
/// </summary>
public int IntervalMinutes { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether to purge data before task execution.
/// </summary>
public bool PrePurge { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to reindex after task execution.
/// </summary>
public bool ReIndex { get; set; } = false;
}
@@ -4,10 +4,18 @@ namespace JdeScoping.ConfigManager;
class Program
{
/// <summary>
/// The entry point of the application.
/// </summary>
/// <param name="args">Command-line arguments.</param>
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
/// <summary>
/// Builds the Avalonia application builder with platform and font configuration.
/// </summary>
/// <returns>A configured AppBuilder ready for application startup.</returns>
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
@@ -17,12 +17,22 @@ public class AutoDiscoveryService : IAutoDiscoveryService
private const string EnvVarName = "JDESCOPING_CONFIG_PATH";
private const string AppSettingsFileName = "appsettings.json";
/// <summary>
/// Initializes a new instance of the <see cref="AutoDiscoveryService"/> class.
/// </summary>
/// <param name="fileSystem">The file system abstraction to use for directory and file checks.</param>
/// <param name="logger">Optional logger for recording discovery process information.</param>
public AutoDiscoveryService(IFileSystem fileSystem, ILogger<AutoDiscoveryService>? logger = null)
{
_fileSystem = fileSystem;
_logger = logger;
}
/// <summary>
/// Finds the configuration folder using a prioritized search strategy.
/// </summary>
/// <param name="ct">Cancellation token for the async operation.</param>
/// <returns>The path to a valid configuration folder, or null if none is found.</returns>
public Task<string?> FindConfigFolderAsync(CancellationToken ct = default)
{
// 1. Check environment variable
@@ -12,12 +12,24 @@ public class BackupService : IBackupService
private readonly ILogger<BackupService>? _logger;
private const string TimestampFormat = "yyyy-MM-dd_HHmmss";
/// <summary>
/// Initializes a new instance of the <see cref="BackupService"/> class.
/// </summary>
/// <param name="fileSystem">The file system abstraction for file operations.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
public BackupService(IFileSystem fileSystem, ILogger<BackupService>? logger = null)
{
_fileSystem = fileSystem;
_logger = logger;
}
/// <summary>
/// Creates a backup copy of the specified file with a timestamp suffix.
/// </summary>
/// <param name="filePath">The path of the file to backup.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The path of the created backup file.</returns>
/// <exception cref="FileNotFoundException">Thrown when the source file does not exist.</exception>
public async Task<string> CreateBackupAsync(string filePath, CancellationToken ct = default)
{
if (!_fileSystem.FileExists(filePath))
@@ -34,6 +46,12 @@ public class BackupService : IBackupService
return backupPath;
}
/// <summary>
/// Retrieves a list of existing backups for the specified file, sorted by timestamp descending.
/// </summary>
/// <param name="filePath">The original file path to find backups for.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A read-only list of backup information sorted by timestamp.</returns>
public async Task<IReadOnlyList<BackupInfo>> GetBackupsAsync(string filePath, CancellationToken ct = default)
{
var directory = _fileSystem.GetDirectoryName(filePath);
@@ -59,12 +77,24 @@ public class BackupService : IBackupService
return backups.OrderByDescending(b => b.Timestamp).ToList();
}
/// <summary>
/// Restores a backup file by copying it to the target location.
/// </summary>
/// <param name="backupPath">The path of the backup file to restore from.</param>
/// <param name="targetPath">The target path where the backup should be restored.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task RestoreBackupAsync(string backupPath, string targetPath, CancellationToken ct = default)
{
_logger?.LogInformation("Restoring backup from {BackupPath} to {TargetPath}", backupPath, targetPath);
await _fileSystem.CopyFileAsync(backupPath, targetPath, ct);
}
/// <summary>
/// Removes old backup files, keeping only the most recent backups.
/// </summary>
/// <param name="filePath">The original file path to find backups for.</param>
/// <param name="keepCount">The number of most recent backups to retain (default: 10).</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task CleanupOldBackupsAsync(string filePath, int keepCount = 10, CancellationToken ct = default)
{
var backups = await GetBackupsAsync(filePath, ct);
@@ -21,12 +21,24 @@ public class ConfigFileService : IConfigFileService
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Initializes a new instance of the <see cref="ConfigFileService"/> class.
/// </summary>
/// <param name="fileSystem">The file system abstraction for file operations.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
public ConfigFileService(IFileSystem fileSystem, ILogger<ConfigFileService>? logger = null)
{
_fileSystem = fileSystem;
_logger = logger;
}
/// <summary>
/// Loads the application settings configuration from the specified file path.
/// </summary>
/// <param name="path">The file path to load appsettings from.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The loaded configuration model or a new empty model if deserialization fails.</returns>
/// <exception cref="ConfigLoadException">Thrown when the JSON cannot be parsed.</exception>
public async Task<ConfigModel> LoadAppSettingsAsync(string path, CancellationToken ct = default)
{
_logger?.LogInformation("Loading appsettings from {Path}", path);
@@ -43,6 +55,13 @@ public class ConfigFileService : IConfigFileService
}
}
/// <summary>
/// Loads the pipelines configuration from the specified file path.
/// </summary>
/// <param name="path">The file path to load pipelines from.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The loaded pipelines configuration model or a new empty model if deserialization fails.</returns>
/// <exception cref="ConfigLoadException">Thrown when the JSON cannot be parsed.</exception>
public async Task<PipelinesConfigModel> LoadPipelinesAsync(string path, CancellationToken ct = default)
{
_logger?.LogInformation("Loading pipelines from {Path}", path);
@@ -59,6 +78,12 @@ public class ConfigFileService : IConfigFileService
}
}
/// <summary>
/// Saves the application settings configuration to the specified file path.
/// </summary>
/// <param name="path">The file path to save appsettings to.</param>
/// <param name="config">The configuration model to save.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task SaveAppSettingsAsync(string path, ConfigModel config, CancellationToken ct = default)
{
_logger?.LogInformation("Saving appsettings to {Path}", path);
@@ -66,6 +91,12 @@ public class ConfigFileService : IConfigFileService
await _fileSystem.WriteAllTextAsync(path, json, ct);
}
/// <summary>
/// Saves the pipelines configuration to the specified file path.
/// </summary>
/// <param name="path">The file path to save pipelines to.</param>
/// <param name="config">The pipelines configuration model to save.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task SavePipelinesAsync(string path, PipelinesConfigModel config, CancellationToken ct = default)
{
_logger?.LogInformation("Saving pipelines to {Path}", path);
@@ -5,8 +5,17 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public class ConfigLoadException : Exception
{
/// <summary>
/// Gets the path to the configuration file that failed to load.
/// </summary>
public string FilePath { get; }
/// <summary>
/// Initializes a new instance of the ConfigLoadException class.
/// </summary>
/// <param name="filePath">The path to the configuration file that failed to load.</param>
/// <param name="message">The error message describing the failure.</param>
/// <param name="inner">The inner exception that caused this exception, if any.</param>
public ConfigLoadException(string filePath, string message, Exception? inner = null)
: base(message, inner)
{
@@ -9,6 +9,12 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public class DiffService : IDiffService
{
/// <summary>
/// Generates a diff between original and modified text content.
/// </summary>
/// <param name="original">The original text content.</param>
/// <param name="modified">The modified text content.</param>
/// <returns>A diff result containing added, removed, and unchanged lines with counts.</returns>
public DiffResult GenerateDiff(string original, string modified)
{
var diffBuilder = new InlineDiffBuilder(new Differ());
@@ -5,36 +5,96 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public class FileSystem : IFileSystem
{
/// <summary>
/// Determines whether the specified file exists.
/// </summary>
/// <param name="path">The file path to check.</param>
/// <returns>True if the file exists; otherwise, false.</returns>
public bool FileExists(string path) => File.Exists(path);
/// <summary>
/// Determines whether the specified directory exists.
/// </summary>
/// <param name="path">The directory path to check.</param>
/// <returns>True if the directory exists; otherwise, false.</returns>
public bool DirectoryExists(string path) => Directory.Exists(path);
/// <summary>
/// Reads all text from the specified file asynchronously.
/// </summary>
/// <param name="path">The file path to read from.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The file contents as a string.</returns>
public async Task<string> ReadAllTextAsync(string path, CancellationToken ct = default)
=> await File.ReadAllTextAsync(path, ct);
/// <summary>
/// Writes all text to the specified file asynchronously, overwriting if it exists.
/// </summary>
/// <param name="path">The file path to write to.</param>
/// <param name="content">The text content to write.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task WriteAllTextAsync(string path, string content, CancellationToken ct = default)
=> await File.WriteAllTextAsync(path, content, ct);
/// <summary>
/// Gets an array of file paths matching the pattern in the specified directory.
/// </summary>
/// <param name="directory">The directory path to search in.</param>
/// <param name="pattern">The search pattern to match files.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>An array of file paths matching the pattern.</returns>
public Task<string[]> GetFilesAsync(string directory, string pattern, CancellationToken ct = default)
=> Task.FromResult(Directory.GetFiles(directory, pattern));
/// <summary>
/// Copies a file from source to destination asynchronously.
/// </summary>
/// <param name="source">The source file path.</param>
/// <param name="destination">The destination file path.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task CopyFileAsync(string source, string destination, CancellationToken ct = default)
{
var content = await File.ReadAllBytesAsync(source, ct);
await File.WriteAllBytesAsync(destination, content, ct);
}
/// <summary>
/// Deletes the specified file asynchronously.
/// </summary>
/// <param name="path">The file path to delete.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task DeleteFileAsync(string path, CancellationToken ct = default)
{
File.Delete(path);
return Task.CompletedTask;
}
/// <summary>
/// Returns the directory name for the specified path.
/// </summary>
/// <param name="path">The file or directory path.</param>
/// <returns>The directory name, or an empty string if not available.</returns>
public string GetDirectoryName(string path) => Path.GetDirectoryName(path) ?? string.Empty;
/// <summary>
/// Returns the file name and extension for the specified path.
/// </summary>
/// <param name="path">The file path.</param>
/// <returns>The file name with extension.</returns>
public string GetFileName(string path) => Path.GetFileName(path);
/// <summary>
/// Returns the file name without the extension for the specified path.
/// </summary>
/// <param name="path">The file path.</param>
/// <returns>The file name without extension.</returns>
public string GetFileNameWithoutExtension(string path) => Path.GetFileNameWithoutExtension(path);
/// <summary>
/// Combines multiple path components into a single path.
/// </summary>
/// <param name="paths">The path components to combine.</param>
/// <returns>The combined path.</returns>
public string Combine(params string[] paths) => Path.Combine(paths);
}
@@ -5,8 +5,19 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public class BackupInfo
{
/// <summary>
/// Gets the full path to the backup file.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Gets the timestamp when the backup was created.
/// </summary>
public required DateTime Timestamp { get; init; }
/// <summary>
/// Gets the file size in bytes.
/// </summary>
public required long Size { get; init; }
}
@@ -15,8 +26,35 @@ public class BackupInfo
/// </summary>
public interface IBackupService
{
/// <summary>
/// Creates a backup copy of the specified file with a timestamp suffix.
/// </summary>
/// <param name="filePath">The path of the file to backup.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The path of the created backup file.</returns>
Task<string> CreateBackupAsync(string filePath, CancellationToken ct = default);
/// <summary>
/// Retrieves a list of existing backups for the specified file, sorted by timestamp descending.
/// </summary>
/// <param name="filePath">The original file path to find backups for.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A read-only list of backup information sorted by timestamp.</returns>
Task<IReadOnlyList<BackupInfo>> GetBackupsAsync(string filePath, CancellationToken ct = default);
/// <summary>
/// Restores a backup file by copying it to the target location.
/// </summary>
/// <param name="backupPath">The path of the backup file to restore from.</param>
/// <param name="targetPath">The target path where the backup should be restored.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task RestoreBackupAsync(string backupPath, string targetPath, CancellationToken ct = default);
/// <summary>
/// Removes old backup files, keeping only the most recent backups.
/// </summary>
/// <param name="filePath">The original file path to find backups for.</param>
/// <param name="keepCount">The number of most recent backups to retain (default: 10).</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task CleanupOldBackupsAsync(string filePath, int keepCount = 10, CancellationToken ct = default);
}
@@ -7,8 +7,35 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public interface IConfigFileService
{
/// <summary>
/// Loads the application settings configuration from the specified file path.
/// </summary>
/// <param name="path">The file path to load appsettings from.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The loaded configuration model or a new empty model if deserialization fails.</returns>
Task<ConfigModel> LoadAppSettingsAsync(string path, CancellationToken ct = default);
/// <summary>
/// Loads the pipelines configuration from the specified file path.
/// </summary>
/// <param name="path">The file path to load pipelines from.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The loaded pipelines configuration model or a new empty model if deserialization fails.</returns>
Task<PipelinesConfigModel> LoadPipelinesAsync(string path, CancellationToken ct = default);
/// <summary>
/// Saves the application settings configuration to the specified file path.
/// </summary>
/// <param name="path">The file path to save appsettings to.</param>
/// <param name="config">The configuration model to save.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task SaveAppSettingsAsync(string path, ConfigModel config, CancellationToken ct = default);
/// <summary>
/// Saves the pipelines configuration to the specified file path.
/// </summary>
/// <param name="path">The file path to save pipelines to.</param>
/// <param name="config">The pipelines configuration model to save.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task SavePipelinesAsync(string path, PipelinesConfigModel config, CancellationToken ct = default);
}
@@ -5,9 +5,24 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public class DiffLine
{
/// <summary>
/// Gets the line number in the original text, or null for added lines.
/// </summary>
public required int? OldLineNumber { get; init; }
/// <summary>
/// Gets the line number in the modified text, or null for removed lines.
/// </summary>
public required int? NewLineNumber { get; init; }
/// <summary>
/// Gets the text content of the line.
/// </summary>
public required string Text { get; init; }
/// <summary>
/// Gets the type of change for this line (added, removed, or unchanged).
/// </summary>
public required DiffLineType Type { get; init; }
}
@@ -23,9 +38,24 @@ public enum DiffLineType
/// </summary>
public class DiffResult
{
/// <summary>
/// Gets a value indicating whether the diff contains any changes.
/// </summary>
public bool HasChanges { get; init; }
/// <summary>
/// Gets the list of lines representing the diff result.
/// </summary>
public List<DiffLine> Lines { get; init; } = [];
/// <summary>
/// Gets the number of lines added in the modification.
/// </summary>
public int Insertions { get; init; }
/// <summary>
/// Gets the number of lines removed in the modification.
/// </summary>
public int Deletions { get; init; }
}
@@ -34,5 +64,11 @@ public class DiffResult
/// </summary>
public interface IDiffService
{
/// <summary>
/// Generates a diff between original and modified text content.
/// </summary>
/// <param name="original">The original text content.</param>
/// <param name="modified">The modified text content.</param>
/// <returns>A diff result containing added, removed, and unchanged lines with counts.</returns>
DiffResult GenerateDiff(string original, string modified);
}
@@ -5,15 +5,85 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public interface IFileSystem
{
/// <summary>
/// Determines whether the specified file exists.
/// </summary>
/// <param name="path">The file path to check.</param>
/// <returns>True if the file exists; otherwise, false.</returns>
bool FileExists(string path);
/// <summary>
/// Determines whether the specified directory exists.
/// </summary>
/// <param name="path">The directory path to check.</param>
/// <returns>True if the directory exists; otherwise, false.</returns>
bool DirectoryExists(string path);
/// <summary>
/// Reads all text from the specified file asynchronously.
/// </summary>
/// <param name="path">The file path to read from.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The file contents as a string.</returns>
Task<string> ReadAllTextAsync(string path, CancellationToken ct = default);
/// <summary>
/// Writes all text to the specified file asynchronously, overwriting if it exists.
/// </summary>
/// <param name="path">The file path to write to.</param>
/// <param name="content">The text content to write.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task WriteAllTextAsync(string path, string content, CancellationToken ct = default);
/// <summary>
/// Gets an array of file paths matching the pattern in the specified directory.
/// </summary>
/// <param name="directory">The directory path to search in.</param>
/// <param name="pattern">The search pattern to match files.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>An array of file paths matching the pattern.</returns>
Task<string[]> GetFilesAsync(string directory, string pattern, CancellationToken ct = default);
/// <summary>
/// Copies a file from source to destination asynchronously.
/// </summary>
/// <param name="source">The source file path.</param>
/// <param name="destination">The destination file path.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task CopyFileAsync(string source, string destination, CancellationToken ct = default);
/// <summary>
/// Deletes the specified file asynchronously.
/// </summary>
/// <param name="path">The file path to delete.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task DeleteFileAsync(string path, CancellationToken ct = default);
/// <summary>
/// Returns the directory name for the specified path.
/// </summary>
/// <param name="path">The file or directory path.</param>
/// <returns>The directory name, or an empty string if not available.</returns>
string GetDirectoryName(string path);
/// <summary>
/// Returns the file name and extension for the specified path.
/// </summary>
/// <param name="path">The file path.</param>
/// <returns>The file name with extension.</returns>
string GetFileName(string path);
/// <summary>
/// Returns the file name without the extension for the specified path.
/// </summary>
/// <param name="path">The file path.</param>
/// <returns>The file name without extension.</returns>
string GetFileNameWithoutExtension(string path);
/// <summary>
/// Combines multiple path components into a single path.
/// </summary>
/// <param name="paths">The path components to combine.</param>
/// <returns>The combined path.</returns>
string Combine(params string[] paths);
}
@@ -7,11 +7,31 @@ namespace JdeScoping.ConfigManager.Services;
/// </summary>
public class ValidationResult
{
/// <summary>
/// Gets a value indicating whether the validation succeeded (no errors).
/// </summary>
public bool IsValid => Errors.Count == 0;
/// <summary>
/// Gets the list of validation errors encountered.
/// </summary>
public List<string> Errors { get; } = [];
/// <summary>
/// Gets the list of validation warnings encountered.
/// </summary>
public List<string> Warnings { get; } = [];
/// <summary>
/// Adds an error message to the validation result.
/// </summary>
/// <param name="message">The error message to add.</param>
public void AddError(string message) => Errors.Add(message);
/// <summary>
/// Adds a warning message to the validation result.
/// </summary>
/// <param name="message">The warning message to add.</param>
public void AddWarning(string message) => Warnings.Add(message);
}
@@ -20,6 +40,17 @@ public class ValidationResult
/// </summary>
public interface IValidationService
{
/// <summary>
/// Validates the application settings configuration.
/// </summary>
/// <param name="config">The configuration model to validate.</param>
/// <returns>A validation result containing any errors or warnings found.</returns>
ValidationResult ValidateAppSettings(ConfigModel config);
/// <summary>
/// Validates the pipelines configuration.
/// </summary>
/// <param name="config">The pipelines configuration model to validate.</param>
/// <returns>A validation result containing any errors or warnings found.</returns>
ValidationResult ValidatePipelines(PipelinesConfigModel config);
}
@@ -9,6 +9,11 @@ public class ValidationService : IValidationService
{
private static readonly string[] ValidConnections = ["jde", "cms", "giw", "lotfinderdb"];
/// <summary>
/// Validates the application settings configuration.
/// </summary>
/// <param name="config">The configuration model to validate.</param>
/// <returns>A validation result containing any errors or warnings found.</returns>
public ValidationResult ValidateAppSettings(ConfigModel config)
{
var result = new ValidationResult();
@@ -50,6 +55,11 @@ public class ValidationService : IValidationService
return result;
}
/// <summary>
/// Validates the pipelines configuration.
/// </summary>
/// <param name="config">The pipelines configuration model to validate.</param>
/// <returns>A validation result containing any errors or warnings found.</returns>
public ValidationResult ValidatePipelines(PipelinesConfigModel config)
{
var result = new ValidationResult();
@@ -12,20 +12,38 @@ public class AsyncRelayCommand : ICommand
private bool _isExecuting;
private EventHandler? _canExecuteChanged;
/// <summary>
/// Occurs when the result of <see cref="CanExecute"/> has changed.
/// </summary>
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
/// <summary>
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class.
/// </summary>
/// <param name="execute">The async action to execute when the command is invoked.</param>
/// <param name="canExecute">An optional predicate to determine if the command can execute.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="execute"/> is null.</exception>
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
/// <summary>
/// Determines whether the command can execute in its current state.
/// </summary>
/// <param name="parameter">Unused parameter required by <see cref="ICommand"/> interface.</param>
/// <returns>False if the command is currently executing; otherwise returns the result of the canExecute predicate.</returns>
public bool CanExecute(object? parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true);
/// <summary>
/// Executes the async command, preventing concurrent execution.
/// </summary>
/// <param name="parameter">Unused parameter required by <see cref="ICommand"/> interface.</param>
public async void Execute(object? parameter)
{
if (!CanExecute(parameter)) return;
@@ -44,5 +62,8 @@ public class AsyncRelayCommand : ICommand
}
}
/// <summary>
/// Raises the <see cref="CanExecuteChanged"/> event to notify command bindings of state changes.
/// </summary>
public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
@@ -11,26 +11,52 @@ public class RelayCommand : ICommand
private readonly Predicate<object?>? _canExecute;
private EventHandler? _canExecuteChanged;
/// <summary>
/// Occurs when the result of <see cref="CanExecute"/> has changed.
/// </summary>
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
/// <summary>
/// Initializes a new instance of the <see cref="RelayCommand"/> class with a parameterized action.
/// </summary>
/// <param name="execute">The action to execute when the command is invoked.</param>
/// <param name="canExecute">An optional predicate to determine if the command can execute.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="execute"/> is null.</exception>
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
/// <summary>
/// Initializes a new instance of the <see cref="RelayCommand"/> class with a parameterless action.
/// </summary>
/// <param name="execute">The parameterless action to execute when the command is invoked.</param>
/// <param name="canExecute">An optional predicate to determine if the command can execute.</param>
public RelayCommand(Action execute, Func<bool>? canExecute = null)
: this(_ => execute(), canExecute != null ? _ => canExecute() : null)
{
}
/// <summary>
/// Determines whether the command can execute in its current state.
/// </summary>
/// <param name="parameter">The parameter to pass to the canExecute predicate, or null.</param>
/// <returns>True if the command can execute; otherwise, false.</returns>
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
/// <summary>
/// Executes the command with the specified parameter.
/// </summary>
/// <param name="parameter">The parameter to pass to the execute action.</param>
public void Execute(object? parameter) => _execute(parameter);
/// <summary>
/// Raises the <see cref="CanExecuteChanged"/> event to notify command bindings of state changes.
/// </summary>
public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
@@ -27,30 +27,61 @@ public class TreeNodeViewModel : ViewModelBase
private bool _isSelected;
private ValidationState _validationState = ValidationState.Unknown;
/// <summary>
/// Gets the name of the tree node.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the icon identifier for the tree node.
/// </summary>
public string Icon { get; }
/// <summary>
/// Gets the type of tree node (folder, settings section, or pipeline).
/// </summary>
public TreeNodeType NodeType { get; }
/// <summary>
/// Gets or sets the configuration section key associated with this node.
/// </summary>
public string? SectionKey { get; init; }
/// <summary>
/// Gets the collection of child nodes.
/// </summary>
public ObservableCollection<TreeNodeViewModel> Children { get; } = [];
/// <summary>
/// Gets or sets a value indicating whether this node has been modified.
/// </summary>
public bool IsModified
{
get => _isModified;
set => SetProperty(ref _isModified, value);
}
/// <summary>
/// Gets or sets a value indicating whether this node is expanded in the tree view.
/// </summary>
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
/// <summary>
/// Gets or sets a value indicating whether this node is selected in the tree view.
/// </summary>
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
/// <summary>
/// Gets or sets the validation state of this node.
/// </summary>
public ValidationState ValidationState
{
get => _validationState;
@@ -61,6 +92,9 @@ public class TreeNodeViewModel : ViewModelBase
}
}
/// <summary>
/// Gets the icon character representing the validation state.
/// </summary>
public string StatusIcon => ValidationState switch
{
ValidationState.Valid => "✓",
@@ -69,6 +103,12 @@ public class TreeNodeViewModel : ViewModelBase
_ => ""
};
/// <summary>
/// Initializes a new instance of the <see cref="TreeNodeViewModel"/> class.
/// </summary>
/// <param name="name">The name of the node.</param>
/// <param name="icon">The icon identifier for the node.</param>
/// <param name="nodeType">The type of the node.</param>
public TreeNodeViewModel(string name, string icon, TreeNodeType nodeType)
{
Name = name;
@@ -8,13 +8,28 @@ namespace JdeScoping.ConfigManager.ViewModels;
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
/// <summary>
/// Occurs when a property value changes.
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// Raises the PropertyChanged event with the specified property name.
/// </summary>
/// <param name="propertyName">The name of the property that changed. Automatically captured from the calling member.</param>
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// Updates a field value and raises PropertyChanged if the value actually changed.
/// </summary>
/// <typeparam name="T">The type of the property value.</typeparam>
/// <param name="field">The backing field to update by reference.</param>
/// <param name="value">The new value to assign.</param>
/// <param name="propertyName">The name of the property being changed. Automatically captured from the calling member.</param>
/// <returns>True if the value was changed; otherwise, false.</returns>
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
@@ -4,6 +4,9 @@ namespace JdeScoping.ConfigManager.Views;
public partial class MainWindow : Window
{
/// <summary>
/// Initializes a new instance of the MainWindow.
/// </summary>
public MainWindow()
{
InitializeComponent();
@@ -13,13 +13,18 @@ namespace JdeScoping.SecureStoreManager;
public partial class App : Avalonia.Application
{
/// <summary>
/// Gets the service provider instance for dependency injection.
/// </summary>
public static IServiceProvider Services { get; private set; } = null!;
/// <inheritdoc />
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
var services = new ServiceCollection();
@@ -11,6 +11,11 @@ public class SecretUseCases
private readonly ISecureStoreManager _storeManager;
private readonly ILogger<SecretUseCases> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SecretUseCases"/> class.
/// </summary>
/// <param name="storeManager">The secure store manager.</param>
/// <param name="logger">The logger instance.</param>
public SecretUseCases(ISecureStoreManager storeManager, ILogger<SecretUseCases> logger)
{
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
@@ -20,6 +25,8 @@ public class SecretUseCases
/// <summary>
/// Sets a secret with the given key and value.
/// </summary>
/// <param name="key">The secret key.</param>
/// <param name="value">The secret value.</param>
public void SetSecret(string key, string value)
{
_logger.LogInformation("Setting secret {Key}", key);
@@ -29,6 +36,7 @@ public class SecretUseCases
/// <summary>
/// Removes a secret by key.
/// </summary>
/// <param name="key">The secret key to remove.</param>
public void RemoveSecret(string key)
{
_logger.LogInformation("Removing secret {Key}", key);
@@ -46,6 +54,8 @@ public class SecretUseCases
/// <summary>
/// Gets the value of a secret by key.
/// </summary>
/// <param name="key">The secret key.</param>
/// <returns>The secret value.</returns>
public string GetSecret(string key)
{
return _storeManager.GetSecret(key);
@@ -11,6 +11,11 @@ public class StoreUseCases
private readonly ISecureStoreManager _storeManager;
private readonly ILogger<StoreUseCases> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="StoreUseCases"/> class.
/// </summary>
/// <param name="storeManager">The secure store manager instance.</param>
/// <param name="logger">The logger instance.</param>
public StoreUseCases(ISecureStoreManager storeManager, ILogger<StoreUseCases> logger)
{
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
@@ -20,6 +25,9 @@ public class StoreUseCases
/// <summary>
/// Creates a new store with either key file or password authentication.
/// </summary>
/// <param name="storePath">The path where the store will be created.</param>
/// <param name="keyFilePath">The path to the key file, or null for password-based authentication.</param>
/// <param name="password">The password for authentication, or null for key file-based authentication.</param>
public void CreateStore(string storePath, string? keyFilePath, string? password)
{
_logger.LogInformation("Creating store at {StorePath}", storePath);
@@ -43,6 +51,9 @@ public class StoreUseCases
/// <summary>
/// Opens an existing store with either key file or password authentication.
/// </summary>
/// <param name="storePath">The path to the existing store.</param>
/// <param name="keyFilePath">The path to the key file, or null for password-based authentication.</param>
/// <param name="password">The password for authentication, or null for key file-based authentication.</param>
public void OpenStore(string storePath, string? keyFilePath, string? password)
{
_logger.LogInformation("Opening store at {StorePath}", storePath);
@@ -84,6 +95,7 @@ public class StoreUseCases
/// <summary>
/// Generates a new key file at the specified path.
/// </summary>
/// <param name="path">The path where the key file will be generated.</param>
public void GenerateKeyFile(string path)
{
_logger.LogInformation("Generating key file at {Path}", path);
@@ -94,6 +106,7 @@ public class StoreUseCases
/// <summary>
/// Exports the current store's key to a file.
/// </summary>
/// <param name="path">The path where the key will be exported.</param>
public void ExportKey(string path)
{
_logger.LogInformation("Exporting key to {Path}", path);
@@ -8,6 +8,14 @@ namespace JdeScoping.SecureStoreManager.Converters;
/// </summary>
public class InverseBooleanConverter : IValueConverter
{
/// <summary>
/// Converts a boolean value to its inverted counterpart.
/// </summary>
/// <param name="value">The boolean value to invert.</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>The inverted boolean value, or false if the input is not a boolean.</returns>
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool boolValue)
@@ -17,6 +25,14 @@ public class InverseBooleanConverter : IValueConverter
return false;
}
/// <summary>
/// Converts a value back to a boolean by inverting it.
/// </summary>
/// <param name="value">The value to invert.</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>The inverted boolean value, or false if the input is not a boolean.</returns>
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool boolValue)
@@ -32,6 +48,14 @@ public class InverseBooleanConverter : IValueConverter
/// </summary>
public class BooleanToVisibilityIconConverter : IValueConverter
{
/// <summary>
/// Converts a boolean to a visibility icon string.
/// </summary>
/// <param name="value">The boolean value indicating visibility.</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>"Hide" if true, "Show" if false or input is not boolean.</returns>
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isVisible)
@@ -42,6 +66,15 @@ public class BooleanToVisibilityIconConverter : IValueConverter
return "Show";
}
/// <summary>
/// Converts a value back (not implemented for visibility icons).
/// </summary>
/// <param name="value">The value to convert back (ignored).</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>Not implemented.</returns>
/// <exception cref="NotImplementedException">Always thrown.</exception>
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
@@ -53,11 +86,28 @@ public class BooleanToVisibilityIconConverter : IValueConverter
/// </summary>
public class NullToBoolConverter : IValueConverter
{
/// <summary>
/// Converts a value to a boolean based on null status.
/// </summary>
/// <param name="value">The value to check for null.</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>True if value is not null, false otherwise.</returns>
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value != null;
}
/// <summary>
/// Converts a value back (not implemented for null checks).
/// </summary>
/// <param name="value">The value to convert back (ignored).</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>Not implemented.</returns>
/// <exception cref="NotImplementedException">Always thrown.</exception>
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
@@ -69,6 +119,14 @@ public class NullToBoolConverter : IValueConverter
/// </summary>
public class StringToBoolConverter : IValueConverter
{
/// <summary>
/// Converts a string to a boolean based on whether it's empty or null.
/// </summary>
/// <param name="value">The string value to check.</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>True if string is not null or whitespace, false otherwise.</returns>
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is string str)
@@ -78,6 +136,15 @@ public class StringToBoolConverter : IValueConverter
return false;
}
/// <summary>
/// Converts a value back (not implemented for string checks).
/// </summary>
/// <param name="value">The value to convert back (ignored).</param>
/// <param name="targetType">The target type (ignored).</param>
/// <param name="parameter">An optional parameter (ignored).</param>
/// <param name="culture">The culture information (ignored).</param>
/// <returns>Not implemented.</returns>
/// <exception cref="NotImplementedException">Always thrown.</exception>
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
@@ -4,13 +4,17 @@ namespace JdeScoping.SecureStoreManager;
internal class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called.
/// <summary>
/// The main entry point for the application.
/// </summary>
/// <param name="args">Command-line arguments.</param>
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
/// <summary>
/// Builds the Avalonia application configuration.
/// </summary>
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
@@ -18,6 +18,10 @@ public class AvaloniaClipboardService : IClipboardService
_getClipboard = getClipboard ?? throw new ArgumentNullException(nameof(getClipboard));
}
/// <summary>
/// Sets the clipboard text asynchronously.
/// </summary>
/// <param name="text">The text to set on the clipboard.</param>
public async Task SetTextAsync(string text)
{
var clipboard = _getClipboard();
@@ -22,6 +22,12 @@ public class AvaloniaDialogService : IDialogService
_getOwnerWindow = getOwnerWindow ?? throw new ArgumentNullException(nameof(getOwnerWindow));
}
/// <summary>
/// Displays an error dialog with the specified message and title.
/// </summary>
/// <param name="message">The error message to display.</param>
/// <param name="title">The title of the error dialog.</param>
/// <returns>A task that completes when the dialog is dismissed.</returns>
public async Task ShowErrorAsync(string message, string title)
{
var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.Ok, Icon.Error);
@@ -36,6 +42,12 @@ public class AvaloniaDialogService : IDialogService
}
}
/// <summary>
/// Displays an informational dialog with the specified message and title.
/// </summary>
/// <param name="message">The information message to display.</param>
/// <param name="title">The title of the information dialog.</param>
/// <returns>A task that completes when the dialog is dismissed.</returns>
public async Task ShowInfoAsync(string message, string title)
{
var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.Ok, Icon.Info);
@@ -50,6 +62,12 @@ public class AvaloniaDialogService : IDialogService
}
}
/// <summary>
/// Displays a confirmation dialog with Yes/No buttons.
/// </summary>
/// <param name="message">The confirmation message to display.</param>
/// <param name="title">The title of the confirmation dialog.</param>
/// <returns>A task that completes with true if Yes was clicked; otherwise, false.</returns>
public async Task<bool> ShowConfirmationAsync(string message, string title)
{
var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.YesNo, Icon.Warning);
@@ -66,6 +84,10 @@ public class AvaloniaDialogService : IDialogService
return result == ButtonResult.Yes;
}
/// <summary>
/// Displays a prompt asking the user whether to save unsaved changes.
/// </summary>
/// <returns>A task that completes with the user's choice: Save, DontSave, or Cancel.</returns>
public async Task<UnsavedChangesResult> ShowUnsavedChangesPromptAsync()
{
var box = MessageBoxManager.GetMessageBoxStandard(
@@ -93,6 +115,14 @@ public class AvaloniaDialogService : IDialogService
};
}
/// <summary>
/// Displays a save file dialog allowing the user to select a file path.
/// </summary>
/// <param name="title">The title of the save file dialog.</param>
/// <param name="fileTypeName">The friendly name of the file type (e.g., "Excel Files").</param>
/// <param name="pattern">The file extension pattern (e.g., "*.xlsx").</param>
/// <param name="defaultExtension">The default file extension (e.g., ".xlsx").</param>
/// <returns>A task that completes with the selected file path, or null if canceled.</returns>
public async Task<string?> ShowSaveFileDialogAsync(string title, string fileTypeName, string pattern, string defaultExtension)
{
var window = _getOwnerWindow();
@@ -113,6 +143,13 @@ public class AvaloniaDialogService : IDialogService
return file?.Path.LocalPath;
}
/// <summary>
/// Displays an open file dialog allowing the user to select a file to open.
/// </summary>
/// <param name="title">The title of the open file dialog.</param>
/// <param name="fileTypeName">The friendly name of the file type (e.g., "Excel Files").</param>
/// <param name="pattern">The file extension pattern (e.g., "*.xlsx").</param>
/// <returns>A task that completes with the selected file path, or null if canceled.</returns>
public async Task<string?> ShowOpenFileDialogAsync(string title, string fileTypeName, string pattern)
{
var window = _getOwnerWindow();
@@ -19,16 +19,22 @@ public interface IDialogService
/// <summary>
/// Shows an error message dialog.
/// </summary>
/// <param name="message">The error message to display.</param>
/// <param name="title">The dialog title.</param>
Task ShowErrorAsync(string message, string title);
/// <summary>
/// Shows an informational message dialog.
/// </summary>
/// <param name="message">The informational message to display.</param>
/// <param name="title">The dialog title.</param>
Task ShowInfoAsync(string message, string title);
/// <summary>
/// Shows a confirmation dialog with Yes/No options.
/// </summary>
/// <param name="message">The confirmation message to display.</param>
/// <param name="title">The dialog title.</param>
/// <returns>True if user clicked Yes, false otherwise.</returns>
Task<bool> ShowConfirmationAsync(string message, string title);
@@ -35,6 +35,7 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
/// <summary>
/// Creates a new SecureStoreManager with the specified logger.
/// </summary>
/// <param name="logger">Logger instance for diagnostic output.</param>
public SecureStoreManager(ILogger<SecureStoreManager> logger)
{
_logger = logger ?? NullLogger<SecureStoreManager>.Instance;
@@ -320,6 +321,9 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
}
/// <summary>
/// Releases the resources used by the <see cref="SecureStoreManager"/>.
/// </summary>
public void Dispose()
{
if (_disposed) return;
@@ -12,6 +12,9 @@ public class AsyncRelayCommand : ICommand
private bool _isExecuting;
private EventHandler? _canExecuteChanged;
/// <summary>
/// Raised when the result of CanExecute() may have changed.
/// </summary>
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
@@ -38,11 +41,19 @@ public class AsyncRelayCommand : ICommand
_canExecute = canExecute;
}
/// <summary>
/// Determines whether the command can be executed in its current state.
/// </summary>
/// <param name="parameter">The command parameter (unused).</param>
public bool CanExecute(object? parameter)
{
return !_isExecuting && (_canExecute?.Invoke() ?? true);
}
/// <summary>
/// Executes the async command if it can execute.
/// </summary>
/// <param name="parameter">The command parameter (unused).</param>
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
@@ -15,12 +15,18 @@ public class NewStoreDialogViewModel : ViewModelBase
private bool _useKeyFile = true;
private bool _usePassword;
/// <summary>
/// Initializes a new instance of the <see cref="NewStoreDialogViewModel"/> class.
/// </summary>
public NewStoreDialogViewModel()
{
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
}
/// <summary>
/// Gets or sets the path to the store file to create.
/// </summary>
public string StorePath
{
get => _storePath;
@@ -31,6 +37,9 @@ public class NewStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets the path to the key file for encryption.
/// </summary>
public string KeyFilePath
{
get => _keyFilePath;
@@ -41,6 +50,9 @@ public class NewStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets the password for store encryption.
/// </summary>
public string Password
{
get => _password;
@@ -51,6 +63,9 @@ public class NewStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets the password confirmation value.
/// </summary>
public string ConfirmPassword
{
get => _confirmPassword;
@@ -61,6 +76,9 @@ public class NewStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets whether to use key file for store encryption.
/// </summary>
public bool UseKeyFile
{
get => _useKeyFile;
@@ -74,6 +92,9 @@ public class NewStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets whether to use password for store encryption.
/// </summary>
public bool UsePassword
{
get => _usePassword;
@@ -93,9 +114,19 @@ public class NewStoreDialogViewModel : ViewModelBase
OnPropertyChanged(nameof(ValidationError));
}
/// <summary>
/// Gets the command to browse for store path location.
/// </summary>
public ICommand BrowseStorePathCommand { get; }
/// <summary>
/// Gets the command to browse for key file path location.
/// </summary>
public ICommand BrowseKeyFilePathCommand { get; }
/// <summary>
/// Gets a value indicating whether the dialog input is valid.
/// </summary>
public bool IsValid
{
get
@@ -113,6 +144,9 @@ public class NewStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets the validation error message, or null if valid.
/// </summary>
public string? ValidationError
{
get
@@ -187,12 +221,18 @@ public class OpenStoreDialogViewModel : ViewModelBase
private bool _useKeyFile = true;
private bool _usePassword;
/// <summary>
/// Initializes a new instance of the <see cref="OpenStoreDialogViewModel"/> class.
/// </summary>
public OpenStoreDialogViewModel()
{
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
}
/// <summary>
/// Gets or sets the path to the store file to open.
/// </summary>
public string StorePath
{
get => _storePath;
@@ -203,6 +243,9 @@ public class OpenStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets the path to the key file for decryption.
/// </summary>
public string KeyFilePath
{
get => _keyFilePath;
@@ -213,6 +256,9 @@ public class OpenStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets the password for store decryption.
/// </summary>
public string Password
{
get => _password;
@@ -223,6 +269,9 @@ public class OpenStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets whether to use key file for store decryption.
/// </summary>
public bool UseKeyFile
{
get => _useKeyFile;
@@ -236,6 +285,9 @@ public class OpenStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets whether to use password for store decryption.
/// </summary>
public bool UsePassword
{
get => _usePassword;
@@ -255,9 +307,19 @@ public class OpenStoreDialogViewModel : ViewModelBase
OnPropertyChanged(nameof(ValidationError));
}
/// <summary>
/// Gets the command to browse for store path location.
/// </summary>
public ICommand BrowseStorePathCommand { get; }
/// <summary>
/// Gets the command to browse for key file path location.
/// </summary>
public ICommand BrowseKeyFilePathCommand { get; }
/// <summary>
/// Gets a value indicating whether the dialog input is valid.
/// </summary>
public bool IsValid
{
get
@@ -275,6 +337,9 @@ public class OpenStoreDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets the validation error message, or null if valid.
/// </summary>
public string? ValidationError
{
get
@@ -348,10 +413,18 @@ public class SecretEditDialogViewModel : ViewModelBase
private string _value = string.Empty;
private bool _isNewSecret = true;
/// <summary>
/// Initializes a new instance of the <see cref="SecretEditDialogViewModel"/> class.
/// </summary>
public SecretEditDialogViewModel()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SecretEditDialogViewModel"/> class with a key and value for editing.
/// </summary>
/// <param name="key">The secret key.</param>
/// <param name="value">The secret value.</param>
public SecretEditDialogViewModel(string key, string value)
{
_key = key;
@@ -359,6 +432,9 @@ public class SecretEditDialogViewModel : ViewModelBase
_isNewSecret = false;
}
/// <summary>
/// Gets or sets the secret key.
/// </summary>
public string Key
{
get => _key;
@@ -369,24 +445,42 @@ public class SecretEditDialogViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets the secret value.
/// </summary>
public string Value
{
get => _value;
set => SetProperty(ref _value, value);
}
/// <summary>
/// Gets or sets a value indicating whether this is a new secret being added.
/// </summary>
public bool IsNewSecret
{
get => _isNewSecret;
set => SetProperty(ref _isNewSecret, value);
}
/// <summary>
/// Gets a value indicating whether the key field is editable.
/// </summary>
public bool IsKeyEditable => _isNewSecret;
/// <summary>
/// Gets the dialog title based on whether this is a new secret or edit.
/// </summary>
public string DialogTitle => _isNewSecret ? "Add Secret" : "Edit Secret";
/// <summary>
/// Gets a value indicating whether the dialog input is valid.
/// </summary>
public bool IsValid => !string.IsNullOrWhiteSpace(Key);
/// <summary>
/// Gets the validation error message, or null if valid.
/// </summary>
public string? ValidationError
{
get
@@ -16,6 +16,12 @@ public class MainWindowViewModel : ViewModelBase
private SecretItemViewModel? _selectedSecret;
private string _statusMessage = "Ready";
/// <summary>
/// Initializes a new instance of the MainWindowViewModel.
/// </summary>
/// <param name="storeManager">The secure store manager service.</param>
/// <param name="dialogService">The dialog service for user interactions.</param>
/// <param name="clipboardService">The clipboard service for copying secrets.</param>
public MainWindowViewModel(
ISecureStoreManager storeManager,
IDialogService dialogService,
@@ -102,24 +108,64 @@ public class MainWindowViewModel : ViewModelBase
public bool HasUnsavedChanges => _storeManager.HasUnsavedChanges;
// File Commands
/// <summary>
/// Gets the command to create a new store.
/// </summary>
public ICommand NewStoreCommand { get; }
/// <summary>
/// Gets the command to open an existing store.
/// </summary>
public ICommand OpenStoreCommand { get; }
/// <summary>
/// Gets the command to save the current store.
/// </summary>
public ICommand SaveCommand { get; }
/// <summary>
/// Gets the command to close the current store.
/// </summary>
public ICommand CloseStoreCommand { get; }
/// <summary>
/// Gets the command to exit the application.
/// </summary>
public ICommand ExitCommand { get; }
// Secret Commands
/// <summary>
/// Gets the command to add a new secret.
/// </summary>
public ICommand AddSecretCommand { get; }
/// <summary>
/// Gets the command to edit the selected secret.
/// </summary>
public ICommand EditSecretCommand { get; }
/// <summary>
/// Gets the command to delete the selected secret.
/// </summary>
public ICommand DeleteSecretCommand { get; }
// Tools Commands
/// <summary>
/// Gets the command to generate a new key file.
/// </summary>
public ICommand GenerateKeyFileCommand { get; }
/// <summary>
/// Gets the command to export the store's key.
/// </summary>
public ICommand ExportKeyCommand { get; }
/// <summary>
/// Creates a new store. Called by the dialog.
/// </summary>
/// <param name="storePath">The path where the store file will be created.</param>
/// <param name="keyFilePath">Optional path to a key file for encryption.</param>
/// <param name="password">Optional password for encryption.</param>
public async Task CreateNewStoreAsync(string storePath, string? keyFilePath, string? password)
{
try
@@ -154,6 +200,9 @@ public class MainWindowViewModel : ViewModelBase
/// <summary>
/// Opens an existing store. Called by the dialog.
/// </summary>
/// <param name="storePath">The path to the store file to open.</param>
/// <param name="keyFilePath">Optional path to a key file for decryption.</param>
/// <param name="password">Optional password for decryption.</param>
public async Task OpenExistingStoreAsync(string storePath, string? keyFilePath, string? password)
{
try
@@ -188,6 +237,9 @@ public class MainWindowViewModel : ViewModelBase
/// <summary>
/// Adds or updates a secret. Called by the dialog.
/// </summary>
/// <param name="key">The secret key identifier.</param>
/// <param name="value">The secret value to store.</param>
/// <param name="isNew">True if this is a new secret, false if updating an existing one.</param>
public async Task SaveSecretAsync(string key, string value, bool isNew)
{
try
@@ -415,9 +467,28 @@ public class MainWindowViewModel : ViewModelBase
}
// Events for view to show dialogs (these require view-specific DataContext setup)
/// <summary>
/// Raised when a new store creation dialog should be shown.
/// </summary>
public event Action? OnRequestNewStoreDialog;
/// <summary>
/// Raised when an open store dialog should be shown.
/// </summary>
public event Action? OnRequestOpenStoreDialog;
/// <summary>
/// Raised when a new secret dialog should be shown.
/// </summary>
public event Action? OnRequestAddSecretDialog;
/// <summary>
/// Raised when an edit secret dialog should be shown with the specified key and value.
/// </summary>
public event Action<string, string>? OnRequestEditSecretDialog;
/// <summary>
/// Raised when the application should close.
/// </summary>
public event Action? OnRequestClose;
}
@@ -11,6 +11,9 @@ public class RelayCommand : ICommand
private readonly Predicate<object?>? _canExecute;
private EventHandler? _canExecuteChanged;
/// <summary>
/// Raised when the command's ability to execute may have changed.
/// </summary>
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
@@ -56,11 +59,20 @@ public class RelayCommand : ICommand
{
}
/// <summary>
/// Determines whether the command can execute.
/// </summary>
/// <param name="parameter">An optional command parameter.</param>
/// <returns>True if the command can execute, false otherwise.</returns>
public bool CanExecute(object? parameter)
{
return _canExecute == null || _canExecute(parameter);
}
/// <summary>
/// Executes the command with the specified parameter.
/// </summary>
/// <param name="parameter">An optional command parameter.</param>
public void Execute(object? parameter)
{
_execute(parameter);
@@ -13,6 +13,12 @@ public class SecretItemViewModel : ViewModelBase
private bool _isValueVisible;
private const string MaskedValue = "********";
/// <summary>
/// Initializes a new instance of the <see cref="SecretItemViewModel"/> class.
/// </summary>
/// <param name="key">The secret key name.</param>
/// <param name="value">The secret value.</param>
/// <param name="clipboardService">The clipboard service for copy operations.</param>
public SecretItemViewModel(string key, string value, IClipboardService clipboardService)
{
Key = key;
@@ -8,6 +8,7 @@ namespace JdeScoping.SecureStoreManager.ViewModels;
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
/// <summary>Occurs when a property value changes.</summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
@@ -9,6 +9,9 @@ public partial class MainWindow : Window
{
private MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel;
/// <summary>
/// Initializes a new instance of the <see cref="MainWindow"/> class.
/// </summary>
public MainWindow()
{
InitializeComponent();
@@ -10,8 +10,14 @@ namespace JdeScoping.SecureStoreManager.Views;
public partial class NewStoreDialog : Window
{
/// <summary>
/// Gets the view model for this dialog.
/// </summary>
public NewStoreDialogViewModel ViewModel => (NewStoreDialogViewModel)DataContext!;
/// <summary>
/// Initializes a new instance of the NewStoreDialog.
/// </summary>
public NewStoreDialog()
{
InitializeComponent();
@@ -10,8 +10,14 @@ namespace JdeScoping.SecureStoreManager.Views;
public partial class OpenStoreDialog : Window
{
/// <summary>
/// Gets the view model for this dialog.
/// </summary>
public OpenStoreDialogViewModel ViewModel => (OpenStoreDialogViewModel)DataContext!;
/// <summary>
/// Initializes a new instance of the <see cref="OpenStoreDialog"/> class.
/// </summary>
public OpenStoreDialog()
{
InitializeComponent();
@@ -8,14 +8,25 @@ namespace JdeScoping.SecureStoreManager.Views;
public partial class SecretEditDialog : Window
{
/// <summary>
/// Gets the view model for this dialog.
/// </summary>
public SecretEditDialogViewModel ViewModel => (SecretEditDialogViewModel)DataContext!;
/// <summary>
/// Initializes a new instance of the <see cref="SecretEditDialog"/> class for creating a new secret.
/// </summary>
public SecretEditDialog()
{
InitializeComponent();
DataContext = new SecretEditDialogViewModel();
}
/// <summary>
/// Initializes a new instance of the <see cref="SecretEditDialog"/> class for editing an existing secret.
/// </summary>
/// <param name="key">The secret key.</param>
/// <param name="value">The secret value.</param>
public SecretEditDialog(string key, string value)
{
InitializeComponent();