Skip to content

Commit f656bc4

Browse files
committed
Feature: Profile Import CSV
1 parent 557ce66 commit f656bc4

14 files changed

Lines changed: 688 additions & 4 deletions

Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Source/NETworkManager.Localization/Resources/StaticStrings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@
285285
<data name="PrivateKeyFileLocationDots" xml:space="preserve">
286286
<value>C:\Data\Keys\private_ssh.ppk</value>
287287
</data>
288+
<data name="CsvImportFileLocationDots" xml:space="preserve">
289+
<value>C:\Data\profiles.csv</value>
290+
</data>
288291
<data name="ExamplePorts" xml:space="preserve">
289292
<value>22; 80; 443</value>
290293
</data>

Source/NETworkManager.Localization/Resources/Strings.Designer.cs

Lines changed: 64 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Source/NETworkManager.Localization/Resources/Strings.resx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4211,6 +4211,27 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis
42114211
<data name="ImportProfiles_Source_ActiveDirectory" xml:space="preserve">
42124212
<value>Active Directory</value>
42134213
</data>
4214+
<data name="ImportProfiles_Source_Csv" xml:space="preserve">
4215+
<value>CSV file</value>
4216+
</data>
4217+
<data name="ImportProfiles_Method_Csv" xml:space="preserve">
4218+
<value>CSV file</value>
4219+
</data>
4220+
<data name="ImportProfilesFromCsvFile" xml:space="preserve">
4221+
<value>Import profiles from CSV file</value>
4222+
</data>
4223+
<data name="Csv_ImportDescription" xml:space="preserve">
4224+
<value>Imported from CSV file on {0}</value>
4225+
</data>
4226+
<data name="CsvNoEntriesFound" xml:space="preserve">
4227+
<value>No entries were found in the CSV file.</value>
4228+
</data>
4229+
<data name="CsvImportFormatHint" xml:space="preserve">
4230+
<value>Expected CSV format — one profile per line:</value>
4231+
</data>
4232+
<data name="CsvImportFormatNote" xml:space="preserve">
4233+
<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>
4234+
</data>
42144235
<data name="ImportResults" xml:space="preserve">
42154236
<value>Import results</value>
42164237
</data>
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Security.Cryptography;
5+
using System.Text;
6+
7+
namespace NETworkManager.Profiles;
8+
9+
/// <summary>
10+
/// Parses a CSV file into <see cref="ProfileImportCandidate" />s.
11+
/// Expected format: <c>Name;Host</c> with an optional third <c>Description</c> column.
12+
/// The delimiter (<c>;</c>, <c>,</c> or tab) is auto-detected and an optional header row is skipped.
13+
/// </summary>
14+
public static class CsvProfileImportParser
15+
{
16+
private static readonly char[] SupportedDelimiters = [';', ',', '\t'];
17+
18+
/// <summary>
19+
/// Reads the given CSV file and returns one candidate per usable row.
20+
/// </summary>
21+
/// <param name="filePath">Path to the CSV file.</param>
22+
/// <param name="fallbackDescription">
23+
/// Description used when a row does not provide its own (third column). May be empty.
24+
/// </param>
25+
public static IReadOnlyList<ProfileImportCandidate> Parse(string filePath, string fallbackDescription = null)
26+
{
27+
var candidates = new List<ProfileImportCandidate>();
28+
29+
var lines = File.ReadAllLines(filePath);
30+
31+
var delimiter = DetectDelimiter(lines);
32+
var headerChecked = false;
33+
34+
foreach (var line in lines)
35+
{
36+
if (string.IsNullOrWhiteSpace(line))
37+
continue;
38+
39+
var fields = ParseLine(line, delimiter);
40+
41+
var name = fields.Count > 0 ? fields[0].Trim() : string.Empty;
42+
var host = fields.Count > 1 ? fields[1].Trim() : string.Empty;
43+
var description = fields.Count > 2 ? fields[2].Trim() : string.Empty;
44+
45+
// Skip an optional header row (only checked on the first non-empty line).
46+
if (!headerChecked)
47+
{
48+
headerChecked = true;
49+
50+
if (IsHeaderRow(name, host))
51+
continue;
52+
}
53+
54+
// Ignore completely empty rows.
55+
if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(host))
56+
continue;
57+
58+
// Fall back to the host as name so we never create a nameless profile.
59+
if (string.IsNullOrEmpty(name))
60+
name = host;
61+
62+
candidates.Add(new ProfileImportCandidate(
63+
name: name,
64+
host: host,
65+
description: !string.IsNullOrEmpty(description) ? description : fallbackDescription,
66+
importSource: ProfileImportSource.Csv,
67+
importSourceId: BuildImportSourceId(name, host)));
68+
}
69+
70+
return candidates;
71+
}
72+
73+
/// <summary>
74+
/// Builds a stable duplicate-detection key from the import source and a hash of name + host.
75+
/// </summary>
76+
private static string BuildImportSourceId(string name, string host)
77+
{
78+
var raw = $"Csv|{name.Trim().ToLowerInvariant()}|{host.Trim().ToLowerInvariant()}";
79+
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
80+
81+
return Convert.ToHexString(hash).ToLowerInvariant();
82+
}
83+
84+
/// <summary>
85+
/// Picks the delimiter that occurs most often across the first non-empty lines.
86+
/// </summary>
87+
private static char DetectDelimiter(IReadOnlyList<string> lines)
88+
{
89+
foreach (var line in lines)
90+
{
91+
if (string.IsNullOrWhiteSpace(line))
92+
continue;
93+
94+
var bestDelimiter = SupportedDelimiters[0];
95+
var bestCount = 0;
96+
97+
foreach (var delimiter in SupportedDelimiters)
98+
{
99+
var count = 0;
100+
101+
foreach (var c in line)
102+
if (c == delimiter)
103+
count++;
104+
105+
if (count <= bestCount)
106+
continue;
107+
108+
bestCount = count;
109+
bestDelimiter = delimiter;
110+
}
111+
112+
return bestDelimiter;
113+
}
114+
115+
return SupportedDelimiters[0];
116+
}
117+
118+
/// <summary>
119+
/// Detects whether the first row is a header (e.g. <c>Name;Host</c>).
120+
/// </summary>
121+
private static bool IsHeaderRow(string firstField, string secondField)
122+
{
123+
return string.Equals(firstField, "Name", StringComparison.OrdinalIgnoreCase) &&
124+
(string.Equals(secondField, "Host", StringComparison.OrdinalIgnoreCase) ||
125+
string.Equals(secondField, "Host/IP", StringComparison.OrdinalIgnoreCase) ||
126+
string.Equals(secondField, "Hostname", StringComparison.OrdinalIgnoreCase) ||
127+
string.Equals(secondField, "IP", StringComparison.OrdinalIgnoreCase));
128+
}
129+
130+
/// <summary>
131+
/// Splits a single CSV line by the given delimiter, honoring double-quoted fields.
132+
/// </summary>
133+
private static List<string> ParseLine(string line, char delimiter)
134+
{
135+
var fields = new List<string>();
136+
var builder = new StringBuilder();
137+
var inQuotes = false;
138+
139+
for (var i = 0; i < line.Length; i++)
140+
{
141+
var c = line[i];
142+
143+
if (inQuotes)
144+
{
145+
if (c == '"')
146+
{
147+
// Escaped quote ("") inside a quoted field.
148+
if (i + 1 < line.Length && line[i + 1] == '"')
149+
{
150+
builder.Append('"');
151+
i++;
152+
}
153+
else
154+
{
155+
inQuotes = false;
156+
}
157+
}
158+
else
159+
{
160+
builder.Append(c);
161+
}
162+
}
163+
else if (c == '"')
164+
{
165+
inQuotes = true;
166+
}
167+
else if (c == delimiter)
168+
{
169+
fields.Add(builder.ToString());
170+
builder.Clear();
171+
}
172+
else
173+
{
174+
builder.Append(c);
175+
}
176+
}
177+
178+
fields.Add(builder.ToString());
179+
180+
return fields;
181+
}
182+
}

