diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index fbd0f19ec1..b57b12bff7 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -2220,6 +2220,15 @@ public static string Country { } } + /// + /// Sucht eine lokalisierte Zeichenfolge, die Create daily backup ähnelt. + /// + public static string CreateDailyBackup { + get { + return ResourceManager.GetString("CreateDailyBackup", resourceCulture); + } + } + /// /// Sucht eine lokalisierte Zeichenfolge, die Credential ähnelt. /// @@ -4450,6 +4459,15 @@ public static string HelpMessage_ExperimentalFeatures { } } + /// + /// Sucht eine lokalisierte Zeichenfolge, die Number of backups that are retained before the oldest one is deleted. ähnelt. + /// + public static string HelpMessage_MaximumNumberOfBackups { + get { + return ResourceManager.GetString("HelpMessage_MaximumNumberOfBackups", resourceCulture); + } + } + /// /// Sucht eine lokalisierte Zeichenfolge, die Application that is displayed at startup. ähnelt. /// @@ -5783,6 +5801,15 @@ public static string MaximumHops { } } + /// + /// Sucht eine lokalisierte Zeichenfolge, die Maximum number of backups ähnelt. + /// + public static string MaximumNumberOfBackups { + get { + return ResourceManager.GetString("MaximumNumberOfBackups", resourceCulture); + } + } + /// /// Sucht eine lokalisierte Zeichenfolge, die Maximum number ({0}) of hops/router reached! ähnelt. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 28ccf1c004..3dd4967eaf 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -3951,4 +3951,13 @@ If you click Cancel, the profile file will remain unencrypted. Could not parse "{0}". + + Maximum number of backups + + + Create daily backup + + + Number of backups that are retained before the oldest one is deleted. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/NetworkInterface.cs b/Source/NETworkManager.Models/Network/NetworkInterface.cs index d7662f6704..7cd8c34038 100644 --- a/Source/NETworkManager.Models/Network/NetworkInterface.cs +++ b/Source/NETworkManager.Models/Network/NetworkInterface.cs @@ -527,7 +527,6 @@ private static void RemoveIPAddressFromNetworkInterface(NetworkInterfaceConfig c #endregion - #region Events /// diff --git a/Source/NETworkManager.Profiles/GroupInfo.cs b/Source/NETworkManager.Profiles/GroupInfo.cs index 4b39c78043..2598a48d05 100644 --- a/Source/NETworkManager.Profiles/GroupInfo.cs +++ b/Source/NETworkManager.Profiles/GroupInfo.cs @@ -311,4 +311,4 @@ public GroupInfo(GroupInfo group) : this(group.Name) [XmlIgnore] public SecureString SNMP_Auth { get; set; } public SNMPV3PrivacyProvider SNMP_PrivacyProvider { get; set; } = GlobalStaticConfiguration.SNMP_PrivacyProvider; [XmlIgnore] public SecureString SNMP_Priv { get; set; } -} \ No newline at end of file +} diff --git a/Source/NETworkManager.Profiles/GroupInfoSerializable.cs b/Source/NETworkManager.Profiles/GroupInfoSerializable.cs index 2ef946500c..01e9a03aa1 100644 --- a/Source/NETworkManager.Profiles/GroupInfoSerializable.cs +++ b/Source/NETworkManager.Profiles/GroupInfoSerializable.cs @@ -10,6 +10,7 @@ public GroupInfoSerializable() public GroupInfoSerializable(GroupInfo profileGroup) : base(profileGroup) { + } /// diff --git a/Source/NETworkManager.Profiles/ProfileFileData.cs b/Source/NETworkManager.Profiles/ProfileFileData.cs new file mode 100644 index 0000000000..db2093e901 --- /dev/null +++ b/Source/NETworkManager.Profiles/ProfileFileData.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace NETworkManager.Profiles; + +/// +/// Represents the data structure for a profile file, including versioning, backup information, and groups of profiles. +/// +/// This class supports property change notification through the +/// interface, allowing consumers to track changes to its properties. The property +/// indicates whether the data has been modified since it was last saved, but is not persisted when serializing the +/// object. Use this class to manage and persist user profile data, including handling schema migrations via the property. +public class ProfileFileData : INotifyPropertyChanged +{ + /// + /// Occurs when a property value changes. + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Helper method to raise the event and mark data as changed. + /// + /// Name of the property that changed. + private void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + ProfilesChanged = true; + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + /// + /// Indicates if the profile file data has been modified and needs to be saved. + /// This is not serialized to the file. + /// + [JsonIgnore] + public bool ProfilesChanged { get; set; } + + private int _version = 1; + + /// + /// Schema version for handling future migrations. + /// + public int Version + { + get => _version; + set + { + if (value == _version) + return; + + _version = value; + OnPropertyChanged(); + } + } + + private DateTime? _lastBackup; + + /// + /// Date of the last backup (used for daily backup tracking). + /// + public DateTime? LastBackup + { + get => _lastBackup; + set + { + if (value == _lastBackup) + return; + + _lastBackup = value; + OnPropertyChanged(); + } + } + + /// + /// List of groups containing profiles. + /// + public List Groups { get; set; } = []; +} diff --git a/Source/NETworkManager.Profiles/ProfileManager.cs b/Source/NETworkManager.Profiles/ProfileManager.cs index e7cdb73390..e540c02369 100644 --- a/Source/NETworkManager.Profiles/ProfileManager.cs +++ b/Source/NETworkManager.Profiles/ProfileManager.cs @@ -25,14 +25,14 @@ public static class ProfileManager private const string ProfilesFolderName = "Profiles"; /// - /// Default profile name. + /// Profiles backups directory name. /// - private const string ProfilesDefaultFileName = "Default"; + private static string BackupFolderName => "Backups"; /// - /// Profiles backups directory name. + /// Default profile name. /// - private static string BackupFolderName => "Backups"; + private const string ProfilesDefaultFileName = "Default"; /// /// Profile file extension. @@ -92,14 +92,31 @@ private set } /// - /// Currently loaded groups with profiles. + /// Currently loaded profile file data (wrapper containing groups and metadata). + /// This is updated during load/save operations. /// - public static List Groups { get; set; } = []; + private static ProfileFileData _loadedProfileFileData = new(); /// - /// Indicates if profiles have changed. + /// Currently loaded profile file data (wrapper containing groups and metadata). + /// This is updated during load/save operations. /// - public static bool ProfilesChanged { get; set; } + public static ProfileFileData LoadedProfileFileData + { + get => _loadedProfileFileData; + private set + { + if (Equals(value, _loadedProfileFileData)) + return; + + _loadedProfileFileData = value; + } + } + + /// + /// Currently loaded groups with profiles (working copy in memory). + /// + public static List Groups { get; set; } = []; #endregion @@ -172,9 +189,9 @@ private static void ProfileMigrationCompleted() /// /// Method to fire the . /// - private static void ProfilesUpdated() + private static void ProfilesUpdated(bool profilesChanged = true) { - ProfilesChanged = true; + LoadedProfileFileData?.ProfilesChanged = profilesChanged; OnProfilesUpdated?.Invoke(null, EventArgs.Empty); } @@ -290,6 +307,7 @@ public static void CreateEmptyProfileFile(string profileName) /// New of the profile file. public static void RenameProfileFile(ProfileFileInfo profileFileInfo, string newProfileName) { + // Check if the profile is currently in use var switchProfile = false; if (LoadedProfileFile != null && LoadedProfileFile.Equals(profileFileInfo)) @@ -298,6 +316,12 @@ public static void RenameProfileFile(ProfileFileInfo profileFileInfo, string new switchProfile = true; } + // Create backup + Backup(profileFileInfo.Path, + GetProfilesBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path))); + + // Create new profile info with the new name ProfileFileInfo newProfileFileInfo = new(newProfileName, Path.Combine(GetProfilesFolderLocation(), $"{newProfileName}{Path.GetExtension(profileFileInfo.Path)}"), profileFileInfo.IsEncrypted) @@ -306,15 +330,18 @@ public static void RenameProfileFile(ProfileFileInfo profileFileInfo, string new IsPasswordValid = profileFileInfo.IsPasswordValid }; + // Copy the profile file to the new location File.Copy(profileFileInfo.Path, newProfileFileInfo.Path); ProfileFiles.Add(newProfileFileInfo); + // Switch profile, if it was previously loaded if (switchProfile) { Switch(newProfileFileInfo, false); LoadedProfileFileChanged(LoadedProfileFile, true); } + // Remove the old profile file File.Delete(profileFileInfo.Path); ProfileFiles.Remove(profileFileInfo); } @@ -353,6 +380,11 @@ public static void EnableEncryption(ProfileFileInfo profileFileInfo, SecureStrin switchProfile = true; } + // Create backup + Backup(profileFileInfo.Path, + GetProfilesBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path))); + // Create a new profile info with the encryption infos var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtensionEncrypted), true) @@ -361,9 +393,9 @@ public static void EnableEncryption(ProfileFileInfo profileFileInfo, SecureStrin IsPasswordValid = true }; - List profiles = Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension - ? DeserializeFromXmlFile(profileFileInfo.Path) - : DeserializeFromFile(profileFileInfo.Path); + List profiles = Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension ? + DeserializeFromXmlFile(profileFileInfo.Path) : + DeserializeFromFile(profileFileInfo.Path); // Save the encrypted file var decryptedBytes = SerializeToByteArray(profiles); @@ -409,7 +441,12 @@ public static void ChangeMasterPassword(ProfileFileInfo profileFileInfo, SecureS switchProfile = true; } - // Create a new profile info with the encryption infos + // Create backup + Backup(profileFileInfo.Path, + GetProfilesBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path))); + + // Create new profile info with the encryption infos var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtensionEncrypted), true) { @@ -423,11 +460,9 @@ public static void ChangeMasterPassword(ProfileFileInfo profileFileInfo, SecureS GlobalStaticConfiguration.Profile_EncryptionKeySize, GlobalStaticConfiguration.Profile_EncryptionIterations); - List profiles; - - profiles = IsXmlContent(decryptedBytes) - ? DeserializeFromXmlByteArray(decryptedBytes) - : DeserializeFromByteArray(decryptedBytes); + List profiles = IsXmlContent(decryptedBytes) ? + DeserializeFromXmlByteArray(decryptedBytes) : + DeserializeFromByteArray(decryptedBytes); // Save the encrypted file decryptedBytes = SerializeToByteArray(profiles); @@ -468,7 +503,12 @@ public static void DisableEncryption(ProfileFileInfo profileFileInfo, SecureStri switchProfile = true; } - // Create a new profile info + // Create backup + Backup(profileFileInfo.Path, + GetProfilesBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path))); + + // Create new profile info var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtension)); @@ -478,9 +518,10 @@ public static void DisableEncryption(ProfileFileInfo profileFileInfo, SecureStri GlobalStaticConfiguration.Profile_EncryptionKeySize, GlobalStaticConfiguration.Profile_EncryptionIterations); - List profiles = IsXmlContent(decryptedBytes) - ? DeserializeFromXmlByteArray(decryptedBytes) - : DeserializeFromByteArray(decryptedBytes); + List profiles = IsXmlContent(decryptedBytes) ? + DeserializeFromXmlByteArray(decryptedBytes) : + DeserializeFromByteArray(decryptedBytes); + // Save the decrypted profiles to the profile file SerializeToFile(newProfileFileInfo.Path, profiles); @@ -558,7 +599,7 @@ private static void Load(ProfileFileInfo profileFileInfo) groups = DeserializeFromByteArray(decryptedBytes); } - AddGroups(groups); + AddGroups(groups, false); // Password is valid ProfileFiles.FirstOrDefault(x => x.Equals(profileFileInfo))!.IsPasswordValid = true; @@ -580,8 +621,6 @@ private static void Load(ProfileFileInfo profileFileInfo) // Load from legacy XML file groups = DeserializeFromXmlFile(profileFileInfo.Path); - ProfilesChanged = false; - LoadedProfileFile = profileFileInfo; // Create a backup of the legacy XML file and delete the original @@ -622,7 +661,7 @@ private static void Load(ProfileFileInfo profileFileInfo) groups = DeserializeFromFile(profileFileInfo.Path); } - AddGroups(groups); + AddGroups(groups, false); } } else @@ -632,8 +671,6 @@ private static void Load(ProfileFileInfo profileFileInfo) throw new FileNotFoundException($"{profileFileInfo.Path} could not be found!"); } - ProfilesChanged = false; - LoadedProfileFile = profileFileInfo; if (loadedProfileUpdated) @@ -647,7 +684,7 @@ public static void Save() { if (LoadedProfileFile == null) { - Log.Warn("Cannot save profiles because no profile file is loaded. The profile file may be encrypted and not yet unlocked."); + Log.Warn("Cannot save profiles because no profile file is loaded or the profile file is encrypted and not yet unlocked."); return; } @@ -655,6 +692,9 @@ public static void Save() // Ensure the profiles directory exists. Directory.CreateDirectory(GetProfilesFolderLocation()); + // Create backup before modifying + CreateDailyBackupIfNeeded(); + // Write profiles to the profile file (JSON, optionally encrypted). if (LoadedProfileFile.IsEncrypted) { @@ -675,7 +715,7 @@ public static void Save() SerializeToFile(LoadedProfileFile.Path, [.. Groups]); } - ProfilesChanged = false; + LoadedProfileFileData?.ProfilesChanged = false; } /// @@ -684,10 +724,11 @@ public static void Save() /// Save loaded profile file (default is true) public static void Unload(bool saveLoadedProfiles = true) { - if (saveLoadedProfiles && LoadedProfileFile != null && ProfilesChanged) + if (saveLoadedProfiles && LoadedProfileFile != null && LoadedProfileFileData?.ProfilesChanged == true) Save(); LoadedProfileFile = null; + LoadedProfileFileData = null; Groups.Clear(); @@ -717,7 +758,13 @@ public static void Switch(ProfileFileInfo info, bool saveLoadedProfiles = true) /// List of the groups as to serialize. private static void SerializeToFile(string filePath, List groups) { - var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions); + // Ensure LoadedProfileFileData exists + LoadedProfileFileData ??= new ProfileFileData(); + + // Update LoadedProfileFileData with current groups + LoadedProfileFileData.Groups = SerializeGroup(groups); + + var jsonString = JsonSerializer.Serialize(LoadedProfileFileData, JsonOptions); File.WriteAllText(filePath, jsonString); } @@ -729,7 +776,13 @@ private static void SerializeToFile(string filePath, List groups) /// Serialized list of groups as as byte array. private static byte[] SerializeToByteArray(List groups) { - var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions); + // Ensure LoadedProfileFileData exists + LoadedProfileFileData ??= new ProfileFileData(); + + // Update LoadedProfileFileData with current groups + LoadedProfileFileData.Groups = SerializeGroup(groups); + + var jsonString = JsonSerializer.Serialize(LoadedProfileFileData, JsonOptions); return Encoding.UTF8.GetBytes(jsonString); } @@ -849,11 +902,36 @@ private static List DeserializeFromXmlByteArray(byte[] xml) /// List of groups as . private static List DeserializeFromJson(string jsonString) { + try + { + var profileFileData = JsonSerializer.Deserialize(jsonString, JsonOptions); + + if (profileFileData?.Groups != null) + { + LoadedProfileFileData = profileFileData; + + return DeserializeGroup(profileFileData.Groups); + } + } + catch (JsonException) + { + Log.Info("Failed to deserialize as ProfileFileData, trying legacy format (direct Groups array)..."); + } + + // Fallback: Try to deserialize as legacy format (direct array of GroupInfoSerializable) var groupsSerializable = JsonSerializer.Deserialize>(jsonString, JsonOptions); if (groupsSerializable == null) throw new InvalidOperationException("Failed to deserialize JSON profile file."); + // Create ProfileFileData wrapper for legacy format + LoadedProfileFileData = new ProfileFileData + { + Groups = groupsSerializable + }; + + Log.Info("Successfully loaded profile file in legacy format. It will be migrated to new format on next save."); + return DeserializeGroup(groupsSerializable); } @@ -973,23 +1051,24 @@ private static List DeserializeGroup(List grou /// Method to add a list of to the list. /// /// List of groups as to add. - private static void AddGroups(List groups) + private static void AddGroups(List groups, bool profilesChanged = true) { foreach (var group in groups) Groups.Add(group); - ProfilesUpdated(); + ProfilesUpdated(profilesChanged); } /// /// Method to add a to the list. /// /// Group as to add. - public static void AddGroup(GroupInfo group) + public static void AddGroup(GroupInfo group, bool profilesChanged = true) + { Groups.Add(group); - ProfilesUpdated(); + ProfilesUpdated(profilesChanged); } /// @@ -1119,6 +1198,97 @@ public static void RemoveProfiles(IEnumerable profiles) #region Backup + /// + /// Creates a backup of the currently loaded profile file if a backup has not already been created for the current day. + /// + private static void CreateDailyBackupIfNeeded() + { + // Skip if daily backups are disabled + if (!SettingsManager.Current.Profiles_IsDailyBackupEnabled) + { + Log.Info("Daily profile backups are disabled. Skipping backup creation..."); + return; + } + + // Skip if no profile is loaded + if (LoadedProfileFile == null || LoadedProfileFileData == null) + { + Log.Info("No profile file is currently loaded. Skipping backup creation..."); + return; + } + + // Skip if the profile file doesn't exist yet + if (!File.Exists(LoadedProfileFile.Path)) + { + Log.Warn($"Profile file does not exist yet: {LoadedProfileFile.Path}. Skipping backup creation..."); + return; + } + + // Create backup if needed + var currentDate = DateTime.Now.Date; + var lastBackupDate = LoadedProfileFileData.LastBackup?.Date ?? DateTime.MinValue; + var profileFileName = Path.GetFileName(LoadedProfileFile.Path); + + if (lastBackupDate < currentDate) + { + Log.Info($"Creating daily backup for profile: {profileFileName}"); + + // Create backup + Backup(LoadedProfileFile.Path, + GetProfilesBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(profileFileName)); + + // Cleanup old backups + CleanupBackups(GetProfilesBackupFolderLocation(), + profileFileName, + SettingsManager.Current.Profiles_MaximumNumberOfBackups); + + LoadedProfileFileData.LastBackup = currentDate; + } + } + + /// + /// Deletes older backup files in the specified folder to ensure that only the most recent backups, up to the + /// specified maximum, are retained. + /// + /// The full path to the directory containing the backup files to be managed. + /// The profile file name pattern used to identify backup files for cleanup. + /// The maximum number of backup files to retain. Must be greater than zero. + private static void CleanupBackups(string backupFolderPath, string profileFileName, int maxBackupFiles) + { + // Extract profile name without extension to match all backup files regardless of extension + // (e.g., "Default" matches "2025-01-19_Default.json", "2025-01-19_Default.encrypted", etc.) + var profileNameWithoutExtension = Path.GetFileNameWithoutExtension(profileFileName); + + // Get all backup files for this specific profile (any extension) sorted by timestamp (newest first) + var backupFiles = Directory.GetFiles(backupFolderPath) + .Where(f => + { + var fileName = Path.GetFileName(f); + + // Check if it's a timestamped backup and contains the profile name + return TimestampHelper.IsTimestampedFilename(fileName) && + fileName.Contains($"_{profileNameWithoutExtension}."); + }) + .OrderByDescending(f => TimestampHelper.ExtractTimestampFromFilename(Path.GetFileName(f))) + .ToList(); + + if (backupFiles.Count > maxBackupFiles) + Log.Info($"Cleaning up old backup files for {profileNameWithoutExtension}... Found {backupFiles.Count} backups, keeping the most recent {maxBackupFiles}."); + + // Delete oldest backups until the maximum number is reached + while (backupFiles.Count > maxBackupFiles) + { + var fileToDelete = backupFiles.Last(); + + File.Delete(fileToDelete); + + backupFiles.RemoveAt(backupFiles.Count - 1); + + Log.Info($"Backup deleted: {fileToDelete}"); + } + } + /// /// Creates a backup of the specified profile file in the given backup folder with the provided backup file name. /// diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index af7ea9117d..851a5c233c 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -46,9 +46,6 @@ public static class GlobalStaticConfiguration public static string ZipFileExtensionFilter => "ZIP Archive (*.zip)|*.zip"; public static string XmlFileExtensionFilter => "XML-File (*.xml)|*.xml"; - // Backup settings - public static int Backup_MaximumNumberOfBackups => 10; - #endregion #region Default settings @@ -74,18 +71,23 @@ public static class GlobalStaticConfiguration public static bool Status_ShowWindowOnNetworkChange => true; public static int Status_WindowCloseTime => 10; - // HotKey + // Settings: HotKey public static int HotKey_ShowWindowKey => 79; public static int HotKey_ShowWindowModifier => 3; - // Update + // Settings: Update public static bool Update_CheckForUpdatesAtStartup => true; - public static bool Update_CheckForPreReleases => false; - - // Experimental public static bool Experimental_EnableExperimentalFeatures => false; + // Settings: Profiles + public static bool Profiles_IsDailyBackupEnabled => true; + public static int Profiles_MaximumNumberOfBackups => 10; + + // Settings: Settings + public static bool Settings_IsDailyBackupEnabled => true; + public static int Settings_MaximumNumberOfBackups => 10; + // Application: Dashboard public static string Dashboard_PublicIPv4Address => "1.1.1.1"; public static string Dashboard_PublicIPv6Address => "2606:4700:4700::1111"; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 4a41e94680..2d96124243 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -51,7 +51,7 @@ private void OnPropertyChanged([CallerMemberName] string propertyName = null) /// /// Determines if the welcome dialog should be shown on application start. - /// + /// S public bool WelcomeDialog_Show { get => _welcomeDialog_Show; @@ -582,6 +582,67 @@ public string Profiles_LastSelected } } + private bool _profiles_IsDailyBackupEnabled = GlobalStaticConfiguration.Profiles_IsDailyBackupEnabled; + + public bool Profiles_IsDailyBackupEnabled + { + get => _profiles_IsDailyBackupEnabled; + set + { + if (value == _profiles_IsDailyBackupEnabled) + return; + + _profiles_IsDailyBackupEnabled = value; + OnPropertyChanged(); + } + } + + private int _profiles_MaximumNumberOfBackups = GlobalStaticConfiguration.Profiles_MaximumNumberOfBackups; + + public int Profiles_MaximumNumberOfBackups + { + get => _profiles_MaximumNumberOfBackups; + set + { + if (value == _profiles_MaximumNumberOfBackups) + return; + + _profiles_MaximumNumberOfBackups = value; + OnPropertyChanged(); + } + } + + // Settings + private bool _settings_IsDailyBackupEnabled = GlobalStaticConfiguration.Settings_IsDailyBackupEnabled; + + public bool Settings_IsDailyBackupEnabled + { + get => _settings_IsDailyBackupEnabled; + set + { + if (value == _settings_IsDailyBackupEnabled) + return; + + _settings_IsDailyBackupEnabled = value; + OnPropertyChanged(); + } + } + + private int _settings_MaximumNumberOfBackups = GlobalStaticConfiguration.Settings_MaximumNumberOfBackups; + + public int Settings_MaximumNumberOfBackups + { + get => _settings_MaximumNumberOfBackups; + set + { + if (value == _settings_MaximumNumberOfBackups) + return; + + _settings_MaximumNumberOfBackups = value; + OnPropertyChanged(); + } + } + #endregion #region Others diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 7c0041f063..34ff00a850 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -228,7 +228,7 @@ private static SettingsInfo DeserializeFromXmlFile(string filePath) /// Method to save the currently loaded settings (to a file). /// public static void Save() - { + { // Create the directory if it does not exist Directory.CreateDirectory(GetSettingsFolderLocation()); @@ -265,16 +265,27 @@ private static void SerializeToFile(string filePath) /// called as part of a daily maintenance routine. private static void CreateDailyBackupIfNeeded() { - var currentDate = DateTime.Now.Date; + // Skip if daily backups are disabled + if (!Current.Settings_IsDailyBackupEnabled) + { + Log.Info("Daily backups are disabled. Skipping backup creation..."); + return; + } - if (Current.LastBackup < currentDate) + // Skip if settings file doesn't exist yet + if (!File.Exists(GetSettingsFilePath())) { - // Check if settings file exists - if (!File.Exists(GetSettingsFilePath())) - { - Log.Warn("Settings file does not exist yet. Skipping backup creation..."); - return; - } + Log.Warn("Settings file does not exist yet. Skipping backup creation..."); + return; + } + + // Create backup if needed + var currentDate = DateTime.Now.Date; + var lastBackupDate = Current.LastBackup.Date; + + if (lastBackupDate < currentDate) + { + Log.Info("Creating daily backup of settings..."); // Create backup Backup(GetSettingsFilePath(), @@ -284,7 +295,7 @@ private static void CreateDailyBackupIfNeeded() // Cleanup old backups CleanupBackups(GetSettingsBackupFolderLocation(), GetSettingsFileName(), - GlobalStaticConfiguration.Backup_MaximumNumberOfBackups); + Current.Settings_MaximumNumberOfBackups); Current.LastBackup = currentDate; } @@ -302,14 +313,25 @@ private static void CreateDailyBackupIfNeeded() /// The maximum number of backup files to retain. Must be greater than zero. private static void CleanupBackups(string backupFolderPath, string settingsFileName, int maxBackupFiles) { - // Get all backup files sorted by timestamp (newest first) + // Extract settings name without extension to match all backup files regardless of extension + // (e.g., "Settings" matches "2025-01-19_Settings.json", "2025-01-19_Settings.xml") + var settingsNameWithoutExtension = Path.GetFileNameWithoutExtension(settingsFileName); + + // Get all backup files for settings (any extension) sorted by timestamp (newest first) var backupFiles = Directory.GetFiles(backupFolderPath) - .Where(f => (f.EndsWith(settingsFileName) || f.EndsWith(GetLegacySettingsFileName())) && TimestampHelper.IsTimestampedFilename(Path.GetFileName(f))) + .Where(f => + { + var fileName = Path.GetFileName(f); + + // Check if it's a timestamped backup and contains the settings name + return TimestampHelper.IsTimestampedFilename(fileName) && + fileName.Contains($"_{settingsNameWithoutExtension}."); + }) .OrderByDescending(f => TimestampHelper.ExtractTimestampFromFilename(Path.GetFileName(f))) .ToList(); if (backupFiles.Count > maxBackupFiles) - Log.Info($"Cleaning up old backup files... Found {backupFiles.Count} backups, keeping the most recent {maxBackupFiles}."); + Log.Info($"Cleaning up old backup files for {settingsNameWithoutExtension}... Found {backupFiles.Count} backups, keeping the most recent {maxBackupFiles}."); // Delete oldest backups until the maximum number is reached while (backupFiles.Count > maxBackupFiles) @@ -357,6 +379,11 @@ public static void Upgrade(Version fromVersion, Version toVersion) { Log.Info($"Start settings upgrade from {fromVersion} to {toVersion}..."); + // Create backup + Backup(GetSettingsFilePath(), + GetSettingsBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(GetSettingsFileName())); + // 2023.3.7.0 if (fromVersion < new Version(2023, 3, 7, 0)) UpgradeTo_2023_3_7_0(); diff --git a/Source/NETworkManager.Utilities/TimestampHelper.cs b/Source/NETworkManager.Utilities/TimestampHelper.cs index 0ed4b5b83b..6ee5e83c09 100644 --- a/Source/NETworkManager.Utilities/TimestampHelper.cs +++ b/Source/NETworkManager.Utilities/TimestampHelper.cs @@ -35,7 +35,7 @@ public static bool IsTimestampedFilename(string fileName) if (fileName.Length < 16) return false; - var timestampString = fileName.Substring(0, 14); + var timestampString = fileName[..14]; return DateTime.TryParseExact(timestampString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); } diff --git a/Source/NETworkManager/App.xaml.cs b/Source/NETworkManager/App.xaml.cs index b3e4cd3f1f..ede9bc5e36 100644 --- a/Source/NETworkManager/App.xaml.cs +++ b/Source/NETworkManager/App.xaml.cs @@ -271,16 +271,25 @@ private void Application_Exit(object sender, ExitEventArgs e) private void Save() { + // Save settings if they have changed if (SettingsManager.Current.SettingsChanged) { Log.Info("Save application settings..."); SettingsManager.Save(); } - if (ProfileManager.ProfilesChanged) + // Save profiles if they have changed + if (ProfileManager.LoadedProfileFile != null && ProfileManager.LoadedProfileFileData != null) { - Log.Info("Save current profiles..."); - ProfileManager.Save(); + if (ProfileManager.LoadedProfileFileData.ProfilesChanged) + { + Log.Info($"Save current profile file \"{ProfileManager.LoadedProfileFile.Name}\"..."); + ProfileManager.Save(); + } + } + else + { + Log.Warn("Cannot save profiles because no profile file is loaded or the profile file is encrypted and not yet unlocked."); } } } diff --git a/Source/NETworkManager/ViewModels/SettingsProfilesViewModel.cs b/Source/NETworkManager/ViewModels/SettingsProfilesViewModel.cs index fd42c76821..7dd0e17546 100644 --- a/Source/NETworkManager/ViewModels/SettingsProfilesViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsProfilesViewModel.cs @@ -22,6 +22,8 @@ public class SettingsProfilesViewModel : ViewModelBase public Action CloseAction { get; set; } + private readonly bool _isLoading; + private string _location; public string Location @@ -67,12 +69,49 @@ public ProfileFileInfo SelectedProfileFile } } + private bool _isDailyBackupEnabled; + + public bool IsDailyBackupEnabled + { + get => _isDailyBackupEnabled; + set + { + if (value == _isDailyBackupEnabled) + return; + + if (!_isLoading) + SettingsManager.Current.Profiles_IsDailyBackupEnabled = value; + + _isDailyBackupEnabled = value; + OnPropertyChanged(); + } + } + + private int _maximumNumberOfBackups; + + public int MaximumNumberOfBackups + { + get => _maximumNumberOfBackups; + set + { + if (value == _maximumNumberOfBackups) + return; + + if (!_isLoading) + SettingsManager.Current.Profiles_MaximumNumberOfBackups = value; + + _maximumNumberOfBackups = value; + OnPropertyChanged(); + } + } #endregion #region Constructor, LoadSettings public SettingsProfilesViewModel() { + _isLoading = true; + ProfileFiles = new CollectionViewSource { Source = ProfileManager.ProfileFiles }.View; ProfileFiles.SortDescriptions.Add( new SortDescription(nameof(ProfileFileInfo.Name), ListSortDirection.Ascending)); @@ -80,11 +119,15 @@ public SettingsProfilesViewModel() SelectedProfileFile = ProfileFiles.Cast().FirstOrDefault(); LoadSettings(); + + _isLoading = false; } private void LoadSettings() { Location = ProfileManager.GetProfilesFolderLocation(); + IsDailyBackupEnabled = SettingsManager.Current.Profiles_IsDailyBackupEnabled; + MaximumNumberOfBackups = SettingsManager.Current.Profiles_MaximumNumberOfBackups; } #endregion diff --git a/Source/NETworkManager/ViewModels/SettingsSettingsViewModel.cs b/Source/NETworkManager/ViewModels/SettingsSettingsViewModel.cs index 399ffb1e02..dc1d7d95b1 100644 --- a/Source/NETworkManager/ViewModels/SettingsSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsSettingsViewModel.cs @@ -1,8 +1,6 @@ -using MahApps.Metro.SimpleChildWindow; -using NETworkManager.Localization.Resources; +using NETworkManager.Localization.Resources; using NETworkManager.Settings; using NETworkManager.Utilities; -using NETworkManager.Views; using System; using System.Diagnostics; using System.Threading.Tasks; @@ -16,6 +14,8 @@ public class SettingsSettingsViewModel : ViewModelBase #region Variables public Action CloseAction { get; set; } + private readonly bool _isLoading; + private string _location; public string Location @@ -30,18 +30,61 @@ public string Location OnPropertyChanged(); } } + + private bool _isDailyBackupEnabled; + + public bool IsDailyBackupEnabled + { + get => _isDailyBackupEnabled; + set + { + if (value == _isDailyBackupEnabled) + return; + + if (!_isLoading) + SettingsManager.Current.Settings_IsDailyBackupEnabled = value; + + _isDailyBackupEnabled = value; + OnPropertyChanged(); + } + } + + private int _maximumNumberOfBackups; + + public int MaximumNumberOfBackups + { + get => _maximumNumberOfBackups; + set + { + if (value == _maximumNumberOfBackups) + return; + + if (!_isLoading) + SettingsManager.Current.Settings_MaximumNumberOfBackups = value; + + _maximumNumberOfBackups = value; + OnPropertyChanged(); + } + } + #endregion #region Constructor, LoadSettings public SettingsSettingsViewModel() { + _isLoading = true; + LoadSettings(); + + _isLoading = false; } private void LoadSettings() { Location = SettingsManager.GetSettingsFolderLocation(); + IsDailyBackupEnabled = SettingsManager.Current.Settings_IsDailyBackupEnabled; + MaximumNumberOfBackups = SettingsManager.Current.Settings_MaximumNumberOfBackups; } #endregion diff --git a/Source/NETworkManager/Views/SettingsProfilesView.xaml b/Source/NETworkManager/Views/SettingsProfilesView.xaml index d73f2640c8..0989c4a24d 100644 --- a/Source/NETworkManager/Views/SettingsProfilesView.xaml +++ b/Source/NETworkManager/Views/SettingsProfilesView.xaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" @@ -144,7 +145,8 @@ + + + + + + + +