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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml.cs b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml.cs
new file mode 100644
index 0000000000..b9e0aa3eb7
--- /dev/null
+++ b/Source/NETworkManager/Views/ImportCsvFileChildWindow.xaml.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Windows;
+using System.Windows.Threading;
+using NETworkManager.ViewModels;
+
+namespace NETworkManager.Views;
+
+public partial class ImportCsvFileChildWindow
+{
+ public ImportCsvFileChildWindow(Window parentWindow)
+ {
+ InitializeComponent();
+
+ // Set the width and height of the child window based on the parent window size
+ ChildWindowMaxWidth = 850;
+ ChildWindowMaxHeight = 650;
+ ChildWindowWidth = parentWindow.ActualWidth * 0.85;
+
+ // Update the size of the child window when the parent window is resized
+ parentWindow.SizeChanged += (_, _) => { ChildWindowWidth = parentWindow.ActualWidth * 0.85; };
+ }
+
+ 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/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index dcb28741fd..29aede1745 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -63,6 +63,7 @@ Release date: **xx.xx.2025**
**Profiles**
- Profiles can now be imported from **Active Directory**. Search for computers by name using an AD query, select the results, assign a group, and apply connection settings (RDP, SSH, etc.) before importing. [#3368](https://github.com/BornToBeRoot/NETworkManager/pull/3368)
+- Profiles can now be imported from a **CSV file**. Select a `.csv` file (drag & drop or browse) with `Name;Host` entries and an optional description column, then select the results, assign a group, and apply connection settings (RDP, SSH, etc.) before importing. The delimiter (semicolon, comma or tab) and an optional header row are detected automatically, and re-importing the same file detects already imported entries. [#3502](https://github.com/BornToBeRoot/NETworkManager/pull/3502)
**Ping Monitor**
diff --git a/Website/docs/groups-and-profiles.md b/Website/docs/groups-and-profiles.md
index 971575384b..69f4c8772b 100644
--- a/Website/docs/groups-and-profiles.md
+++ b/Website/docs/groups-and-profiles.md
@@ -116,6 +116,7 @@ Profiles can be imported from an external source. To start the import, click **I
The following import sources are available:
- [Active Directory](#active-directory)
+- [CSV file](#csv-file)
### Active Directory
@@ -137,6 +138,31 @@ Profiles can be imported from Active Directory by querying computer accounts via
Once the computers are found, proceed to [Review and import](#review-and-import) to select entries and configure import options.
+### CSV file
+
+Profiles can be imported from a CSV file. After selecting **CSV file** as the import source, choose the file (drag & drop it onto the path field or click the browse button) and click **Import**.
+
+| Field | Description |
+| ------------- | ---------------------------------------------------------------- |
+| **File path** | Path to the `.csv` file to import. Drag & drop or browse for it. |
+
+The expected format is one entry per line as `Name;Host`, with an optional third column for the description:
+
+```csv
+Name;Host;Description
+Webserver;192.168.1.10;Production web server
+DC01;dc01.corp.local
+Router;10.0.0.1;Core router
+```
+
+- A header row (e.g. `Name;Host`) is optional and detected automatically.
+- The delimiter (semicolon `;`, comma `,` or tab) is detected automatically.
+- The **Description** column is optional. If omitted, a default description with the import date is used.
+- Entries without a host are listed but cannot be imported.
+- Re-importing the same file detects already imported entries (see the **Status** column in [Review and import](#review-and-import)), based on a hash of the name and host.
+
+Once the file is parsed, proceed to [Review and import](#review-and-import) to select entries and configure import options.
+
### Review and import
Select the entries to import and configure the options before clicking **Import**.