Source/NETworkManager.Profiles/ProfileImportSource.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ namespace NETworkManager.Profiles;
33
public enum ProfileImportSource
44
{
55
None,
6-
ActiveDirectory
6+
ActiveDirectory,
7+
Csv
78
}

Source/NETworkManager.Settings/GlobalStaticConfiguration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public static class GlobalStaticConfiguration
5050
public static string PuTTYPrivateKeyFileExtensionFilter => "PuTTY Private Key Files (*.ppk)|*.ppk";
5151
public static string ZipFileExtensionFilter => "ZIP Archive (*.zip)|*.zip";
5252
public static string XmlFileExtensionFilter => "XML-File (*.xml)|*.xml";
53+
public static string CsvFileExtensionFilter => "CSV-File (*.csv)|*.csv";
5354

5455
#endregion
5556

Source/NETworkManager.Settings/SettingsInfo.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,19 @@ public string Profiles_ImportActiveDirectoryAdditionalFilter
657657
}
658658
}
659659

660+
public string Profiles_ImportCsvLastFilePath
661+
{
662+
get;
663+
set
664+
{
665+
if (value == field)
666+
return;
667+
668+
field = value;
669+
OnPropertyChanged();
670+
}
671+
}
672+
660673
// Settings
661674

662675
public bool Settings_IsDailyBackupEnabled

