diff --git a/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs b/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs index b50bca1305..08636974d6 100644 --- a/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs @@ -752,7 +752,16 @@ public static string PrivateKeyFileLocationDots { return ResourceManager.GetString("PrivateKeyFileLocationDots", resourceCulture); } } - + + /// + /// Looks up a localized string similar to C:\Data\profiles.csv. + /// + public static string CsvImportFileLocationDots { + get { + return ResourceManager.GetString("CsvImportFileLocationDots", resourceCulture); + } + } + /// /// Looks up a localized string similar to NETworkManager. /// diff --git a/Source/NETworkManager.Localization/Resources/StaticStrings.resx b/Source/NETworkManager.Localization/Resources/StaticStrings.resx index 271c5f19d1..e9ca7ea40d 100644 --- a/Source/NETworkManager.Localization/Resources/StaticStrings.resx +++ b/Source/NETworkManager.Localization/Resources/StaticStrings.resx @@ -285,6 +285,9 @@ C:\Data\Keys\private_ssh.ppk + + C:\Data\profiles.csv + 22; 80; 443 diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 2154fa8875..a693c28075 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -5588,7 +5588,70 @@ public static string ImportProfiles_Source_ActiveDirectory { return ResourceManager.GetString("ImportProfiles_Source_ActiveDirectory", resourceCulture); } } - + + /// + /// Looks up a localized string similar to CSV file. + /// + public static string ImportProfiles_Source_Csv { + get { + return ResourceManager.GetString("ImportProfiles_Source_Csv", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to CSV file. + /// + public static string ImportProfiles_Method_Csv { + get { + return ResourceManager.GetString("ImportProfiles_Method_Csv", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Import profiles from CSV file. + /// + public static string ImportProfilesFromCsvFile { + get { + return ResourceManager.GetString("ImportProfilesFromCsvFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Imported from CSV file on {0}. + /// + public static string Csv_ImportDescription { + get { + return ResourceManager.GetString("Csv_ImportDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No entries were found in the CSV file.. + /// + public static string CsvNoEntriesFound { + get { + return ResourceManager.GetString("CsvNoEntriesFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Expected CSV format — one profile per line:. + /// + public static string CsvImportFormatHint { + get { + return ResourceManager.GetString("CsvImportFormatHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A header row and the delimiter (semicolon, comma or tab) are detected automatically. The description column is optional. Entries without a host cannot be imported.. + /// + public static string CsvImportFormatNote { + get { + return ResourceManager.GetString("CsvImportFormatNote", resourceCulture); + } + } + /// /// Looks up a localized string similar to Imported. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 1492cf1be0..68fff988d9 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4211,6 +4211,27 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis Active Directory + + CSV file + + + CSV file + + + Import profiles from CSV file + + + Imported from CSV file on {0} + + + No entries were found in the CSV file. + + + Expected CSV format — one profile per line: + + + A header row and the delimiter (semicolon, comma or tab) are detected automatically. The description column is optional. Entries without a host cannot be imported. + Import results diff --git a/Source/NETworkManager.Profiles/CsvProfileImportParser.cs b/Source/NETworkManager.Profiles/CsvProfileImportParser.cs new file mode 100644 index 0000000000..8c8dcc74f1 --- /dev/null +++ b/Source/NETworkManager.Profiles/CsvProfileImportParser.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace NETworkManager.Profiles; + +/// +/// Parses a CSV file into s. +/// Expected format: Name;Host with an optional third Description column. +/// The delimiter (;, , or tab) is auto-detected and an optional header row is skipped. +/// +public static class CsvProfileImportParser +{ + private static readonly char[] SupportedDelimiters = [';', ',', '\t']; + + /// + /// Reads the given CSV file and returns one candidate per usable row. + /// + /// Path to the CSV file. + /// + /// Description used when a row does not provide its own (third column). May be empty. + /// + public static IReadOnlyList Parse(string filePath, string fallbackDescription = null) + { + var candidates = new List(); + + var lines = File.ReadAllLines(filePath); + + var delimiter = DetectDelimiter(lines); + var headerChecked = false; + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + var fields = ParseLine(line, delimiter); + + var name = fields.Count > 0 ? fields[0].Trim() : string.Empty; + var host = fields.Count > 1 ? fields[1].Trim() : string.Empty; + var description = fields.Count > 2 ? fields[2].Trim() : string.Empty; + + // Skip an optional header row (only checked on the first non-empty line). + if (!headerChecked) + { + headerChecked = true; + + if (IsHeaderRow(name, host)) + continue; + } + + // Ignore completely empty rows. + if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(host)) + continue; + + // Fall back to the host as name so we never create a nameless profile. + if (string.IsNullOrEmpty(name)) + name = host; + + candidates.Add(new ProfileImportCandidate( + name: name, + host: host, + description: !string.IsNullOrEmpty(description) ? description : fallbackDescription, + importSource: ProfileImportSource.Csv, + importSourceId: BuildImportSourceId(name, host))); + } + + return candidates; + } + + /// + /// Builds a stable duplicate-detection key from the import source and a hash of name + host. + /// + private static string BuildImportSourceId(string name, string host) + { + var raw = $"Csv|{name.Trim().ToLowerInvariant()}|{host.Trim().ToLowerInvariant()}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + /// + /// Picks the delimiter that occurs most often across the first non-empty lines. + /// + private static char DetectDelimiter(IReadOnlyList lines) + { + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + var bestDelimiter = SupportedDelimiters[0]; + var bestCount = 0; + + foreach (var delimiter in SupportedDelimiters) + { + var count = 0; + + foreach (var c in line) + if (c == delimiter) + count++; + + if (count <= bestCount) + continue; + + bestCount = count; + bestDelimiter = delimiter; + } + + return bestDelimiter; + } + + return SupportedDelimiters[0]; + } + + /// + /// Detects whether the first row is a header (e.g. Name;Host). + /// + private static bool IsHeaderRow(string firstField, string secondField) + { + return string.Equals(firstField, "Name", StringComparison.OrdinalIgnoreCase) && + (string.Equals(secondField, "Host", StringComparison.OrdinalIgnoreCase) || + string.Equals(secondField, "Host/IP", StringComparison.OrdinalIgnoreCase) || + string.Equals(secondField, "Hostname", StringComparison.OrdinalIgnoreCase) || + string.Equals(secondField, "IP", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Splits a single CSV line by the given delimiter, honoring double-quoted fields. + /// + private static List ParseLine(string line, char delimiter) + { + var fields = new List(); + var builder = new StringBuilder(); + var inQuotes = false; + + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + + if (inQuotes) + { + if (c == '"') + { + // Escaped quote ("") inside a quoted field. + if (i + 1 < line.Length && line[i + 1] == '"') + { + builder.Append('"'); + i++; + } + else + { + inQuotes = false; + } + } + else + { + builder.Append(c); + } + } + else if (c == '"') + { + inQuotes = true; + } + else if (c == delimiter) + { + fields.Add(builder.ToString()); + builder.Clear(); + } + else + { + builder.Append(c); + } + } + + fields.Add(builder.ToString()); + + return fields; + } +} diff --git a/Source/NETworkManager.Profiles/ProfileImportSource.cs b/Source/NETworkManager.Profiles/ProfileImportSource.cs index 731c195728..970b890577 100644 --- a/Source/NETworkManager.Profiles/ProfileImportSource.cs +++ b/Source/NETworkManager.Profiles/ProfileImportSource.cs @@ -3,5 +3,6 @@ namespace NETworkManager.Profiles; public enum ProfileImportSource { None, - ActiveDirectory + ActiveDirectory, + Csv } \ No newline at end of file diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 2d4c40b93f..ab2566676c 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -50,6 +50,7 @@ public static class GlobalStaticConfiguration public static string PuTTYPrivateKeyFileExtensionFilter => "PuTTY Private Key Files (*.ppk)|*.ppk"; public static string ZipFileExtensionFilter => "ZIP Archive (*.zip)|*.zip"; public static string XmlFileExtensionFilter => "XML-File (*.xml)|*.xml"; + public static string CsvFileExtensionFilter => "CSV-File (*.csv)|*.csv"; #endregion diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index dac5457ea4..862a9c542b 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -657,6 +657,19 @@ public string Profiles_ImportActiveDirectoryAdditionalFilter } } + public string Profiles_ImportCsvLastFilePath + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + // Settings public bool Settings_IsDailyBackupEnabled diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs index f8ed376705..2934902abf 100644 --- a/Source/NETworkManager/ProfileDialogManager.cs +++ b/Source/NETworkManager/ProfileDialogManager.cs @@ -587,6 +587,9 @@ void CloseChild() case ProfileImportSource.ActiveDirectory: _ = ShowSearchAdComputersDialog(parentWindow, viewModel, targetGroup, previousState: null); break; + case ProfileImportSource.Csv: + _ = ShowImportCsvFileDialog(parentWindow, viewModel, targetGroup, previousState: null); + break; } }, _ => CloseChild()); @@ -633,6 +636,39 @@ void CloseChild() return parentWindow.ShowChildWindowAsync(childWindow); } + private static Task ShowImportCsvFileDialog(Window parentWindow, IProfileManagerMinimal viewModel, + string targetGroup, ImportCsvFileViewModel previousState) + { + var childWindow = new ImportCsvFileChildWindow(parentWindow); + + void CloseChild() + { + childWindow.IsOpen = false; + Settings.ConfigurationManager.Current.IsChildWindowOpen = false; + + viewModel.OnProfileManagerDialogClose(); + } + + var childWindowViewModel = new ImportCsvFileViewModel( + (candidates, csvViewModel) => + { + CloseChild(); + + _ = ShowImportProfilesResultDialog(parentWindow, viewModel, targetGroup, candidates, + ProfileImportSource.Csv, Strings.ImportProfiles_Source_Csv, + backToSourceCallback: () => _ = ShowImportCsvFileDialog(parentWindow, viewModel, targetGroup, csvViewModel)); + }, CloseChild, previousState); + + childWindow.Title = Strings.ImportProfilesFromCsvFile; + childWindow.DataContext = childWindowViewModel; + + viewModel.OnProfileManagerDialogOpen(); + + Settings.ConfigurationManager.Current.IsChildWindowOpen = true; + + return parentWindow.ShowChildWindowAsync(childWindow); + } + private static Task ShowImportProfilesResultDialog(Window parentWindow, IProfileManagerMinimal viewModel, string targetGroup, IReadOnlyList candidates, ProfileImportSource importSource, string sourceLabel, Action backToSourceCallback) diff --git a/Source/NETworkManager/ViewModels/ImportCsvFileViewModel.cs b/Source/NETworkManager/ViewModels/ImportCsvFileViewModel.cs new file mode 100644 index 0000000000..60dab14c72 --- /dev/null +++ b/Source/NETworkManager/ViewModels/ImportCsvFileViewModel.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows.Forms; +using System.Windows.Input; +using log4net; +using NETworkManager.Localization.Resources; +using NETworkManager.Profiles; +using NETworkManager.Settings; +using NETworkManager.Utilities; + +namespace NETworkManager.ViewModels; + +public sealed class ImportCsvFileViewModel : ViewModelBase +{ + private static readonly ILog Log = LogManager.GetLogger(typeof(ImportCsvFileViewModel)); + + private readonly Action, ImportCsvFileViewModel> _parseCompleted; + + public ImportCsvFileViewModel(Action, ImportCsvFileViewModel> parseCompleted, + Action cancelDialog, ImportCsvFileViewModel previousState = null) + { + _parseCompleted = parseCompleted; + + FilePath = previousState != null + ? previousState.FilePath + : SettingsManager.Current.Profiles_ImportCsvLastFilePath ?? string.Empty; + + BrowseFileCommand = new RelayCommand(_ => BrowseFileAction()); + ImportCommand = new RelayCommand(_ => ImportAction()); + CancelCommand = new RelayCommand(_ => cancelDialog()); + } + + public string FilePath + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + public bool IsStatusMessageDisplayed + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public string StatusMessage + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public ICommand BrowseFileCommand { get; } + + public ICommand ImportCommand { get; } + + public ICommand CancelCommand { get; } + + private void BrowseFileAction() + { + var openFileDialog = new OpenFileDialog + { + Filter = GlobalStaticConfiguration.CsvFileExtensionFilter + }; + + if (openFileDialog.ShowDialog() == DialogResult.OK) + FilePath = openFileDialog.FileName; + } + + /// + /// Set the from drag and drop. + /// + /// Path to the file. + public void SetFilePathFromDragDrop(string filePath) + { + FilePath = filePath; + + OnPropertyChanged(nameof(FilePath)); + } + + private void ImportAction() + { + IsStatusMessageDisplayed = false; + + IReadOnlyList candidates; + + try + { + var importedAt = DateTime.Now.ToString("g", CultureInfo.CurrentUICulture); + var fallbackDescription = string.Format(Strings.Csv_ImportDescription, importedAt); + + candidates = CsvProfileImportParser.Parse(FilePath.Trim(), fallbackDescription); + } + catch (Exception exception) + { + Log.Error("CSV import failed.", exception); + + StatusMessage = exception.Message; + IsStatusMessageDisplayed = true; + + return; + } + + if (candidates.Count == 0) + { + StatusMessage = Strings.CsvNoEntriesFound; + IsStatusMessageDisplayed = true; + + return; + } + + SettingsManager.Current.Profiles_ImportCsvLastFilePath = FilePath.Trim(); + + _parseCompleted(candidates, this); + } +} diff --git a/Source/NETworkManager/ViewModels/ImportProfilesViewModel.cs b/Source/NETworkManager/ViewModels/ImportProfilesViewModel.cs index 5ca15535d3..4a5b016bc5 100644 --- a/Source/NETworkManager/ViewModels/ImportProfilesViewModel.cs +++ b/Source/NETworkManager/ViewModels/ImportProfilesViewModel.cs @@ -14,7 +14,8 @@ public ImportProfilesViewModel(Action importCommand, { Methods = new List { - new(ProfileImportSource.ActiveDirectory, Strings.ImportProfiles_Method_ActiveDirectory) + new(ProfileImportSource.ActiveDirectory, Strings.ImportProfiles_Method_ActiveDirectory), + new(ProfileImportSource.Csv, Strings.ImportProfiles_Method_Csv) }; SelectedMethod = Methods[0]; diff --git a/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml new file mode 100644 index 0000000000..ddfd60a206 --- /dev/null +++ b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +