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..fca07f29c8 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.
///
@@ -7317,7 +7380,16 @@ public static string NewTab {
return ResourceManager.GetString("NewTab", resourceCulture);
}
}
-
+
+ ///
+ /// Looks up a localized string similar to Next.
+ ///
+ public static string Next {
+ get {
+ return ResourceManager.GetString("Next", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to No.
///
@@ -7688,6 +7760,15 @@ public static string OnlyNumbersCanBeEntered {
}
}
+ ///
+ /// Looks up a localized string similar to Only CSV files are allowed!.
+ ///
+ public static string OnlyCsvFilesAllowed {
+ get {
+ return ResourceManager.GetString("OnlyCsvFilesAllowed", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Only when using the full screen.
///
@@ -7931,6 +8012,15 @@ public static string PasswordsDoNotMatch {
}
}
+ ///
+ /// Looks up a localized string similar to Parse.
+ ///
+ public static string Parse {
+ get {
+ return ResourceManager.GetString("Parse", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Paste.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 1492cf1be0..f3c9912eec 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -318,6 +318,9 @@
New tab
+
+ Next
+
OUI
@@ -1011,6 +1014,9 @@ Create one...
OID
+
+ Only CSV files are allowed!
+
Only when using the full screen
@@ -1384,6 +1390,9 @@ Profile files are not affected!
Overwrite
+
+ Parse
+
Paste
@@ -4211,6 +4220,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..8266339156
--- /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 on the first non-empty line.
+ ///
+ 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..4eb52230c1 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();
+
+ 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..b0e2dcb023
--- /dev/null
+++ b/Source/NETworkManager/ViewModels/ImportCsvFileViewModel.cs
@@ -0,0 +1,144 @@
+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());
+ ParseCommand = new RelayCommand(_ => ImportAction());
+ CancelCommand = new RelayCommand(_ => cancelDialog());
+ }
+
+ public string FilePath
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ 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 ParseCommand { 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)
+ {
+ if (!filePath.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
+ {
+ StatusMessage = Strings.OnlyCsvFilesAllowed;
+ IsStatusMessageDisplayed = true;
+ return;
+ }
+
+ IsStatusMessageDisplayed = false;
+ 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..19cd16e551 100644
--- a/Source/NETworkManager/ViewModels/ImportProfilesViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ImportProfilesViewModel.cs
@@ -14,12 +14,13 @@ 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];
- ImportCommand = new RelayCommand(_ => importCommand(this), _ => SelectedMethod != null);
+ OKCommand = new RelayCommand(_ => importCommand(this), _ => SelectedMethod != null);
CancelCommand = new RelayCommand(_ => cancelHandler(this));
}
@@ -38,7 +39,7 @@ public ImportMethodItem SelectedMethod
}
}
- public ICommand ImportCommand { get; }
+ public ICommand OKCommand { get; }
public ICommand CancelCommand { get; }
diff --git a/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml
new file mode 100644
index 0000000000..179bad7df7
--- /dev/null
+++ b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml.cs b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml.cs
new file mode 100644
index 0000000000..1dfe840291
--- /dev/null
+++ b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Windows;
+using System.Windows.Threading;
+using NETworkManager.ViewModels;
+
+namespace NETworkManager.Views;
+
+public partial class ImportCsvFileChildWindow
+{
+ public ImportCsvFileChildWindow()
+ {
+ InitializeComponent();
+ }
+
+ private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e)
+ {
+ Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate { TextBoxFilePath.Focus(); }));
+ }
+
+ ///
+ /// Set the file from drag and drop.
+ ///
+ private void TextBoxFilePath_Drop(object sender, DragEventArgs e)
+ {
+ if (!e.Data.GetDataPresent(DataFormats.FileDrop))
+ return;
+
+ var files = (string[])e.Data.GetData(DataFormats.FileDrop);
+
+ if (files != null && DataContext is ImportCsvFileViewModel viewModel)
+ viewModel.SetFilePathFromDragDrop(files[0]);
+ }
+
+ ///
+ /// Method to override the drag over effect.
+ ///
+ private void TextBoxFilePath_PreviewDragOver(object sender, DragEventArgs e)
+ {
+ e.Effects = DragDropEffects.Copy;
+ e.Handled = true;
+ }
+}
diff --git a/Source/NETworkManager/Views/ImportProfilesChildWindow.xaml b/Source/NETworkManager/Views/ImportProfilesChildWindow.xaml
index e1886303eb..30b9931d0e 100644
--- a/Source/NETworkManager/Views/ImportProfilesChildWindow.xaml
+++ b/Source/NETworkManager/Views/ImportProfilesChildWindow.xaml
@@ -46,7 +46,7 @@
-