Source/NETworkManager/ProfileDialogManager.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,9 @@ void CloseChild()
587587
case ProfileImportSource.ActiveDirectory:
588588
_ = ShowSearchAdComputersDialog(parentWindow, viewModel, targetGroup, previousState: null);
589589
break;
590+
case ProfileImportSource.Csv:
591+
_ = ShowImportCsvFileDialog(parentWindow, viewModel, targetGroup, previousState: null);
592+
break;
590593
}
591594
}, _ => CloseChild());
592595

@@ -633,6 +636,39 @@ void CloseChild()
633636
return parentWindow.ShowChildWindowAsync(childWindow);
634637
}
635638

639+
private static Task ShowImportCsvFileDialog(Window parentWindow, IProfileManagerMinimal viewModel,
640+
string targetGroup, ImportCsvFileViewModel previousState)
641+
{
642+
var childWindow = new ImportCsvFileChildWindow(parentWindow);
643+
644+
void CloseChild()
645+
{
646+
childWindow.IsOpen = false;
647+
Settings.ConfigurationManager.Current.IsChildWindowOpen = false;
648+
649+
viewModel.OnProfileManagerDialogClose();
650+
}
651+
652+
var childWindowViewModel = new ImportCsvFileViewModel(
653+
(candidates, csvViewModel) =>
654+
{
655+
CloseChild();
656+
657+
_ = ShowImportProfilesResultDialog(parentWindow, viewModel, targetGroup, candidates,
658+
ProfileImportSource.Csv, Strings.ImportProfiles_Source_Csv,
659+
backToSourceCallback: () => _ = ShowImportCsvFileDialog(parentWindow, viewModel, targetGroup, csvViewModel));
660+
}, CloseChild, previousState);
661+
662+
childWindow.Title = Strings.ImportProfilesFromCsvFile;
663+
childWindow.DataContext = childWindowViewModel;
664+
665+
viewModel.OnProfileManagerDialogOpen();
666+
667+
Settings.ConfigurationManager.Current.IsChildWindowOpen = true;
668+
669+
return parentWindow.ShowChildWindowAsync(childWindow);
670+
}
671+
636672
private static Task ShowImportProfilesResultDialog(Window parentWindow, IProfileManagerMinimal viewModel,
637673
string targetGroup, IReadOnlyList<ProfileImportCandidate> candidates, ProfileImportSource importSource, string sourceLabel,
638674
Action backToSourceCallback)

0 commit comments

Comments
 (0)