diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index c3775cd7eb..d1962439c4 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -13689,5 +13689,31 @@ public static string RDAPReferralPrivateAddressX { } } + /// + /// Looks up a localized string similar to Edit comment. + /// + public static string EditComment { + get { + return ResourceManager.GetString("EditComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Edit comment.... + /// + public static string EditCommentDots { + get { + return ResourceManager.GetString("EditCommentDots", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The selected {0} entries will be permanently deleted.. + /// + public static string DeleteHostsFileEntriesMessage { + get { + return ResourceManager.GetString("DeleteHostsFileEntriesMessage", resourceCulture); + } + } } } diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index d53d5d3906..8dc9159342 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4703,4 +4703,13 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri This domain cannot be queried via RDAP. Try Whois instead. + + Edit comment + + + Edit comment... + + + The selected {0} entries will be permanently deleted. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs b/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs index 2a211c638c..c94359ea45 100644 --- a/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs +++ b/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs @@ -1,4 +1,4 @@ -using log4net; +using log4net; using NETworkManager.Utilities; using System; using System.Collections.Generic; @@ -164,6 +164,16 @@ private static IEnumerable GetHostsFileEntries() return entries; } + /// + /// Enable multiple hosts file entries asynchronously. + /// + /// Entries to enable. + /// if entries were enabled successfully, otherwise an error result. + public static Task EnableEntriesAsync(IEnumerable entries) + { + return Task.Run(() => ModifyEntries(entries, e => new HostsFileEntry(true, e.IPAddress, e.Hostname, e.Comment))); + } + /// /// Enable a hosts file entry asynchronously. /// @@ -171,107 +181,56 @@ private static IEnumerable GetHostsFileEntries() /// if the entry was enabled successfully, otherwise an error result. public static Task EnableEntryAsync(HostsFileEntry entry) { - return Task.Run(() => EnableEntry(entry)); + return EnableEntriesAsync([entry]); } /// - /// Enable a hosts file entry. + /// Disable multiple hosts file entries asynchronously. /// - /// Entry to enable. - /// if the entry was enabled successfully, otherwise an error result. - private static HostsFileEntryModifyResult EnableEntry(HostsFileEntry entry) + /// Entries to disable. + /// if entries were disabled successfully, otherwise an error result. + public static Task DisableEntriesAsync(IEnumerable entries) { - // Create a backup of the hosts file before making changes - if (CreateBackup() == false) - { - Log.Error("EnableEntry - Failed to create backup before enabling entry."); - return HostsFileEntryModifyResult.BackupError; - } - - // Enable the entry in the hosts file - List hostsFileLines; - - try - { - hostsFileLines = [.. File.ReadAllLines(HostsFilePath)]; - } - catch (Exception ex) - { - Log.Error($"EnableEntry - Failed to read hosts file: {HostsFilePath}", ex); - return HostsFileEntryModifyResult.ReadError; - } - - var entryFound = false; - - for (var i = 0; i < hostsFileLines.Count; i++) - { - if (hostsFileLines[i] != entry.Line) - continue; - - entryFound = true; - - Log.Debug($"EnableEntry - Found entry: {hostsFileLines[i]}"); - hostsFileLines.RemoveAt(i); - - var newEntry = new HostsFileEntry( - true, - entry.IPAddress, - entry.Hostname, - entry.Comment - ); - - Log.Debug($"EnableEntry - Enabling entry: {newEntry.Line}"); - hostsFileLines.Insert(i, newEntry.Line); - - break; - } - - if (!entryFound) - { - Log.Warn($"EnableEntry - Entry not found in hosts file: {entry}"); - - return HostsFileEntryModifyResult.NotFound; - } - - try - { - Log.Debug($"EnableEntry - Writing changes to hosts file: {HostsFilePath}"); - File.WriteAllLines(HostsFilePath, hostsFileLines); - } - catch (Exception ex) - { - Log.Error($"EnableEntry - Failed to write changes to hosts file: {HostsFilePath}", ex); - return HostsFileEntryModifyResult.WriteError; - } - - return HostsFileEntryModifyResult.Success; + return Task.Run(() => ModifyEntries(entries, e => new HostsFileEntry(false, e.IPAddress, e.Hostname, e.Comment))); } /// /// Disable a hosts file entry asynchronously. /// /// Entry to disable. - /// if the entry was enabled successfully, otherwise an error result. + /// if the entry was disabled successfully, otherwise an error result. public static Task DisableEntryAsync(HostsFileEntry entry) { - return Task.Run(() => DisableEntry(entry)); + return DisableEntriesAsync([entry]); } /// - /// Disable a hosts file entry. + /// Edit the comment for multiple hosts file entries asynchronously. /// - /// Entry to disable. - /// if the entry was enabled successfully, otherwise an error result. - private static HostsFileEntryModifyResult DisableEntry(HostsFileEntry entry) + /// Entries to update. + /// The new comment to apply. + /// if entries were edited successfully, otherwise an error result. + public static Task EditCommentAsync(IEnumerable entries, string comment) { + return Task.Run(() => ModifyEntries(entries, e => new HostsFileEntry(e.IsEnabled, e.IPAddress, e.Hostname, comment))); + } + + /// + /// Modifies multiple hosts file entries using a transform function. + /// + private static HostsFileEntryModifyResult ModifyEntries(IEnumerable entries, Func transform) + { + var entriesList = entries?.ToList() ?? []; + if (entriesList.Count == 0) + return HostsFileEntryModifyResult.Success; + // Create a backup of the hosts file before making changes if (CreateBackup() == false) { - Log.Error("DisableEntry - Failed to create backup before disabling entry."); + Log.Error("ModifyEntries - Failed to create backup before modifying entries."); return HostsFileEntryModifyResult.BackupError; } - // Disable the entry in the hosts file List hostsFileLines; try @@ -280,49 +239,42 @@ private static HostsFileEntryModifyResult DisableEntry(HostsFileEntry entry) } catch (Exception ex) { - Log.Error($"DisableEntry - Failed to read hosts file: {HostsFilePath}", ex); + Log.Error($"ModifyEntries - Failed to read hosts file: {HostsFilePath}", ex); return HostsFileEntryModifyResult.ReadError; } - var entryFound = false; + var anyModified = false; + var pending = entriesList.ToList(); for (var i = 0; i < hostsFileLines.Count; i++) { - if (hostsFileLines[i] != entry.Line) - continue; - - entryFound = true; - - Log.Debug($"DisableEntry - Found entry: {hostsFileLines[i]}"); - hostsFileLines.RemoveAt(i); - - var newEntry = new HostsFileEntry( - false, - entry.IPAddress, - entry.Hostname, - entry.Comment - ); - - Log.Debug($"DisableEntry - Disabling entry: {newEntry.Line}"); - hostsFileLines.Insert(i, newEntry.Line); + var matchIndex = pending.FindIndex(e => e.Line == hostsFileLines[i]); + if (matchIndex >= 0) + { + var matched = pending[matchIndex]; + pending.RemoveAt(matchIndex); - break; + var newEntry = transform(matched); + Log.Debug($"ModifyEntries - Replacing entry: {hostsFileLines[i]} with {newEntry.Line}"); + hostsFileLines[i] = newEntry.Line; + anyModified = true; + } } - if (!entryFound) + if (!anyModified && entriesList.Count > 0) { - Log.Warn($"DisableEntry - Entry not found in hosts file: {entry.Line}"); + Log.Warn("ModifyEntries - None of the specified entries were found in hosts file."); return HostsFileEntryModifyResult.NotFound; } try { - Log.Debug($"DisableEntry - Writing changes to hosts file: {HostsFilePath}"); + Log.Debug($"ModifyEntries - Writing changes to hosts file: {HostsFilePath}"); File.WriteAllLines(HostsFilePath, hostsFileLines); } catch (Exception ex) { - Log.Error($"DisableEntry - Failed to write changes to hosts file: {HostsFilePath}", ex); + Log.Error($"ModifyEntries - Failed to write changes to hosts file: {HostsFilePath}", ex); return HostsFileEntryModifyResult.WriteError; } @@ -460,31 +412,43 @@ private static HostsFileEntryModifyResult EditEntry(HostsFileEntry entry, HostsF return HostsFileEntryModifyResult.Success; } + /// + /// Delete multiple hosts file entries asynchronously. + /// + /// Entries to delete. + /// if entries were deleted successfully, otherwise an error result. + public static Task DeleteEntriesAsync(IEnumerable entries) + { + return Task.Run(() => DeleteEntries(entries)); + } + /// /// Delete a hosts file entry asynchronously. /// - /// Entry to delete."/> - /// if the entry was enabled successfully, otherwise an error result.s + /// Entry to delete. + /// if the entry was deleted successfully, otherwise an error result. public static Task DeleteEntryAsync(HostsFileEntry entry) { - return Task.Run(() => DeleteEntry(entry)); + return DeleteEntriesAsync([entry]); } /// - /// Delete a hosts file entry. + /// Delete multiple hosts file entries. /// - /// Entry to delete."/> - /// if the entry was enabled successfully, otherwise an error result. - private static HostsFileEntryModifyResult DeleteEntry(HostsFileEntry entry) + private static HostsFileEntryModifyResult DeleteEntries(IEnumerable entries) { + var entriesList = entries?.ToList() ?? []; + if (entriesList.Count == 0) + return HostsFileEntryModifyResult.Success; + // Create a backup of the hosts file before making changes if (CreateBackup() == false) { - Log.Error("DeleteEntry - Failed to create backup before deleting entry."); + Log.Error("DeleteEntries - Failed to create backup before deleting entries."); return HostsFileEntryModifyResult.BackupError; } - // Remove the entry from the hosts file + // Remove the entries from the hosts file List hostsFileLines; try @@ -493,39 +457,39 @@ private static HostsFileEntryModifyResult DeleteEntry(HostsFileEntry entry) } catch (Exception ex) { - Log.Error($"DeleteEntry - Failed to read hosts file: {HostsFilePath}", ex); + Log.Error($"DeleteEntries - Failed to read hosts file: {HostsFilePath}", ex); return HostsFileEntryModifyResult.ReadError; } - var entryFound = false; + var anyDeleted = false; + var pending = entriesList.ToList(); - for (var i = 0; i < hostsFileLines.Count; i++) + for (var i = hostsFileLines.Count - 1; i >= 0; i--) { - if (hostsFileLines[i] != entry.Line) - continue; - - entryFound = true; - - Log.Debug($"DeleteEntry - Found entry: {hostsFileLines[i]}"); - hostsFileLines.RemoveAt(i); - - break; + var matchIndex = pending.FindIndex(e => e.Line == hostsFileLines[i]); + if (matchIndex >= 0) + { + pending.RemoveAt(matchIndex); + Log.Debug($"DeleteEntries - Found entry: {hostsFileLines[i]}"); + hostsFileLines.RemoveAt(i); + anyDeleted = true; + } } - if (!entryFound) + if (!anyDeleted && entriesList.Count > 0) { - Log.Warn($"DeleteEntry - Entry not found in hosts file: {entry.Line}"); + Log.Warn("DeleteEntries - None of the specified entries were found in hosts file."); return HostsFileEntryModifyResult.NotFound; } try { - Log.Debug($"DeleteEntry - Writing changes to hosts file: {HostsFilePath}"); + Log.Debug($"DeleteEntries - Writing changes to hosts file: {HostsFilePath}"); File.WriteAllLines(HostsFilePath, hostsFileLines); } catch (Exception ex) { - Log.Error($"DeleteEntry - Failed to write changes to hosts file: {HostsFilePath}", ex); + Log.Error($"DeleteEntries - Failed to write changes to hosts file: {HostsFilePath}", ex); return HostsFileEntryModifyResult.WriteError; } diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorCommentViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorCommentViewModel.cs new file mode 100644 index 0000000000..271f8cc57c --- /dev/null +++ b/Source/NETworkManager/ViewModels/HostsFileEditorCommentViewModel.cs @@ -0,0 +1,51 @@ +using NETworkManager.Utilities; +using System; +using System.Windows.Input; + +namespace NETworkManager.ViewModels; + +/// +/// ViewModel for editing the comment of one or more hosts file entries. +/// +public class HostsFileEditorCommentViewModel : ViewModelBase +{ + /// + /// Initializes a new instance of the class. + /// + /// Action executed on confirmation. + /// Action executed on cancellation. + /// Initial comment value. + public HostsFileEditorCommentViewModel(Action okCommand, + Action cancelHandler, string initialComment = "") + { + OKCommand = new RelayCommand(_ => okCommand(this)); + CancelCommand = new RelayCommand(_ => cancelHandler(this)); + Comment = initialComment; + } + + /// + /// OK command to confirm the comment change. + /// + public ICommand OKCommand { get; } + + /// + /// Cancel command to discard changes. + /// + public ICommand CancelCommand { get; } + + /// + /// Gets or sets the comment. + /// + public string Comment + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } +} diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs index 0b69b39d89..d1de22441e 100644 --- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs +++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs @@ -1,4 +1,4 @@ -using log4net; +using log4net; using MahApps.Metro.Controls; using MahApps.Metro.SimpleChildWindow; using NETworkManager.Localization.Resources; @@ -9,6 +9,7 @@ using NETworkManager.Views; using System; using System.Collections; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; @@ -299,6 +300,20 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } + /// + /// Helper to get the target entries for operations (either multi-selection or single selected entry). + /// + private List GetTargetEntries() + { + if (SelectedResults != null && SelectedResults.Count > 1) + return [.. SelectedResults.Cast()]; + + if (SelectedResult != null) + return [SelectedResult]; + + return []; + } + /// /// Gets the command to enable the selected entry. /// @@ -306,13 +321,17 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro new RelayCommand(parameter => { _ = EnableEntryAction(); }, ModifyEntry_CanExecute); /// - /// Action to enable the selected entry. + /// Action to enable the selected entry or entries. /// private async Task EnableEntryAction() { + var entries = GetTargetEntries(); + if (entries.Count == 0) + return; + IsModifying = true; - var result = await HostsFileEditor.EnableEntryAsync(SelectedResult); + var result = await HostsFileEditor.EnableEntriesAsync(entries); if (result != HostsFileEntryModifyResult.Success) await ShowErrorMessageAsync(result); @@ -327,13 +346,17 @@ private async Task EnableEntryAction() new RelayCommand(parameter => { _ = DisableEntryAction(); }, ModifyEntry_CanExecute); /// - /// Action to disable the selected entry. + /// Action to disable the selected entry or entries. /// private async Task DisableEntryAction() { + var entries = GetTargetEntries(); + if (entries.Count == 0) + return; + IsModifying = true; - var result = await HostsFileEditor.DisableEntryAsync(SelectedResult); + var result = await HostsFileEditor.DisableEntriesAsync(entries); if (result != HostsFileEntryModifyResult.Success) await ShowErrorMessageAsync(result); @@ -391,6 +414,56 @@ private async Task AddEntryAction() await Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } + /// + /// Gets the command to edit comments of the selected entry or entries. + /// + public ICommand EditCommentCommand => + new RelayCommand(parameter => { _ = EditCommentAction(); }, ModifyEntry_CanExecute); + + /// + /// Action to edit comments of the selected entry or entries. + /// + private async Task EditCommentAction() + { + var entries = GetTargetEntries(); + if (entries.Count == 0) + return; + + IsModifying = true; + + var initialComment = entries.Select(e => e.Comment ?? string.Empty).Distinct().Count() == 1 + ? entries[0].Comment ?? string.Empty + : string.Empty; + + var childWindow = new HostsFileEditorCommentChildWindow(); + + var childWindowViewModel = new HostsFileEditorCommentViewModel(async instance => + { + childWindow.IsOpen = false; + ConfigurationManager.Current.IsChildWindowOpen = false; + + var result = await HostsFileEditor.EditCommentAsync(entries, instance.Comment); + + if (result != HostsFileEntryModifyResult.Success) + await ShowErrorMessageAsync(result); + + IsModifying = false; + }, _ => + { + childWindow.IsOpen = false; + ConfigurationManager.Current.IsChildWindowOpen = false; + + IsModifying = false; + }, initialComment); + + childWindow.Title = Strings.EditComment; + childWindow.DataContext = childWindowViewModel; + + ConfigurationManager.Current.IsChildWindowOpen = true; + + await Application.Current.MainWindow.ShowChildWindowAsync(childWindow); + } + /// /// Gets the command to edit the selected entry. /// @@ -402,6 +475,15 @@ private async Task AddEntryAction() /// private async Task EditEntryAction() { + if (SelectedResults != null && SelectedResults.Count > 1) + { + await EditCommentAction(); + return; + } + + if (SelectedResult == null) + return; + IsModifying = true; var childWindow = new HostsFileEditorEntryChildWindow(); @@ -448,19 +530,38 @@ private async Task EditEntryAction() new RelayCommand(parameter => { _ = DeleteEntryAction(); }, ModifyEntry_CanExecute); /// - /// Action to delete the selected entry. + /// Action to delete the selected entry or entries. /// private async Task DeleteEntryAction() { + var entries = GetTargetEntries(); + if (entries.Count == 0) + return; + IsModifying = true; - var result = await DialogHelper.ShowConfirmationMessageAsync( - Application.Current.MainWindow, - Strings.DeleteEntry, - string.Format(Strings.DeleteHostsFileEntryMessage, SelectedResult.IPAddress, SelectedResult.Hostname, - string.IsNullOrEmpty(SelectedResult.Comment) ? "" : $"# {SelectedResult.Comment}"), - ChildWindowIcon.Info, - Strings.Delete); + bool result; + + if (entries.Count > 1) + { + result = await DialogHelper.ShowConfirmationMessageAsync( + Application.Current.MainWindow, + Strings.Delete, + string.Format(Strings.DeleteHostsFileEntriesMessage, entries.Count), + ChildWindowIcon.Info, + Strings.Delete); + } + else + { + var single = entries[0]; + result = await DialogHelper.ShowConfirmationMessageAsync( + Application.Current.MainWindow, + Strings.DeleteEntry, + string.Format(Strings.DeleteHostsFileEntryMessage, single.IPAddress, single.Hostname, + string.IsNullOrEmpty(single.Comment) ? "" : $"# {single.Comment}"), + ChildWindowIcon.Info, + Strings.Delete); + } if (!result) { @@ -468,8 +569,7 @@ private async Task DeleteEntryAction() return; } - - var modifyResult = await HostsFileEditor.DeleteEntryAsync(SelectedResult); + var modifyResult = await HostsFileEditor.DeleteEntriesAsync(entries); if (modifyResult != HostsFileEntryModifyResult.Success) await ShowErrorMessageAsync(modifyResult); diff --git a/Source/NETworkManager/Views/HostsFileEditorCommentChildWindow.xaml b/Source/NETworkManager/Views/HostsFileEditorCommentChildWindow.xaml new file mode 100644 index 0000000000..0d431b6161 --- /dev/null +++ b/Source/NETworkManager/Views/HostsFileEditorCommentChildWindow.xaml @@ -0,0 +1,49 @@ + + + + + + + + + +