Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@
<data name="PrivateKeyFileLocationDots" xml:space="preserve">
<value>C:\Data\Keys\private_ssh.ppk</value>
</data>
<data name="CsvImportFileLocationDots" xml:space="preserve">
<value>C:\Data\profiles.csv</value>
</data>
<data name="ExamplePorts" xml:space="preserve">
<value>22; 80; 443</value>
</data>
Expand Down
65 changes: 64 additions & 1 deletion Source/NETworkManager.Localization/Resources/Strings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions Source/NETworkManager.Localization/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -4211,6 +4211,27 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis
<data name="ImportProfiles_Source_ActiveDirectory" xml:space="preserve">
<value>Active Directory</value>
</data>
<data name="ImportProfiles_Source_Csv" xml:space="preserve">
<value>CSV file</value>
</data>
<data name="ImportProfiles_Method_Csv" xml:space="preserve">
<value>CSV file</value>
</data>
<data name="ImportProfilesFromCsvFile" xml:space="preserve">
<value>Import profiles from CSV file</value>
</data>
<data name="Csv_ImportDescription" xml:space="preserve">
<value>Imported from CSV file on {0}</value>
</data>
<data name="CsvNoEntriesFound" xml:space="preserve">
<value>No entries were found in the CSV file.</value>
</data>
<data name="CsvImportFormatHint" xml:space="preserve">
<value>Expected CSV format — one profile per line:</value>
</data>
<data name="CsvImportFormatNote" xml:space="preserve">
<value>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.</value>
</data>
<data name="ImportResults" xml:space="preserve">
<value>Import results</value>
</data>
Expand Down
182 changes: 182 additions & 0 deletions Source/NETworkManager.Profiles/CsvProfileImportParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace NETworkManager.Profiles;

/// <summary>
/// Parses a CSV file into <see cref="ProfileImportCandidate" />s.
/// Expected format: <c>Name;Host</c> with an optional third <c>Description</c> column.
/// The delimiter (<c>;</c>, <c>,</c> or tab) is auto-detected and an optional header row is skipped.
/// </summary>
public static class CsvProfileImportParser
{
private static readonly char[] SupportedDelimiters = [';', ',', '\t'];

/// <summary>
/// Reads the given CSV file and returns one candidate per usable row.
/// </summary>
/// <param name="filePath">Path to the CSV file.</param>
/// <param name="fallbackDescription">
/// Description used when a row does not provide its own (third column). May be empty.
/// </param>
public static IReadOnlyList<ProfileImportCandidate> Parse(string filePath, string fallbackDescription = null)
{
var candidates = new List<ProfileImportCandidate>();

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;
}

/// <summary>
/// Builds a stable duplicate-detection key from the import source and a hash of name + host.
/// </summary>
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();
}

/// <summary>
/// Picks the delimiter that occurs most often across the first non-empty lines.
/// </summary>
private static char DetectDelimiter(IReadOnlyList<string> 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];
}

/// <summary>
/// Detects whether the first row is a header (e.g. <c>Name;Host</c>).
/// </summary>
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));
}

/// <summary>
/// Splits a single CSV line by the given delimiter, honoring double-quoted fields.
/// </summary>
private static List<string> ParseLine(string line, char delimiter)
{
var fields = new List<string>();
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;
}
}
3 changes: 2 additions & 1 deletion Source/NETworkManager.Profiles/ProfileImportSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ namespace NETworkManager.Profiles;
public enum ProfileImportSource
{
None,
ActiveDirectory
ActiveDirectory,
Csv
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions Source/NETworkManager.Settings/SettingsInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions Source/NETworkManager/ProfileDialogManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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<ProfileImportCandidate> candidates, ProfileImportSource importSource, string sourceLabel,
Action backToSourceCallback)
Expand Down
Loading
Loading