From aac94020496aa20b1a19663f68acb4560f4b9fec Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:36:57 -0500 Subject: [PATCH 01/33] Now can scan for duplicate files --- .gitignore | 1 + Parallel.Cli/Commands/DecryptCommand.cs | 2 +- Parallel.Cli/Commands/DuplicatesCommand.cs | 23 ++----------------- Parallel.Cli/Commands/EncryptCommand.cs | 2 +- Parallel.Core/Settings/ParallelSettings.cs | 12 ++++++++++ Parallel.Core/Settings/ProfileConfig.cs | 26 +++++----------------- 6 files changed, 23 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 9a9d1e7..707d565 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.userosscache *.sln.docstates *.sln +*.lnk # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs index ea28e9c..4aa9112 100644 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ b/Parallel.Cli/Commands/DecryptCommand.cs @@ -56,7 +56,7 @@ public DecryptCommand() : base("decrypt", "Decrypts a file or directory.") private async Task DecryptDirectoryAsync(string path, string masterKey) { CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray); - string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".gz")).ToArray(); + string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).ToArray(); if (files.Length == 0) { CommandLine.WriteLine("No files found to decrypt!", ConsoleColor.Yellow); diff --git a/Parallel.Cli/Commands/DuplicatesCommand.cs b/Parallel.Cli/Commands/DuplicatesCommand.cs index 4d5e2aa..6a4ad92 100644 --- a/Parallel.Cli/Commands/DuplicatesCommand.cs +++ b/Parallel.Cli/Commands/DuplicatesCommand.cs @@ -14,34 +14,15 @@ namespace Parallel.Cli.Commands public class DuplicatesCommand : Command { private Argument sourceArg = new("path", "The directory to scan."); - private Option credsOpt = new(["--credentials", "-c"], "The file system credentials to use."); public DuplicatesCommand() : base("duplicates", "Scans a directory for duplicate files.") { this.AddArgument(sourceArg); - this.AddOption(credsOpt); - this.SetHandler((path, config) => - { - ProfileConfig profile = ProfileConfig.Load(Program.Settings, config); - ScanForDuplicateFiles(path, profile); - }, sourceArg, credsOpt); + this.SetHandler(ScanForDuplicateFiles, sourceArg); } - private void ScanForDuplicateFiles(string path, ProfileConfig profile) + private void ScanForDuplicateFiles(string path) { - IBackupManager backup = BackupManager.CreateNew(profile); - if (!backup.Initialize()) - { - CommandLine.WriteLine("Failed to connect to backup file system!", ConsoleColor.Red); - return; - } - - if (!Directory.Exists(path)) - { - CommandLine.WriteLine("The provided directory is invalid!", ConsoleColor.Yellow); - return; - } - CommandLine.WriteLine($"Scanning for duplicate files in {path}...", ConsoleColor.DarkGray); Dictionary duplicates = FileScanner.GetDuplicateFiles(path); Dictionary result = duplicates.ToDictionary(k => k.Key, v => v.Value.Select(l => l.LocalPath).ToArray()); diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs index dd048c4..aea212d 100644 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ b/Parallel.Cli/Commands/EncryptCommand.cs @@ -59,7 +59,7 @@ public EncryptCommand() : base("encrypt", "Encrypts a file or directory.") private async Task EncryptDirectoryAsync(string path, string masterKey) { CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray); - string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".gz")).ToArray(); + string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).ToArray(); if (files.Length == 0) { CommandLine.WriteLine("No files found to encrypt!", ConsoleColor.Yellow); diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index 412a8b5..caaec4e 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -66,5 +66,17 @@ public void Save() if (!Directory.Exists(PathBuilder.ProgramData)) Directory.CreateDirectory(PathBuilder.ProgramData); File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(this, Formatting.Indented)); } + + /// + /// + /// + /// + public void ForEachProfile(Action action) + { + foreach (ProfileConfig? profile in Profiles.Select(ProfileConfig.Load)) + { + if (profile != null) action(profile); + } + } } } \ No newline at end of file diff --git a/Parallel.Core/Settings/ProfileConfig.cs b/Parallel.Core/Settings/ProfileConfig.cs index 5878534..0d54fed 100644 --- a/Parallel.Core/Settings/ProfileConfig.cs +++ b/Parallel.Core/Settings/ProfileConfig.cs @@ -112,18 +112,11 @@ public ProfileConfig(string name, DatabaseCredentials database, FileSystemCreden /// /// Loads settings from a file. /// - public static ProfileConfig Load(string path) + public static ProfileConfig? Load(string path) { - if (File.Exists(path)) - { - string json = File.ReadAllText(path); - return JsonConvert.DeserializeObject(json); - } - else - { - string name = Path.GetFileNameWithoutExtension(path); - return new ProfileConfig(name, new DatabaseCredentials(), new FileSystemCredentials()); - } + if (!File.Exists(path)) return null; + string json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json); } /// @@ -132,15 +125,8 @@ public static ProfileConfig Load(string path) /// A instance. public static ProfileConfig? Load(ParallelSettings settings, string name) { - ProfileConfig profile = Load(settings.Profiles.First()); - if (!string.IsNullOrEmpty(name)) - { - string path = Path.Combine(ParallelSettings.ProfilesDir, name + ".json"); - if (!File.Exists(path)) return null; - profile = Load(Path.GetFileNameWithoutExtension(path)); - } - - return profile; + ProfileConfig? profile = Load(settings.Profiles.First()); + return string.IsNullOrEmpty(name) ? profile : Load(Path.Combine(ParallelSettings.ProfilesDir, name + ".json")); } /// From 2b9d004512c9f2bed36e1179b910c76ba99b0ade Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 25 Aug 2025 19:06:05 -0500 Subject: [PATCH 02/33] Refactored compressing files code --- Parallel.Cli/Commands/UnzipCommand.cs | 18 ++---------------- Parallel.Cli/Commands/ZipCommand.cs | 18 ++---------------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/Parallel.Cli/Commands/UnzipCommand.cs b/Parallel.Cli/Commands/UnzipCommand.cs index 385bad6..97c0030 100644 --- a/Parallel.Cli/Commands/UnzipCommand.cs +++ b/Parallel.Cli/Commands/UnzipCommand.cs @@ -33,27 +33,13 @@ public UnzipCommand() : base("unzip", "Unzips files in a directory.") CommandLine.WriteLine($"Unzipping {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); _totalTasks = files.Length; - foreach (string file in files) - { - StartDecompressFile(file, keep); - } - + _tasks.AddRange(files.Select(file => Task.Run(() => DecompressFile(file, keep)))); await Task.WhenAll(_tasks); + CommandLine.WriteLine($"Successfully unzipped {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); }, sourceArg, keepOpt); } - private void StartDecompressFile(string path, bool keep) - { - Task decompTask = Task.Run(() => - { - DecompressFile(path, keep); - }); - - decompTask.ContinueWith(t => t.Dispose()); - _tasks.Add(decompTask); - } - private void DecompressFile(string path, bool keep) { if (File.Exists(path)) diff --git a/Parallel.Cli/Commands/ZipCommand.cs b/Parallel.Cli/Commands/ZipCommand.cs index 3dd1a5d..83722f7 100644 --- a/Parallel.Cli/Commands/ZipCommand.cs +++ b/Parallel.Cli/Commands/ZipCommand.cs @@ -35,27 +35,13 @@ public ZipCommand() : base("zip", "Zips files in a directory.") CommandLine.WriteLine($"Zipping {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); _totalTasks = files.Length; - foreach (string file in files) - { - StartCompressFile(file, keep); - } - + _tasks.AddRange(files.Select(file => Task.Run(() => CompressFile(file, keep)))); await Task.WhenAll(_tasks); + CommandLine.WriteLine($"Successfully zipped {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); }, sourceArg, keepOpt); } - private void StartCompressFile(string path, bool keep) - { - Task compTask = Task.Run(() => - { - CompressFile(path, keep); - }); - - compTask.ContinueWith(t => t.Dispose()); - _tasks.Add(compTask); - } - private void CompressFile(string path, bool keep) { if (File.Exists(path)) From 5c80fab7941171381aad4b52fa7e504b391367dd Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:27:31 -0500 Subject: [PATCH 03/33] Added quick start to readme --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e3fc26d..33b571b 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,30 @@ Your computer already gives you enough to fight with — your files don't have t -## 📦 Installation - -> Coming soon — Parallel is currently in active development. Stay tuned for install instructions, binaries, and package manager support. +## 📦 Quick Start Guide +#### 1. Install Parallel +Download the latest [release](https://github.com/TheGuitarleader/Parallel/releases/latest) or build from source: +``` +git clone https://github.com/TheGuitarleader/Parallel +cd Parallel +dotnet build +``` +#### 2. Set Up Your Vaults +Vaults are storage targets where Parallel sends and recieves files. This can be an external drive, NAS share, SSH server, or S3-compatible cloud. +``` +parallel vaults create +``` +*Note: All vaults are saved as JSON in `%AppData%\Parallel\Vaults` for easy importing and exporting.* +#### 3. Push Files to Vaults +Parallel can push all changed files on the system with: +``` +parallel push +``` +Or you can specify a path which can be a file or folder. +``` +parallel push --path "C:\Windows\System32" +parallel push -p "C:\Windows\System32\cmd.exe" +``` ## 🧪 Status From 8a71619cd2990029a8a02b0d021556892a963a38 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:28:07 -0500 Subject: [PATCH 04/33] Renamed profiles to vaults --- .../{ConfigCommand.cs => VaultsCommand.cs} | 30 +++++++------- Parallel.Core/Settings/ParallelSettings.cs | 11 +++--- .../{ProfileConfig.cs => VaultConfig.cs} | 39 ++++++++++--------- 3 files changed, 43 insertions(+), 37 deletions(-) rename Parallel.Cli/Commands/{ConfigCommand.cs => VaultsCommand.cs} (74%) rename Parallel.Core/Settings/{ProfileConfig.cs => VaultConfig.cs} (81%) diff --git a/Parallel.Cli/Commands/ConfigCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs similarity index 74% rename from Parallel.Cli/Commands/ConfigCommand.cs rename to Parallel.Cli/Commands/VaultsCommand.cs index 661a68d..d6b1492 100644 --- a/Parallel.Cli/Commands/ConfigCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -4,27 +4,31 @@ using Parallel.Cli.Utils; using Parallel.Core.Database; using Parallel.Core.IO.FileSystem; +using Parallel.Core.Security; using Parallel.Core.Settings; using Parallel.Core.Utils; namespace Parallel.Cli.Commands { - public class ConfigCommand : Command + public class VaultsCommand : Command { - private Option configOpt = new(["--config", "-c"], "The profile configuration to use."); + private Option configOpt = new(["--config", "-c"], "The vault configuration to use."); - private Command addCmd = new("add", "Adds a new profile configuration."); - private Command editCmd = new("edit", "Edits a profile configuration."); - private Command viewCmd = new("view", "Shows the profile configuration."); - private Command setCmd = new("set", "Sets a new profile configuration."); - private Command delCmd = new("delete", "Deletes a profile configuration."); + private Command addCmd = new("add", "Adds a new vault configuration."); + private Command editCmd = new("edit", "Edits a vault configuration."); + private Command viewCmd = new("view", "Shows the vault configuration."); + private Command setCmd = new("set", "Sets a new vault configuration."); + private Command delCmd = new("delete", "Deletes a vault configuration."); - public ConfigCommand() : base("config", "View or edit the profile configurations.") + public VaultsCommand() : base("vaults", "View or edit the vaults.") { this.SetHandler(() => { - //ProfileConfig profile = ProfileConfig.Load(); - CommandLine.WriteLine($"Current profile: '{Program.Settings.Profiles.FirstOrDefault()}'"); + CommandLine.WriteLine("Active vaults:"); + Program.Settings.ForEachVault(vault => + { + CommandLine.WriteLine(vault.Name); + }); }); this.AddCommand(addCmd); @@ -70,10 +74,10 @@ public ConfigCommand() : base("config", "View or edit the profile configurations fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); string profileName = CommandLine.ReadString("Profile Name"); - ProfileConfig profile = new ProfileConfig(profileName, dbc, fsc); - profile.SaveToFile(); + VaultConfig vault = new VaultConfig(profileName, dbc, fsc); + vault.SaveToFile(); - CommandLine.WriteLine($"Saved new connection profile: '{profile.Name}'"); + CommandLine.WriteLine($"Saved new connection vault: '{vault.Name}'"); }); this.AddCommand(setCmd); diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index caaec4e..bc3a562 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -19,7 +19,7 @@ public class ParallelSettings /// /// The location of files for different file system credentials./>. /// - public static string ProfilesDir { get; } = Path.Combine(PathBuilder.ProgramData, "Profiles"); + public static string VaultsDir { get; } = Path.Combine(PathBuilder.ProgramData, "Vaults"); /// /// The address that will accept incoming commands. @@ -37,7 +37,7 @@ public class ParallelSettings /// The profiles to use. /// The CLI defaults to the first in the list. /// - public HashSet Profiles { get; } = new HashSet(); + public HashSet Vaults { get; } = new HashSet(); /// @@ -71,11 +71,12 @@ public void Save() /// /// /// - public void ForEachProfile(Action action) + public void ForEachVault(Action action) { - foreach (ProfileConfig? profile in Profiles.Select(ProfileConfig.Load)) + foreach (string path in Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly)) { - if (profile != null) action(profile); + VaultConfig? vault = VaultConfig.Load(path); + if (vault != null) action(vault); } } } diff --git a/Parallel.Core/Settings/ProfileConfig.cs b/Parallel.Core/Settings/VaultConfig.cs similarity index 81% rename from Parallel.Core/Settings/ProfileConfig.cs rename to Parallel.Core/Settings/VaultConfig.cs index 0d54fed..60f2b84 100644 --- a/Parallel.Core/Settings/ProfileConfig.cs +++ b/Parallel.Core/Settings/VaultConfig.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.FileSystem; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.Settings @@ -12,15 +13,15 @@ namespace Parallel.Core.Settings /// /// Represents a back-up connection. /// - public class ProfileConfig + public class VaultConfig { /// - /// A unique hash used to identify the profile. + /// A unique hash used to identify the vault. /// public string Id { get; } = HashGenerator.GenerateHash(12, true); /// - /// The name of the profile. + /// The name of the vault. /// public string Name { get; set; } = "Default"; @@ -80,14 +81,14 @@ public class ProfileConfig /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// /// /// /// [JsonConstructor] - public ProfileConfig(string id, string name, DatabaseCredentials database, FileSystemCredentials fileSystem) + public VaultConfig(string id, string name, DatabaseCredentials database, FileSystemCredentials fileSystem) { Id = id; Name = name; @@ -96,12 +97,12 @@ public ProfileConfig(string id, string name, DatabaseCredentials database, FileS } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// /// /// - public ProfileConfig(string name, DatabaseCredentials database, FileSystemCredentials fileSystem) + public VaultConfig(string name, DatabaseCredentials database, FileSystemCredentials fileSystem) { Id = HashGenerator.GenerateHash(12, true); Name = name; @@ -112,39 +113,39 @@ public ProfileConfig(string name, DatabaseCredentials database, FileSystemCreden /// /// Loads settings from a file. /// - public static ProfileConfig? Load(string path) + public static VaultConfig? Load(string path) { if (!File.Exists(path)) return null; string json = File.ReadAllText(path); - return JsonConvert.DeserializeObject(json); + return JsonConvert.DeserializeObject(json); } /// /// Loads credentials from the app configuration. /// - /// A instance. - public static ProfileConfig? Load(ParallelSettings settings, string name) + /// A instance. + public static VaultConfig? Load(ParallelSettings settings, string name) { - ProfileConfig? profile = Load(settings.Profiles.First()); - return string.IsNullOrEmpty(name) ? profile : Load(Path.Combine(ParallelSettings.ProfilesDir, name + ".json")); + VaultConfig? vault = Load(settings.Vaults.First()); + return string.IsNullOrEmpty(name) ? vault : Load(Path.Combine(ParallelSettings.VaultsDir, name + ".json")); } /// /// Saves credentials to a file. /// - /// The current profile to save. - public static void Save(ProfileConfig profile) + /// The current vault to save. + public static void Save(VaultConfig vault) { - if (!Directory.Exists(ParallelSettings.ProfilesDir)) Directory.CreateDirectory(ParallelSettings.ProfilesDir); - string path = Path.Combine(ParallelSettings.ProfilesDir, profile.Name + ".json"); - Log.Debug($"Saving profile file: {path}"); + if (!Directory.Exists(ParallelSettings.VaultsDir)) Directory.CreateDirectory(ParallelSettings.VaultsDir); + string path = Path.Combine(ParallelSettings.VaultsDir, vault.Name + ".json"); + Log.Debug($"Saving vault file: {path}"); if (!File.Exists(path)) { Log.Debug("Creating file -> " + path); File.Create(path).Close(); } - File.WriteAllText(path, JsonConvert.SerializeObject(profile, Formatting.Indented)); + File.WriteAllText(path, JsonConvert.SerializeObject(vault, Formatting.Indented)); } /// From 4f7b01acf3813cd8a2534688ecd153f7f5c5b97a Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:28:56 -0500 Subject: [PATCH 05/33] Renamed backup to sync for pushing and pulling files --- Parallel.Core/Database/IDatabase.cs | 14 ++++---- .../BaseSyncManager.cs} | 32 ++++++++----------- .../DeltaSyncManager.cs} | 14 ++++---- .../FileSyncManager.cs} | 17 +++++----- .../ISyncManager.cs} | 25 ++++----------- .../SyncManager.cs} | 12 +++---- 6 files changed, 49 insertions(+), 65 deletions(-) rename Parallel.Core/IO/{Backup/BaseFileManager.cs => Syncing/BaseSyncManager.cs} (52%) rename Parallel.Core/IO/{Backup/DeltaBackupManager.cs => Syncing/DeltaSyncManager.cs} (51%) rename Parallel.Core/IO/{Backup/FileBackupManager.cs => Syncing/FileSyncManager.cs} (81%) rename Parallel.Core/IO/{Backup/IBackupManager.cs => Syncing/ISyncManager.cs} (63%) rename Parallel.Core/IO/{Backup/BackupManager.cs => Syncing/SyncManager.cs} (53%) diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index 594674f..c39e5f8 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -29,19 +29,19 @@ public enum HistoryType Cloned, /// - /// A file that has been deleted from the backup. + /// A file that has been deleted from the vault. /// Pruned, /// - /// A file that was deleted and has been restored. + /// A file that was pulled from the vault. /// - Restored, + Pulled, /// - /// A newly synced file. + /// A file that was pushed to the vault. /// - Synced + Pushed } /// @@ -50,7 +50,7 @@ public enum HistoryType public interface IDatabase { /// - /// The identifier to the profile for this database. + /// The identifier to the vault for this database. /// string ProfileId { get; } @@ -93,7 +93,7 @@ public interface IDatabase #endregion - Task> GetFilesAsync(string path, bool b); + Task> GetFilesAsync(string path, bool deleted); Task GetFileAsync(string path); } } \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/BaseFileManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs similarity index 52% rename from Parallel.Core/IO/Backup/BaseFileManager.cs rename to Parallel.Core/IO/Syncing/BaseSyncManager.cs index 53f9b7e..9d6933d 100644 --- a/Parallel.Core/IO/Backup/BaseFileManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -2,20 +2,20 @@ using Parallel.Core.Database; using Parallel.Core.Diagnostics; -using Parallel.Core.Events; +using Parallel.Core.IO.Backup; using Parallel.Core.IO.FileSystem; using Parallel.Core.Models; using Parallel.Core.Settings; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// /// Represents the base way of backing up files to an associated file system. /// - public abstract class BaseFileManager : IBackupManager + public abstract class BaseSyncManager : ISyncManager { /// - public ProfileConfig Profile { get; } + public VaultConfig Vault { get; } /// public IDatabase Database { get; set; } @@ -23,20 +23,14 @@ public abstract class BaseFileManager : IBackupManager /// public IFileSystem FileSystem { get; set; } - /// - public string MachineName { get; } = Environment.MachineName; - - /// - public string RootFolder { get; set; } - /// /// /// - /// - public BaseFileManager(ProfileConfig profile) + /// + public BaseSyncManager(VaultConfig vault) { - FileSystem = FileSystemManager.CreateNew(profile.FileSystem); - Profile = profile; + FileSystem = FileSystemManager.CreateNew(vault.FileSystem); + Vault = vault; } /// @@ -44,10 +38,10 @@ public virtual bool Initialize() { try { - Database = DatabaseConnection.CreateNew(Profile); + Database = DatabaseConnection.CreateNew(Vault); bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; - Profile.IgnoreDirectories.Add(Profile.FileSystem.RootDirectory); - if (Profile != null) Profile.SaveToFile(); + Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); + if (Vault != null) Vault.SaveToFile(); return fsInit; } catch (Exception ex) @@ -58,9 +52,9 @@ public virtual bool Initialize() } /// - public abstract Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress); + public abstract Task PushFilesAsync(SystemFile[] files, IProgressReporter progress); /// - public abstract Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress); + public abstract Task PullFilesAsync(SystemFile[] files, IProgressReporter progress); } } \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/DeltaBackupManager.cs b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs similarity index 51% rename from Parallel.Core/IO/Backup/DeltaBackupManager.cs rename to Parallel.Core/IO/Syncing/DeltaSyncManager.cs index 20c2084..10663d6 100644 --- a/Parallel.Core/IO/Backup/DeltaBackupManager.cs +++ b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs @@ -4,27 +4,27 @@ using Parallel.Core.Models; using Parallel.Core.Settings; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// /// Represents the way to clone files to an associated file system using file deltas. /// - public class DeltaBackupManager : BaseFileManager + public class DeltaSyncManager : BaseSyncManager { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// - public DeltaBackupManager(ProfileConfig profile) : base(profile) { } + /// + public DeltaSyncManager(VaultConfig vault) : base(vault) { } /// - public override Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress) + public override Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) { throw new NotImplementedException(); } /// - public override Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress) + public override Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) { throw new NotImplementedException(); } diff --git a/Parallel.Core/IO/Backup/FileBackupManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs similarity index 81% rename from Parallel.Core/IO/Backup/FileBackupManager.cs rename to Parallel.Core/IO/Syncing/FileSyncManager.cs index 4cd4427..806f442 100644 --- a/Parallel.Core/IO/Backup/FileBackupManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -5,6 +5,7 @@ using Parallel.Core.Diagnostics; using Parallel.Core.Events; using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; using Parallel.Core.Models; using Parallel.Core.Settings; @@ -13,19 +14,19 @@ namespace Parallel.Core.IO.Backup /// /// Represents the way to archive files to an associated file system. /// - public class FileBackupManager : BaseFileManager + public class FileSyncManager : BaseSyncManager { private List _tasks = new List(); private int _totalFiles; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// - public FileBackupManager(ProfileConfig profile) : base(profile) { } + /// + public FileSyncManager(VaultConfig vault) : base(vault) { } /// - public override async Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress) + public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) { if (!files.Any()) return; SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); @@ -47,7 +48,7 @@ public override async Task BackupFilesAsync(SystemFile[] files, IProgressReporte if (remote is not null) { file.RemoteSize = remote.RemoteSize; - await Database.AddHistoryAsync(file.LocalPath, HistoryType.Synced); + await Database.AddHistoryAsync(file.LocalPath, HistoryType.Pushed); await Database.AddFileAsync(file); } } @@ -55,7 +56,7 @@ public override async Task BackupFilesAsync(SystemFile[] files, IProgressReporte } /// - public override async Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress) + public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) { SystemFile[] restoreFiles = files.Where(f => f.Deleted).ToArray(); @@ -66,7 +67,7 @@ public override async Task RestoreFilesAsync(SystemFile[] files, IProgressReport { SystemFile file = files[i]; Log.Information($"Restoring file: {file.LocalPath}..."); - file.RemotePath = PathBuilder.Remote(file.LocalPath, Profile.FileSystem); + file.RemotePath = PathBuilder.Remote(file.LocalPath, Vault.FileSystem); } } } diff --git a/Parallel.Core/IO/Backup/IBackupManager.cs b/Parallel.Core/IO/Syncing/ISyncManager.cs similarity index 63% rename from Parallel.Core/IO/Backup/IBackupManager.cs rename to Parallel.Core/IO/Syncing/ISyncManager.cs index d819857..d634a85 100644 --- a/Parallel.Core/IO/Backup/IBackupManager.cs +++ b/Parallel.Core/IO/Syncing/ISyncManager.cs @@ -12,12 +12,12 @@ namespace Parallel.Core.IO.Backup /// /// Defines the methods needed for backing up a file system. /// - public interface IBackupManager + public interface ISyncManager { /// - /// The back-up connection profile. + /// The back-up connection vault. /// - public ProfileConfig Profile { get; } + public VaultConfig Vault { get; } /// /// The associated database connection. @@ -29,17 +29,6 @@ public interface IBackupManager /// IFileSystem FileSystem { get; set; } - /// - /// The current machine name. - /// - string MachineName { get; } - - /// - /// The root directory of the back-up. - /// - string RootFolder { get; set; } - - /// /// Initializes the backup manager by logging into the and /// @@ -47,17 +36,17 @@ public interface IBackupManager bool Initialize(); /// - /// Backs up a path. Can be either a file or directory. + /// Pushes an array of files to a vault. /// /// /// - Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress); + Task PushFilesAsync(SystemFile[] files, IProgressReporter progress); /// - /// Restores a path. Can be either a file or directory. + /// Pulls an array of files from a vault. /// /// /// - Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress); + Task PullFilesAsync(SystemFile[] files, IProgressReporter progress); } } \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/BackupManager.cs b/Parallel.Core/IO/Syncing/SyncManager.cs similarity index 53% rename from Parallel.Core/IO/Backup/BackupManager.cs rename to Parallel.Core/IO/Syncing/SyncManager.cs index 875c06f..f5b4655 100644 --- a/Parallel.Core/IO/Backup/BackupManager.cs +++ b/Parallel.Core/IO/Syncing/SyncManager.cs @@ -11,18 +11,18 @@ namespace Parallel.Core.IO.Backup { /// - /// Represents the way manage s. + /// Represents the way manage s. /// - public static class BackupManager + public static class SyncManager { /// - /// Creates a new instance of an . + /// Creates a new instance of an . /// - /// + /// /// - public static IBackupManager CreateNew(ProfileConfig profile) + public static ISyncManager CreateNew(VaultConfig vault) { - return new FileBackupManager(profile); + return new FileSyncManager(vault); } } } \ No newline at end of file From b3e136b54525fccff765b42ce9ab6052c8cff230 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:30:00 -0500 Subject: [PATCH 06/33] Added checksums to files for better change detection --- Parallel.Cli/Commands/DecryptCommand.cs | 12 +++---- Parallel.Cli/Commands/EncryptCommand.cs | 13 ++++---- .../Database/Contexts/SqliteContext.cs | 12 +++---- Parallel.Core/Database/DatabaseConnection.cs | 8 ++--- Parallel.Core/IO/Recovery/RecoveryManager.cs | 16 ++++----- Parallel.Core/Models/SystemFile.cs | 33 ++++++++++--------- 6 files changed, 49 insertions(+), 45 deletions(-) diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs index 4aa9112..76bd4ca 100644 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ b/Parallel.Cli/Commands/DecryptCommand.cs @@ -14,7 +14,7 @@ namespace Parallel.Cli.Commands public class DecryptCommand : Command { private readonly Argument _sourceArg = new("path", "The source path of files to zip."); - private readonly Option _configOpt = new(["--config", "-c"], "The profile configuration to use."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private IDatabase? _database; private Stopwatch _sw = new Stopwatch(); @@ -26,15 +26,15 @@ public DecryptCommand() : base("decrypt", "Decrypts a file or directory.") this.AddArgument(_sourceArg); this.SetHandler(async (path, config) => { - ProfileConfig? profile = ProfileConfig.Load(Program.Settings, config); - if (profile == null) + VaultConfig? vault = VaultConfig.Load(Program.Settings, config); + if (vault == null) { - CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow); + CommandLine.WriteLine("No active vault was found!", ConsoleColor.Yellow); return; } - _database = DatabaseConnection.CreateNew(profile); - string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); + _database = DatabaseConnection.CreateNew(vault); + string masterKey = vault.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); if (PathBuilder.IsDirectory(path)) { await DecryptDirectoryAsync(path, masterKey); diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs index aea212d..36ee33f 100644 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ b/Parallel.Cli/Commands/EncryptCommand.cs @@ -7,6 +7,7 @@ using Parallel.Core.Database; using Parallel.Core.IO; using Parallel.Core.Models; +using Parallel.Core.Security; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -15,7 +16,7 @@ namespace Parallel.Cli.Commands public class EncryptCommand : Command { private readonly Argument _sourceArg = new("path", "The source path to encrypt."); - private readonly Option _configOpt = new(["--config", "-c"], "The profile configuration to use."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private IDatabase? _database; private Stopwatch _sw = new Stopwatch(); @@ -28,15 +29,15 @@ public EncryptCommand() : base("encrypt", "Encrypts a file or directory.") this.SetHandler(async (path, config) => { _sw = Stopwatch.StartNew(); - ProfileConfig? profile = ProfileConfig.Load(Program.Settings, config); - if (profile == null) + VaultConfig? vault = VaultConfig.Load(Program.Settings, config); + if (vault == null) { - CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow); + CommandLine.WriteLine("No active vault was found!", ConsoleColor.Yellow); return; } - _database = DatabaseConnection.CreateNew(profile); - string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); + _database = DatabaseConnection.CreateNew(vault); + string masterKey = vault.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); if (PathBuilder.IsDirectory(path)) { await EncryptDirectoryAsync(path, masterKey); diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 738fab1..db9a423 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -43,8 +43,8 @@ public async Task InitializeAsync() File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`profile` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB, `iv` BLOB, PRIMARY KEY(`profile`, `id`));"); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`profile` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`profile`, `timestamp`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`vault` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB, `iv` BLOB, `checksum` BLOB, PRIMARY KEY(`vault`, `id`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`vault` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`vault`, `timestamp`));"); } #endregion @@ -55,7 +55,7 @@ public async Task InitializeAsync() public async Task AddFileAsync(SystemFile file) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO files (profile, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV);"; + string sql = @"INSERT OR REPLACE INTO files (vault, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV);"; return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV }) > 0; } @@ -63,7 +63,7 @@ public async Task AddFileAsync(SystemFile file) public async Task> GetFilesAsync(string path, bool deleted) { using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND deleted = {deleted} ORDER BY lastupdate DESC"; + string sql = $"SELECT * FROM files WHERE vault = \"{ProfileId}\" AND deleted = {deleted} ORDER BY lastupdate DESC"; return await connection.QueryAsync(sql); } @@ -71,7 +71,7 @@ public async Task> GetFilesAsync(string path, bool delet public async Task GetFileAsync(string path) { using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; + string sql = $"SELECT * FROM files WHERE vault = \"{ProfileId}\" AND localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; return await connection.QuerySingleOrDefaultAsync(sql); } @@ -83,7 +83,7 @@ public async Task> GetFilesAsync(string path, bool delet public async Task AddHistoryAsync(string path, HistoryType type) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO history (profile, timestamp, name, path, type) VALUES(@ProfileId, @Timestamp, @Name, @Path, @Type);"; + string sql = @"INSERT OR REPLACE INTO history (vault, timestamp, name, path, type) VALUES(@ProfileId, @Timestamp, @Name, @Path, @Type);"; return await connection.ExecuteAsync(sql, new { ProfileId, Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; } diff --git a/Parallel.Core/Database/DatabaseConnection.cs b/Parallel.Core/Database/DatabaseConnection.cs index b2c8998..c65f6cd 100644 --- a/Parallel.Core/Database/DatabaseConnection.cs +++ b/Parallel.Core/Database/DatabaseConnection.cs @@ -17,15 +17,15 @@ public enum DatabaseProvider /// public class DatabaseConnection { - public static IDatabase CreateNew(ProfileConfig profile) + public static IDatabase CreateNew(VaultConfig vault) { - switch(profile.Database.Provider) + switch(vault.Database.Provider) { default: return null; case DatabaseProvider.Local: - IDatabase db = new SqliteContext(profile.Database, profile.Id); - if (!File.Exists(profile.Database.Address)) db.InitializeAsync(); + IDatabase db = new SqliteContext(vault.Database, vault.Id); + if (!File.Exists(vault.Database.Address)) db.InitializeAsync(); return db; } } diff --git a/Parallel.Core/IO/Recovery/RecoveryManager.cs b/Parallel.Core/IO/Recovery/RecoveryManager.cs index ccab8da..dfe4e23 100644 --- a/Parallel.Core/IO/Recovery/RecoveryManager.cs +++ b/Parallel.Core/IO/Recovery/RecoveryManager.cs @@ -17,7 +17,7 @@ public class RecoveryManager public IDatabase Database { get; set; } public IFileSystem FileSystem { get; set; } - public ProfileConfig Profile { get; set; } + public VaultConfig Vault { get; set; } public string MachineName { get; } = Environment.MachineName; public string RootFolder { get; set; } @@ -25,21 +25,21 @@ public class RecoveryManager /// Initializes a new instance of the class. /// /// - public RecoveryManager(ProfileConfig profile) + public RecoveryManager(VaultConfig vault) { - Profile = profile; - Database = DatabaseConnection.CreateNew(profile); - FileSystem = FileSystemManager.CreateNew(profile.FileSystem); + Vault = vault; + Database = DatabaseConnection.CreateNew(vault); + FileSystem = FileSystemManager.CreateNew(vault.FileSystem); } public bool Initialize() { try { - Database = DatabaseConnection.CreateNew(Profile); + Database = DatabaseConnection.CreateNew(Vault); bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; - Profile.IgnoreDirectories.Add(Profile.FileSystem.RootDirectory); - if (Profile != null) Profile.SaveToFile(); + Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); + if (Vault != null) Vault.SaveToFile(); return fsInit; } catch (Exception ex) diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index aa4ccdb..a18dfed 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -3,6 +3,7 @@ using System.Data; using Parallel.Core.Data; using Parallel.Core.Diagnostics; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.Models @@ -80,12 +81,17 @@ public class SystemFile /// /// The salt used to encrypt the file. /// - public byte[] Salt { get; set; } = Array.Empty(); + public byte[] Salt { get; set; } = []; /// /// The initialization vector used to encrypt the file. /// - public byte[] IV { get; set; } = Array.Empty(); + public byte[] IV { get; set; } = []; + + /// + /// The checksum used to check if the file has changed. + /// + public byte[] CheckSum { get; set; } = []; /// @@ -99,27 +105,23 @@ public SystemFile(string path) LocalPath = fileInfo.FullName; LocalSize = fileInfo.Length; RemoteSize = fileInfo.Length; - Type = FileTypes.GetFileCategory(Path.GetExtension(fileInfo.Name)); LastWrite = new UnixTime(fileInfo.LastWriteTime); LastUpdate = UnixTime.Now; + Type = FileTypes.GetFileCategory(Path.GetExtension(fileInfo.Name)); + Hidden = fileInfo.Attributes.HasFlag(FileAttributes.Hidden); + ReadOnly = fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly); Deleted = !fileInfo.Exists; - - if (fileInfo.Attributes.HasFlag(FileAttributes.Hidden)) - { - Hidden = true; - } - - if (fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly)) - { - ReadOnly = true; - } + Encrypted = false; + Salt = HashGenerator.RandomBytes(16); + IV = HashGenerator.RandomBytes(16); + CheckSum = HashGenerator.CheckSum(path); } /// /// Initializes a new instance of the class. /// /// - public SystemFile(string profile, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, byte[] salt, byte[] iv) + public SystemFile(string vault, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, byte[] salt, byte[] iv, byte[] checksum) { Id = id; Name = name; @@ -129,13 +131,13 @@ public SystemFile(string profile, string id, string name, string localpath, stri LastUpdate = UnixTime.FromMilliseconds(lastupdate); LocalSize = localsize; RemoteSize = remotesize; - //Type = type; Hidden = Converter.ToBool(hidden); ReadOnly = Converter.ToBool(readOnly); Deleted = Converter.ToBool(deleted); Encrypted = Converter.ToBool(encrypted); Salt = salt; IV = iv; + CheckSum = checksum; } public bool Equals(SystemFile value) @@ -155,6 +157,7 @@ public bool Equals(SystemFile value) value?.Encrypted != null ? this.Encrypted.Equals(value.Encrypted) : (bool?)null, this?.Salt != null && value?.Salt != null ? this.Salt.SequenceEqual(value.Salt) : (bool?)null, this?.IV != null && value?.IV != null ? this.IV.SequenceEqual(value.IV) : (bool?)null, + this?.CheckSum != null && value?.CheckSum != null ? this.CheckSum.SequenceEqual(value.CheckSum) : (bool?)null, ]; return results.All(b => b != null && (bool)b); From 86780c51ae4f633ccd80fdeeb980dc0c7d67cde8 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:30:16 -0500 Subject: [PATCH 07/33] Added logging --- Parallel.Cli/Utils/CommandLine.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index ac5bdf6..bc4f8ae 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -73,11 +73,28 @@ public static void Write(object value, ConsoleColor color = ConsoleColor.Gray) public static void WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) { + Log.Information(value.ToString()!); Console.ForegroundColor = color; Console.WriteLine($"> {value}"); Console.ResetColor(); } + public static void WriteWarning(object value) + { + Log.Warning(value.ToString()!); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"> {value}"); + Console.ResetColor(); + } + + public static void WriteError(object value) + { + Log.Error(value.ToString()!); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"> {value}"); + Console.ResetColor(); + } + public static void ProgressBar(double part, double total, TimeSpan elapsed, ConsoleColor color = ConsoleColor.Gray) { double percent = part / total; From ac213fcc3d2a2e117333f1a4e83f5337ee7fb262 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:30:49 -0500 Subject: [PATCH 08/33] Moved to security folder --- Parallel.Core/Security/Encryption.cs | 1 + Parallel.Core/Security/HashGenerator.cs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Parallel.Core/Security/Encryption.cs b/Parallel.Core/Security/Encryption.cs index 029f6fe..3878aba 100644 --- a/Parallel.Core/Security/Encryption.cs +++ b/Parallel.Core/Security/Encryption.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using Parallel.Core.Models; +using Parallel.Core.Security; namespace Parallel.Core.Utils { diff --git a/Parallel.Core/Security/HashGenerator.cs b/Parallel.Core/Security/HashGenerator.cs index 43614f4..eccf200 100644 --- a/Parallel.Core/Security/HashGenerator.cs +++ b/Parallel.Core/Security/HashGenerator.cs @@ -3,13 +3,18 @@ using System.Security.Cryptography; using System.Text; -namespace Parallel.Core.Utils +namespace Parallel.Core.Security { /// /// Provides functionality for generating random hashes. This class cannot be inherited. /// public static class HashGenerator { + /// + /// Generates a random series of bytes. + /// + /// + /// public static byte[] RandomBytes(int length) { byte[] bytes = new byte[length]; @@ -76,5 +81,18 @@ public static string CreateSHA256(string value) ArgumentException.ThrowIfNullOrEmpty(value); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLower(); } + + /// + /// + /// + /// + /// + public static byte[] CheckSum(string path) + { + if(!File.Exists(path)) return []; + using FileStream fs = File.OpenRead(path); + using SHA256 sha256 = SHA256.Create(); + return sha256.ComputeHash(fs); + } } } \ No newline at end of file From 8076a3623f82262a348c9f49bc594860c9cb19b2 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 05:28:31 -0500 Subject: [PATCH 09/33] Now uses checksums to validate file changes --- Parallel.Cli/Commands/PushCommand.cs | 138 ++++++++++++++++++ Parallel.Cli/Commands/VaultsCommand.cs | 2 +- Parallel.Cli/Program.cs | 1 - Parallel.Cli/Utils/CommandLine.cs | 4 +- Parallel.Cli/Utils/ProgressReport.cs | 1 + .../Database/Contexts/SqliteContext.cs | 4 +- .../IO/FileSystem/DotNetFileSystem.cs | 14 +- .../IO/FileSystem/FileSystemManager.cs | 10 +- Parallel.Core/IO/FileSystem/SftpFileSystem.cs | 9 +- Parallel.Core/IO/PathBuilder.cs | 16 +- Parallel.Core/IO/Recovery/RecoveryManager.cs | 2 +- Parallel.Core/IO/Scanning/FileScanner.cs | 92 +++++------- Parallel.Core/IO/Syncing/BaseSyncManager.cs | 8 +- Parallel.Core/IO/Syncing/FileSyncManager.cs | 4 +- Parallel.Core/Settings/ParallelSettings.cs | 15 ++ 15 files changed, 229 insertions(+), 91 deletions(-) create mode 100644 Parallel.Cli/Commands/PushCommand.cs diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs new file mode 100644 index 0000000..bfec12c --- /dev/null +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -0,0 +1,138 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.IO; +using Parallel.Core.IO.Backup; +using Parallel.Core.IO.Scanning; +using Parallel.Core.Models; + +namespace Parallel.Cli.Commands +{ + public class PushCommand : Command + { + private Command addCmd = new("add", "Adds a new directory to the backup list."); + private Command listCmd = new("list", "Shows all directories in the backup list."); + private Command removeCmd = new("remove", "Removes a directory from the backup list."); + + private readonly Option _sourceArg = new(["--path", "-p"], "The source path to backup."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); + private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); + + public PushCommand() : base("push", "Pushes files to ") + { + this.AddOption(_sourceArg); + this.AddOption(_configOpt); + this.AddOption(_verboseOpt); + this.SetHandler(async (path, config, verbose) => + { + if (string.IsNullOrEmpty(path)) + { + await SyncSystemAsync(); + } + else + { + if (PathBuilder.IsDirectory(path)) + { + await SyncDirectoryAsync(path); + } + else if (PathBuilder.IsFile(path)) + { + await SyncFileAsync(path); + } + } + }, _sourceArg, _configOpt, _verboseOpt); + } + + private async Task SyncSystemAsync() + { + throw new NotImplementedException(); + } + + private async Task SyncDirectoryAsync(string path) + { + await Program.Settings.ForEachVaultAsync(async (vault) => + { + ISyncManager sync = SyncManager.CreateNew(vault); + if (!sync.Initialize()) + { + CommandLine.WriteError($"Failed to connect to vault '{vault.Name}'!"); + return; + } + + string[] backupFolders = vault.BackupDirectories.ToArray(); + if (!backupFolders.Any(path.StartsWith)) + { + CommandLine.WriteWarning($"The provided folder is not set to be backed up!"); + return; + } + + string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); + FileScanner scanner = new FileScanner(sync); + + // Checks if the file can be backed up. + if (FileScanner.IsIgnored(path, ignoredFolders)) + { + CommandLine.WriteWarning($"The provided folder is set to be ignored!"); + return; + } + + CommandLine.WriteLine($"Scanning for file changes in {path}...", ConsoleColor.DarkGray); + SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); + int successFiles = files.Length; + if (successFiles == 0) + { + CommandLine.WriteLine($"The provided directory is already up to date.", ConsoleColor.Green); + return; + } + + CommandLine.WriteLine($"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); + await sync.PushFilesAsync(files, new ProgressReport()); + CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files.", ConsoleColor.Green); + }); + } + + private async Task SyncFileAsync(string path) + { + await Program.Settings.ForEachVaultAsync(async (vault) => + { + ISyncManager sync = SyncManager.CreateNew(vault); + if (!sync.Initialize()) + { + CommandLine.WriteError("Failed to connect to backup file system!"); + return; + } + + string[] backupFolders = vault.BackupDirectories.ToArray(); + if (!backupFolders.Any(path.StartsWith)) + { + CommandLine.WriteWarning($"The provided file is not set to be backed up!"); + return; + } + + string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); + FileScanner scanner = new FileScanner(sync); + + // Checks if the file can be backed up. + if (FileScanner.IsIgnored(path, ignoredFolders)) + { + CommandLine.WriteWarning($"The provided folder is set to be ignored!"); + return; + } + + SystemFile localFile = new SystemFile(path); + SystemFile? remoteFile = await sync.Database.GetFileAsync(localFile.LocalPath); + + if (!FileScanner.HasChanged(localFile, remoteFile)) + { + CommandLine.WriteLine($"The provided file is already up to date.", ConsoleColor.Green); + return; + } + + CommandLine.WriteLine($"Pushing: {localFile.LocalPath}", ConsoleColor.DarkGray); + await sync.PushFilesAsync([localFile], new ProgressReport()); + CommandLine.WriteLine($"Successfully pushed: {localFile.LocalPath}", ConsoleColor.Green); + }); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/VaultsCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs index d6b1492..6413342 100644 --- a/Parallel.Cli/Commands/VaultsCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -73,7 +73,7 @@ public VaultsCommand() : base("vaults", "View or edit the vaults.") fsc.Encrypt = CommandLine.ReadBool("Encrypt files? (y/n)", false); fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); - string profileName = CommandLine.ReadString("Profile Name"); + string? profileName = CommandLine.ReadString("Profile Name"); VaultConfig vault = new VaultConfig(profileName, dbc, fsc); vault.SaveToFile(); diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index b6ed7a0..3c54b91 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -16,7 +16,6 @@ public static async Task Main(string[] args) Settings = ParallelSettings.Load(); string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log"); Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.File(logFile).CreateLogger(); - //Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); Log.Information($"{assembly.Name} [Version {assembly.Version}]"); diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index bc4f8ae..e2687d0 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -7,12 +7,12 @@ namespace Parallel.Cli.Utils { public class CommandLine { - public static string? ReadString(object value, ConsoleColor color = ConsoleColor.Gray) + public static string ReadString(object value, ConsoleColor color = ConsoleColor.Gray) { Console.ForegroundColor = color; Console.Write($"> {value}: "); Console.ResetColor(); - return Console.ReadLine(); + return Console.ReadLine() ?? string.Empty; } public static bool ReadBool(object value, bool defaultValue, ConsoleColor color = ConsoleColor.Gray) diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index a5e1d49..eacc676 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -2,6 +2,7 @@ using Parallel.Core.Diagnostics; using Parallel.Core.Models; +using Parallel.Core.Settings; namespace Parallel.Cli.Utils { diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index db9a423..28017ef 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -55,8 +55,8 @@ public async Task InitializeAsync() public async Task AddFileAsync(SystemFile file) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO files (vault, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV);"; - return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV }) > 0; + string sql = @"INSERT OR REPLACE INTO files (vault, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv, checksum) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV, @CheckSum);"; + return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV, file.CheckSum }) > 0; } /// diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index 9268564..3a6e389 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -17,15 +17,15 @@ namespace Parallel.Core.IO.FileSystem /// public class DotNetFileSystem : IFileSystem { - private readonly FileSystemCredentials _credentials; + private readonly VaultConfig _vault; /// /// Represents an for interacting with physical machine hardware. /// - /// The credentials to log in with. - public DotNetFileSystem(FileSystemCredentials credentials) + /// The vault to use. + public DotNetFileSystem(VaultConfig vault) { - _credentials = credentials; + _vault = vault; } /// @@ -83,7 +83,7 @@ public Task GetDirectoryNameAsync(string path) public Task> GetFilesAsync() { Dictionary files = new Dictionary(); - foreach (string file in Directory.GetFiles(PathBuilder.RootDirectory(_credentials), "*.gz", SearchOption.AllDirectories)) + foreach (string file in Directory.GetFiles(PathBuilder.RootDirectory(_vault), "*.gz", SearchOption.AllDirectories)) { FileInfo fi = new(file); files.Add(fi.FullName, new SystemFile(file) @@ -132,7 +132,7 @@ public Task GetFileAsync(string path) public Task PingAsync() { Stopwatch sw = Stopwatch.StartNew(); - if (!Directory.Exists(PathBuilder.RootDirectory(_credentials))) return Task.FromResult(-1); + if (!Directory.Exists(PathBuilder.RootDirectory(_vault))) return Task.FromResult(-1); return Task.FromResult(sw.ElapsedMilliseconds); } @@ -144,7 +144,7 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres { Stopwatch sw = new Stopwatch(); SystemFile file = files[i]; - file.RemotePath = PathBuilder.Remote(file.LocalPath, _credentials); + file.RemotePath = PathBuilder.Remote(file.LocalPath, _vault); progress.Report(ProgressOperation.Uploading, file, i, files.Length); if (File.Exists(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); diff --git a/Parallel.Core/IO/FileSystem/FileSystemManager.cs b/Parallel.Core/IO/FileSystem/FileSystemManager.cs index 97fa7b5..1e93b4d 100644 --- a/Parallel.Core/IO/FileSystem/FileSystemManager.cs +++ b/Parallel.Core/IO/FileSystem/FileSystemManager.cs @@ -33,13 +33,13 @@ public static class FileSystemManager /// /// Creates a new file system association. /// - /// The credentials needed for the associated file system. - public static IFileSystem CreateNew(FileSystemCredentials credentials) + /// The vault needed for the associated file system. + public static IFileSystem CreateNew(VaultConfig vault) { - return credentials?.Service switch + return vault.FileSystem.Service switch { - FileService.Local => new DotNetFileSystem(credentials), - FileService.Remote => new SftpFileSystem(credentials), + FileService.Local => new DotNetFileSystem(vault), + FileService.Remote => new SftpFileSystem(vault), //FileService.Cloud => new AmazonS3FileSystem(credentials), _ => null }; diff --git a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs index 58ec6cd..3a6a503 100644 --- a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs @@ -18,15 +18,16 @@ namespace Parallel.Core.IO.FileSystem public class SftpFileSystem : IFileSystem { private readonly ConnectionInfo _connectionInfo; + private readonly VaultConfig _vault; /// /// Represents an for interacting with an SSH server. /// - /// The credentials to log in with. - public SftpFileSystem(FileSystemCredentials credentials) + /// The credentials to log in with. + public SftpFileSystem(VaultConfig vault) { - Console.WriteLine(JObject.FromObject(credentials)); - _connectionInfo = new ConnectionInfo(credentials.Address, credentials.Username, new PasswordAuthenticationMethod(credentials.Username, Encryption.Decode(credentials.Password))); + _connectionInfo = new ConnectionInfo(vault.FileSystem.Address, vault.FileSystem.Username, new PasswordAuthenticationMethod(vault.FileSystem.Username, Encryption.Decode(vault.FileSystem.Password))); + _vault = vault; } /// diff --git a/Parallel.Core/IO/PathBuilder.cs b/Parallel.Core/IO/PathBuilder.cs index 6833235..99fe668 100644 --- a/Parallel.Core/IO/PathBuilder.cs +++ b/Parallel.Core/IO/PathBuilder.cs @@ -71,15 +71,15 @@ public static string Local(string path, FileSystemCredentials credentials) return main.Replace(@"\", "/"); } - public static string RootDirectory(FileSystemCredentials credentials) + public static string RootDirectory(VaultConfig vault) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName); + string root = Path.Combine(vault.FileSystem.RootDirectory, "Parallel", vault.Id); Log.Debug($"Root directory: {root}"); - return credentials.Service switch + return vault.FileSystem.Service switch { FileService.Local => root, FileService.Remote => root.Replace('\\', '/'), - _ => null + _ => string.Empty }; } @@ -89,14 +89,14 @@ public static string RootDirectory(FileSystemCredentials credentials) /// /// /// - public static string Remote(string path, FileSystemCredentials credentials) + public static string Remote(string path, VaultConfig vault) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName, path.Replace(":", string.Empty)) + ".gz"; - return credentials.Service switch + string root = Path.Combine(vault.FileSystem.RootDirectory, "Parallel", vault.Id, "Files", path.Replace(":", string.Empty)) + ".gz"; + return vault.FileSystem.Service switch { FileService.Local => root, FileService.Remote => root.Replace('\\', '/'), - _ => null + _ => string.Empty }; } diff --git a/Parallel.Core/IO/Recovery/RecoveryManager.cs b/Parallel.Core/IO/Recovery/RecoveryManager.cs index dfe4e23..43f1ad7 100644 --- a/Parallel.Core/IO/Recovery/RecoveryManager.cs +++ b/Parallel.Core/IO/Recovery/RecoveryManager.cs @@ -29,7 +29,7 @@ public RecoveryManager(VaultConfig vault) { Vault = vault; Database = DatabaseConnection.CreateNew(vault); - FileSystem = FileSystemManager.CreateNew(vault.FileSystem); + FileSystem = FileSystemManager.CreateNew(vault); } public bool Initialize() diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 90374fc..013ae05 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -2,6 +2,8 @@ using System.Data; using System.Diagnostics; +using System.Text; +using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.Backup; using Parallel.Core.Models; @@ -16,19 +18,19 @@ namespace Parallel.Core.IO.Scanning /// public class FileScanner { - private readonly ProfileConfig _profile; + private readonly VaultConfig _vault; private readonly IDatabase _db; - public FileScanner(ProfileConfig profile, IDatabase database) + public FileScanner(VaultConfig vault, IDatabase database) { - _profile = profile; + _vault = vault; _db = database; } - public FileScanner(IBackupManager backup) + public FileScanner(ISyncManager sync) { - _profile = backup.Profile; - _db = backup.Database; + _vault = sync.Vault; + _db = sync.Database; } /*/// @@ -58,77 +60,57 @@ public async Task GetFileChangesAsync(string path, string[] ignore { if (!Directory.Exists(path)) return Array.Empty(); - List scannedFiles = new(); - List localFiles = FileScanner.GetFiles(path, ".", ignoreFolders).ToList(); - List remoteFiles = (await _db.GetFilesAsync(path, false)).ToList(); - Stopwatch sw = Stopwatch.StartNew(); - - foreach (SystemFile rsf in remoteFiles.ToArray()) + List scannedFiles = new List(); + HashSet localFiles = FileScanner.GetFiles(path, ".", ignoreFolders).ToHashSet(); + IEnumerable remoteFiles = await _db.GetFilesAsync(path, false); + foreach (SystemFile remoteFile in remoteFiles) { - // Checks if the local file has a valid path and is part of a backup folder. - if (rsf.LocalPath != null && rsf.LocalPath.Contains(path)) + if (File.Exists(remoteFile.LocalPath) && remoteFile.RemotePath != null) { - // Checks if a LocalFile exists on the current file system. - if (File.Exists(rsf.LocalPath) && rsf.RemotePath != null) + SystemFile localFile = new SystemFile(remoteFile.LocalPath); + if (IsIgnored(localFile.LocalPath, ignoreFolders)) { - SystemFile lfi = new(rsf.LocalPath); - if (IsIgnored(lfi.LocalPath, ignoreFolders)) - { - Log.Debug($"Is ignored -> {lfi.LocalPath}"); - - lfi.Deleted = true; - scannedFiles.Add(lfi); - } - - if (rsf.LastWrite.TotalMilliseconds < lfi.LastWrite.TotalMilliseconds) - { - Log.Debug($"Changed -> {lfi.LocalPath}"); - - // Changed file - rsf.Deleted = false; - scannedFiles.Add(lfi); - } - - localFiles.Remove(lfi.LocalPath); + Log.Debug($"Ignored -> {localFile.LocalPath}"); + localFile.Deleted = true; + scannedFiles.Add(localFile); } - else + else if (HasChanged(localFile, remoteFile)) { - // Adds deleted files - Log.Debug($"Deleted -> {rsf.LocalPath}"); - - rsf.Deleted = true; - scannedFiles.Add(rsf); + Log.Debug($"Changed -> {localFile.LocalPath}"); + scannedFiles.Add(localFile); } + + localFiles.Remove(localFile.LocalPath); } else { - // Deletes ignored files - Log.Debug($"No contains Ignored -> {rsf.LocalPath}"); - - rsf.Deleted = true; - scannedFiles.Add(rsf); + Log.Debug($"Deleted -> {remoteFile.LocalPath}"); + remoteFile.Deleted = true; + scannedFiles.Add(remoteFile); } } Log.Debug($"{localFiles.Count} files are untracked! Adding..."); - if (localFiles.Count > 0) + foreach (var file in localFiles) { - foreach (string file in localFiles.ToArray()) + if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) { - if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) - { - Log.Debug($"Created -> {file}"); - scannedFiles.Add(new SystemFile(file)); - localFiles.Remove(file); - } + Log.Debug($"Created -> {file}"); + scannedFiles.Add(new SystemFile(file)); } } Log.Debug($"{localFiles.Count} files remaining."); - Log.Information($"Found {localFiles.Count.ToString("N0")} files in '{path}'. ({sw.ElapsedMilliseconds}ms)"); + Log.Information($"Found {scannedFiles.Count:N0} changes in '{path}'"); return scannedFiles.ToArray(); } + public static bool HasChanged(SystemFile localFile, SystemFile? remoteFile) + { + return remoteFile == null || (localFile.LastWrite.TotalMilliseconds > remoteFile.LastWrite.TotalMilliseconds && !localFile.CheckSum.SequenceEqual(remoteFile.CheckSum)); + } + + /// /// Gets the total size, in bytes, of a directory. /// diff --git a/Parallel.Core/IO/Syncing/BaseSyncManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs index 9d6933d..c2f3362 100644 --- a/Parallel.Core/IO/Syncing/BaseSyncManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -29,7 +29,7 @@ public abstract class BaseSyncManager : ISyncManager /// public BaseSyncManager(VaultConfig vault) { - FileSystem = FileSystemManager.CreateNew(vault.FileSystem); + FileSystem = FileSystemManager.CreateNew(vault); Vault = vault; } @@ -39,10 +39,10 @@ public virtual bool Initialize() try { Database = DatabaseConnection.CreateNew(Vault); - bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; + FileSystem.CreateDirectoryAsync(PathBuilder.RootDirectory(Vault)); Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); - if (Vault != null) Vault.SaveToFile(); - return fsInit; + Vault.SaveToFile(); + return FileSystem.PingAsync().Result >= 0; } catch (Exception ex) { diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index 806f442..57ecf45 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -32,6 +32,8 @@ public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); Log.Information($"Backing up {backupFiles.Length} files..."); await FileSystem.UploadFilesAsync(backupFiles, progress); + + Console.WriteLine($"Successfully pushed {backupFiles.Length} files.", ConsoleColor.Green); for (int i = 0; i < files.Length; i++) { SystemFile file = files.ElementAt(i); @@ -67,7 +69,7 @@ public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter { SystemFile file = files[i]; Log.Information($"Restoring file: {file.LocalPath}..."); - file.RemotePath = PathBuilder.Remote(file.LocalPath, Vault.FileSystem); + file.RemotePath = PathBuilder.Remote(file.LocalPath, Vault); } } } diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index bc3a562..bb9c834 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -79,5 +79,20 @@ public void ForEachVault(Action action) if (vault != null) action(vault); } } + + public async Task ForEachVaultAsync(Func actionAsync) + { + List tasks = new(); + foreach (string path in Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly)) + { + VaultConfig? vault = VaultConfig.Load(path); + if (vault != null) + { + tasks.Add(actionAsync(vault)); + } + } + + await Task.WhenAll(tasks); + } } } \ No newline at end of file From dea9adc33d3db069cf1c2cf8ebd039e0c9c463e8 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 05:47:24 -0500 Subject: [PATCH 10/33] Changed byte arrays to strings for ease with database querying --- Parallel.Cli/Commands/DecryptCommand.cs | 1 + Parallel.Cli/Commands/EncryptCommand.cs | 4 +-- .../Database/Contexts/SqliteContext.cs | 2 +- Parallel.Core/Models/SystemFile.cs | 30 ++++++++++++++----- Parallel.Core/Security/Encryption.cs | 12 ++++---- Parallel.Core/Security/HashGenerator.cs | 6 ++-- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs index 76bd4ca..b451fba 100644 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ b/Parallel.Cli/Commands/DecryptCommand.cs @@ -2,6 +2,7 @@ using System.CommandLine; using System.Diagnostics; +using System.Text; using Parallel.Cli.Utils; using Parallel.Core.Database; using Parallel.Core.IO; diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs index 36ee33f..f76caf0 100644 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ b/Parallel.Cli/Commands/EncryptCommand.cs @@ -88,8 +88,8 @@ private async Task EncryptFileAsync(string path, string masterKey) await using (FileStream openFile = new FileStream(systemFile.LocalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) await using (FileStream createFile = new FileStream(tempFile, FileMode.OpenOrCreate)) { - systemFile.Salt = HashGenerator.RandomBytes(16); - systemFile.IV = HashGenerator.RandomBytes(16); + systemFile.Salt = HashGenerator.GenerateHash(16); + systemFile.IV = HashGenerator.GenerateHash(16); systemFile.Encrypted = true; Encryption.EncryptStream(openFile, createFile, masterKey, systemFile.LastWrite, systemFile.Salt, systemFile.IV); diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 28017ef..a4c2eb4 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -43,7 +43,7 @@ public async Task InitializeAsync() File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`vault` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB, `iv` BLOB, `checksum` BLOB, PRIMARY KEY(`vault`, `id`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`vault` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` TEXT, `iv` TEXT, `checksum` TEXT, PRIMARY KEY(`vault`, `id`));"); await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`vault` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`vault`, `timestamp`));"); } diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index a18dfed..514abbd 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -81,17 +81,17 @@ public class SystemFile /// /// The salt used to encrypt the file. /// - public byte[] Salt { get; set; } = []; + public string Salt { get; set; } /// /// The initialization vector used to encrypt the file. /// - public byte[] IV { get; set; } = []; + public string IV { get; set; } /// /// The checksum used to check if the file has changed. /// - public byte[] CheckSum { get; set; } = []; + public string? CheckSum { get; set; } /// @@ -112,16 +112,32 @@ public SystemFile(string path) ReadOnly = fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly); Deleted = !fileInfo.Exists; Encrypted = false; - Salt = HashGenerator.RandomBytes(16); - IV = HashGenerator.RandomBytes(16); + Salt = HashGenerator.GenerateHash(16); + IV = HashGenerator.GenerateHash(16); CheckSum = HashGenerator.CheckSum(path); } /// /// Initializes a new instance of the class. /// - /// - public SystemFile(string vault, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, byte[] salt, byte[] iv, byte[] checksum) + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public SystemFile(string vault, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, string salt, string iv, string checksum) { Id = id; Name = name; diff --git a/Parallel.Core/Security/Encryption.cs b/Parallel.Core/Security/Encryption.cs index 3878aba..3a7fc45 100644 --- a/Parallel.Core/Security/Encryption.cs +++ b/Parallel.Core/Security/Encryption.cs @@ -47,14 +47,14 @@ public static string Decode(string value) /// /// /// - public static void EncryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, byte[] salt, byte[] iv) + public static void EncryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, string salt, string iv) { input.Position = 0; - byte[] derivedKey = HashGenerator.HKDF(masterKey, salt, timestamp.ToISOString(), 32); + byte[] derivedKey = HashGenerator.HKDF(masterKey, Encoding.ASCII.GetBytes(salt), timestamp.ToISOString(), 32); using (Aes aes = Aes.Create()) { aes.Key = derivedKey; - aes.IV = iv; + aes.IV = Encoding.UTF8.GetBytes(iv); aes.Mode = CipherMode.CBC; using (CryptoStream cryptoStream = new CryptoStream(output, aes.CreateEncryptor(), CryptoStreamMode.Write)) { @@ -70,14 +70,14 @@ public static void EncryptStream(Stream input, Stream output, string masterKey, /// /// /// - public static void DecryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, byte[] salt, byte[] iv) + public static void DecryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, string salt, string iv) { input.Position = 0; - byte[] derivedKey = HashGenerator.HKDF(masterKey, salt, timestamp.ToISOString(), 32); + byte[] derivedKey = HashGenerator.HKDF(masterKey, Encoding.ASCII.GetBytes(salt), timestamp.ToISOString(), 32); using (Aes aes = Aes.Create()) { aes.Key = derivedKey; - aes.IV = iv; + aes.IV = Encoding.ASCII.GetBytes(iv); aes.Mode = CipherMode.CBC; using (CryptoStream cryptoStream = new CryptoStream(input, aes.CreateDecryptor(), CryptoStreamMode.Read)) { diff --git a/Parallel.Core/Security/HashGenerator.cs b/Parallel.Core/Security/HashGenerator.cs index eccf200..71fa07f 100644 --- a/Parallel.Core/Security/HashGenerator.cs +++ b/Parallel.Core/Security/HashGenerator.cs @@ -87,12 +87,12 @@ public static string CreateSHA256(string value) /// /// /// - public static byte[] CheckSum(string path) + public static string? CheckSum(string path) { - if(!File.Exists(path)) return []; + if (!File.Exists(path)) return null; using FileStream fs = File.OpenRead(path); using SHA256 sha256 = SHA256.Create(); - return sha256.ComputeHash(fs); + return Convert.ToHexString(sha256.ComputeHash(fs)).ToLowerInvariant(); } } } \ No newline at end of file From 73c9f89dbb99dc4d56522aa43461b9f7400b6c16 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Fri, 29 Aug 2025 01:19:27 -0500 Subject: [PATCH 11/33] Various async changes --- Parallel.Cli/Commands/HistoryCommand.cs | 139 ++++++++++++++++++ Parallel.Cli/Commands/PullCommand.cs | 9 ++ Parallel.Cli/Commands/PushCommand.cs | 11 +- .../Database/Contexts/SqliteContext.cs | 10 ++ Parallel.Core/Database/DatabaseConnection.cs | 4 +- Parallel.Core/Database/IDatabase.cs | 4 + .../IO/FileSystem/DotNetFileSystem.cs | 6 +- Parallel.Core/Models/HistoryEvent.cs | 8 +- Parallel.Core/Settings/DatabaseCredentials.cs | 4 +- .../Settings/FileSystemCredentials.cs | 6 +- 10 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 Parallel.Cli/Commands/HistoryCommand.cs create mode 100644 Parallel.Cli/Commands/PullCommand.cs diff --git a/Parallel.Cli/Commands/HistoryCommand.cs b/Parallel.Cli/Commands/HistoryCommand.cs new file mode 100644 index 0000000..eef71c2 --- /dev/null +++ b/Parallel.Cli/Commands/HistoryCommand.cs @@ -0,0 +1,139 @@ +// Copyright 2025 Entex Interactive, LLC + +using System.CommandLine; +using System.Data; +using Parallel.Cli.Utils; +using Parallel.Core.Database; +using Parallel.Core.IO; +using Parallel.Core.IO.Backup; +using Parallel.Core.Models; +using Parallel.Core.Settings; +using Parallel.Core.Utils; +using Formatter = Parallel.Cli.Utils.Formatter; + +namespace Parallel.Cli.Commands +{ + public class HistoryCommand : Command + { + private const int Limit = 25; + + private Command _pullCmd = new("pull", "Shows the history related to pulling files from vaults."); + private Command _pushCmd = new("push", "Shows the history related to pushing files from vaults."); + private Command _deleteCmd = new("archive", "Shows the history related to file deletions."); + private Command _cleanCmd = new("cleaned", "Shows the history related to file cleaning."); + private Command _cloneCmd = new("cloned", "Shows the history related to file cloning."); + private Command _pruneCmd = new("pruned", "Shows the history related to file pruning."); + + private Option _sourceOpt = new(["--path", "-p"], "The source path."); + private Option _vaultOpt = new(["--vault", "-v"], "The vault to use."); + private Option _limitOpt = new(["--limit", "-l"], "The number of entries to show."); + + public HistoryCommand() : base("history", "Shows the history of files related to the archive.") + { + this.AddOption(_sourceOpt); + this.AddOption(_vaultOpt); + this.AddOption(_limitOpt); + this.AddCommand(_pullCmd); + this.AddCommand(_pushCmd); + this.AddCommand(_deleteCmd); + this.AddCommand(_cleanCmd); + this.AddCommand(_cloneCmd); + this.AddCommand(_pruneCmd); + this.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _pushCmd.AddOption(_sourceOpt); + _pushCmd.AddOption(_vaultOpt); + _pushCmd.AddOption(_limitOpt); + _pushCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Pushed, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _deleteCmd.AddOption(_sourceOpt); + _deleteCmd.AddOption(_vaultOpt); + _deleteCmd.AddOption(_limitOpt); + _deleteCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving archive information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Archived, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _cleanCmd.AddOption(_sourceOpt); + _cleanCmd.AddOption(_vaultOpt); + _cleanCmd.AddOption(_limitOpt); + _cleanCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving clean information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Cleaned, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _cloneCmd.AddOption(_sourceOpt); + _cloneCmd.AddOption(_vaultOpt); + _cloneCmd.AddOption(_limitOpt); + _cloneCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving clone information...", ConsoleColor.DarkGray); + IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Cloned, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _pruneCmd.AddOption(_sourceOpt); + _pruneCmd.AddOption(_vaultOpt); + _pruneCmd.AddOption(_limitOpt); + _pruneCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving prune information...", ConsoleColor.DarkGray); + IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Pruned, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _pullCmd.AddOption(_sourceOpt); + _pullCmd.AddOption(_vaultOpt); + _pullCmd.AddOption(_limitOpt); + _pullCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving restore information...", ConsoleColor.DarkGray); + IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Pulled, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + } + + private void DisplayHistories(HistoryEvent[]? histories) + { + if (histories?.Length == 0) + { + CommandLine.WriteWarning("No backup history found!"); + return; + } + + foreach (HistoryEvent history in histories.ToArray()) + { + string typeStr = (history.Type + ":").PadRight(9); + CommandLine.WriteLine($"[{Formatter.FromDateTime(history.CreatedAt.ToLocalTime())}] <{history.Vault}> {typeStr} {history.Fullname}", ConsoleColor.White); + } + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/PullCommand.cs b/Parallel.Cli/Commands/PullCommand.cs new file mode 100644 index 0000000..23d40ba --- /dev/null +++ b/Parallel.Cli/Commands/PullCommand.cs @@ -0,0 +1,9 @@ +// Copyright 2025 Kyle Ebbinga + +namespace Parallel.Cli.Commands +{ + public class PullCommand + { + + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index bfec12c..8c97bfb 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -88,13 +88,13 @@ await Program.Settings.ForEachVaultAsync(async (vault) => CommandLine.WriteLine($"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); await sync.PushFilesAsync(files, new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files.", ConsoleColor.Green); + CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); }); } private async Task SyncFileAsync(string path) { - await Program.Settings.ForEachVaultAsync(async (vault) => + await Program.Settings.ForEachVaultAsync(async vault => { ISyncManager sync = SyncManager.CreateNew(vault); if (!sync.Initialize()) @@ -104,15 +104,13 @@ await Program.Settings.ForEachVaultAsync(async (vault) => } string[] backupFolders = vault.BackupDirectories.ToArray(); + string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); if (!backupFolders.Any(path.StartsWith)) { CommandLine.WriteWarning($"The provided file is not set to be backed up!"); return; } - string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); - FileScanner scanner = new FileScanner(sync); - // Checks if the file can be backed up. if (FileScanner.IsIgnored(path, ignoredFolders)) { @@ -122,7 +120,6 @@ await Program.Settings.ForEachVaultAsync(async (vault) => SystemFile localFile = new SystemFile(path); SystemFile? remoteFile = await sync.Database.GetFileAsync(localFile.LocalPath); - if (!FileScanner.HasChanged(localFile, remoteFile)) { CommandLine.WriteLine($"The provided file is already up to date.", ConsoleColor.Green); @@ -131,7 +128,7 @@ await Program.Settings.ForEachVaultAsync(async (vault) => CommandLine.WriteLine($"Pushing: {localFile.LocalPath}", ConsoleColor.DarkGray); await sync.PushFilesAsync([localFile], new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed: {localFile.LocalPath}", ConsoleColor.Green); + CommandLine.WriteLine($"Successfully pushed '{localFile.LocalPath}' to '{vault.FileSystem.Address}'.", ConsoleColor.Green); }); } } diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index a4c2eb4..d0b4c2c 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -87,6 +87,16 @@ public async Task AddHistoryAsync(string path, HistoryType type) return await connection.ExecuteAsync(sql, new { ProfileId, Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; } + public IEnumerable? GetHistory(string path, int limit) + { + throw new NotImplementedException(); + } + + public IEnumerable? GetHistory(string path, HistoryType type, int limit) + { + throw new NotImplementedException(); + } + #endregion } } \ No newline at end of file diff --git a/Parallel.Core/Database/DatabaseConnection.cs b/Parallel.Core/Database/DatabaseConnection.cs index c65f6cd..3fd19f0 100644 --- a/Parallel.Core/Database/DatabaseConnection.cs +++ b/Parallel.Core/Database/DatabaseConnection.cs @@ -17,9 +17,9 @@ public enum DatabaseProvider /// public class DatabaseConnection { - public static IDatabase CreateNew(VaultConfig vault) + public static IDatabase? CreateNew(VaultConfig? vault) { - switch(vault.Database.Provider) + switch(vault?.Database.Provider) { default: return null; diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index c39e5f8..65fea25 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -91,6 +91,10 @@ public interface IDatabase /// True if successful, false otherwise Task AddHistoryAsync(string path, HistoryType type); + IEnumerable? GetHistory(string path, int limit); + + IEnumerable? GetHistory(string path, HistoryType type, int limit); + #endregion Task> GetFilesAsync(string path, bool deleted); diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index 3a6e389..941d721 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -140,13 +140,11 @@ public Task PingAsync() public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { if (!files.Any()) return; - for (int i = 0; i < files.Length; i++) + await Task.WhenAll(files.Select(file => Task.Run(async () => { Stopwatch sw = new Stopwatch(); - SystemFile file = files[i]; file.RemotePath = PathBuilder.Remote(file.LocalPath, _vault); - progress.Report(ProgressOperation.Uploading, file, i, files.Length); if (File.Exists(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); string parent = Path.GetDirectoryName(file.RemotePath); if (!Directory.Exists(parent)) Directory.CreateDirectory(parent); @@ -158,7 +156,7 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); - } + }))); } } } \ No newline at end of file diff --git a/Parallel.Core/Models/HistoryEvent.cs b/Parallel.Core/Models/HistoryEvent.cs index 320c483..e8a4d49 100644 --- a/Parallel.Core/Models/HistoryEvent.cs +++ b/Parallel.Core/Models/HistoryEvent.cs @@ -1,9 +1,15 @@ // Copyright 2025 Kyle Ebbinga +using Parallel.Core.Database; +using Parallel.Core.Utils; + namespace Parallel.Core.Models { public class HistoryEvent { - + public HistoryType Type { get; set; } + public UnixTime CreatedAt { get; set; } + public string Vault { get; set; } + public string Fullname { get; set; } } } \ No newline at end of file diff --git a/Parallel.Core/Settings/DatabaseCredentials.cs b/Parallel.Core/Settings/DatabaseCredentials.cs index 306c45a..761427b 100644 --- a/Parallel.Core/Settings/DatabaseCredentials.cs +++ b/Parallel.Core/Settings/DatabaseCredentials.cs @@ -24,12 +24,12 @@ public class DatabaseCredentials /// /// The username of the database. /// - public string Username { get; set; } = string.Empty; + public string? Username { get; set; } /// /// The password of the database. /// - public string Password { get; set; } = string.Empty; + public string? Password { get; set; } /// /// The database name. diff --git a/Parallel.Core/Settings/FileSystemCredentials.cs b/Parallel.Core/Settings/FileSystemCredentials.cs index db02bf1..8000bf9 100644 --- a/Parallel.Core/Settings/FileSystemCredentials.cs +++ b/Parallel.Core/Settings/FileSystemCredentials.cs @@ -13,9 +13,9 @@ public class FileSystemCredentials { public FileService Service { get; set; } = FileService.Local; public string RootDirectory { get; set; } = string.Empty; - public string Address { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; + public string? Address { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } /// /// If the file system is encrypting files. From 4cd81e79a34b7db6b64a2f9be31f100b63530bd8 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:43:40 -0500 Subject: [PATCH 12/33] Optimized for pushing to multiple vaults --- Parallel.Cli/Commands/HistoryCommand.cs | 2 +- Parallel.Cli/Commands/PushCommand.cs | 89 +++++-------------- Parallel.Cli/Utils/CommandLine.cs | 65 ++++++++++---- Parallel.Cli/Utils/ProgressReport.cs | 6 +- .../{ProgressDebug.cs => ProgressLogger.cs} | 2 +- Parallel.Core/IO/Scanning/FileScanner.cs | 11 ++- Parallel.Core/IO/Syncing/ISyncManager.cs | 3 +- Parallel.Core/IO/Syncing/SyncManager.cs | 1 + Parallel.Core/Settings/ParallelSettings.cs | 28 ++++-- Parallel.Core/Settings/VaultConfig.cs | 1 + 10 files changed, 110 insertions(+), 98 deletions(-) rename Parallel.Core/Diagnostics/{ProgressDebug.cs => ProgressLogger.cs} (95%) diff --git a/Parallel.Cli/Commands/HistoryCommand.cs b/Parallel.Cli/Commands/HistoryCommand.cs index eef71c2..eabd7cb 100644 --- a/Parallel.Cli/Commands/HistoryCommand.cs +++ b/Parallel.Cli/Commands/HistoryCommand.cs @@ -125,7 +125,7 @@ private void DisplayHistories(HistoryEvent[]? histories) { if (histories?.Length == 0) { - CommandLine.WriteWarning("No backup history found!"); + CommandLine.WriteLine("No backup history found!", ConsoleColor.Yellow); return; } diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index 8c97bfb..7d7961c 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -5,7 +5,9 @@ using Parallel.Core.IO; using Parallel.Core.IO.Backup; using Parallel.Core.IO.Scanning; +using Parallel.Core.IO.Syncing; using Parallel.Core.Models; +using Parallel.Core.Settings; namespace Parallel.Cli.Commands { @@ -19,7 +21,7 @@ public class PushCommand : Command private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); - public PushCommand() : base("push", "Pushes files to ") + public PushCommand() : base("push", "Pushes changed files to vaults.") { this.AddOption(_sourceArg); this.AddOption(_configOpt); @@ -32,15 +34,9 @@ public PushCommand() : base("push", "Pushes files to ") } else { - if (PathBuilder.IsDirectory(path)) - { - await SyncDirectoryAsync(path); - } - else if (PathBuilder.IsFile(path)) - { - await SyncFileAsync(path); - } + await SyncPathAsync(path); } + }, _sourceArg, _configOpt, _verboseOpt); } @@ -49,86 +45,49 @@ private async Task SyncSystemAsync() throw new NotImplementedException(); } - private async Task SyncDirectoryAsync(string path) + private async Task SyncPathAsync(string path) { - await Program.Settings.ForEachVaultAsync(async (vault) => + await ParallelSettings.ForEachVaultAsync(async vault => { ISyncManager sync = SyncManager.CreateNew(vault); if (!sync.Initialize()) { - CommandLine.WriteError($"Failed to connect to vault '{vault.Name}'!"); + CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); return; } + // Normalize paths for safe comparison + string fullPath = Path.GetFullPath(path); string[] backupFolders = vault.BackupDirectories.ToArray(); - if (!backupFolders.Any(path.StartsWith)) - { - CommandLine.WriteWarning($"The provided folder is not set to be backed up!"); - return; - } - string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); - FileScanner scanner = new FileScanner(sync); - // Checks if the file can be backed up. - if (FileScanner.IsIgnored(path, ignoredFolders)) + bool isFile = PathBuilder.IsFile(fullPath); + if (!backupFolders.Any(dir => fullPath.StartsWith(dir, StringComparison.OrdinalIgnoreCase))) { - CommandLine.WriteWarning($"The provided folder is set to be ignored!"); + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is not set to be backed up!", ConsoleColor.Yellow); return; } - CommandLine.WriteLine($"Scanning for file changes in {path}...", ConsoleColor.DarkGray); - SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); - int successFiles = files.Length; - if (successFiles == 0) + if (FileScanner.IsIgnored(fullPath, ignoredFolders)) { - CommandLine.WriteLine($"The provided directory is already up to date.", ConsoleColor.Green); + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is set to be ignored!", ConsoleColor.Yellow); return; } - CommandLine.WriteLine($"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); - await sync.PushFilesAsync(files, new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); - }); - } + CommandLine.WriteLine(vault, $"Scanning for file changes in {path}...", ConsoleColor.DarkGray); - private async Task SyncFileAsync(string path) - { - await Program.Settings.ForEachVaultAsync(async vault => - { - ISyncManager sync = SyncManager.CreateNew(vault); - if (!sync.Initialize()) - { - CommandLine.WriteError("Failed to connect to backup file system!"); - return; - } - - string[] backupFolders = vault.BackupDirectories.ToArray(); - string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); - if (!backupFolders.Any(path.StartsWith)) - { - CommandLine.WriteWarning($"The provided file is not set to be backed up!"); - return; - } - - // Checks if the file can be backed up. - if (FileScanner.IsIgnored(path, ignoredFolders)) - { - CommandLine.WriteWarning($"The provided folder is set to be ignored!"); - return; - } - - SystemFile localFile = new SystemFile(path); - SystemFile? remoteFile = await sync.Database.GetFileAsync(localFile.LocalPath); - if (!FileScanner.HasChanged(localFile, remoteFile)) + FileScanner scanner = new FileScanner(sync); + SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); + int successFiles = files.Length; + if (successFiles == 0) { - CommandLine.WriteLine($"The provided file is already up to date.", ConsoleColor.Green); + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is already up to date.", ConsoleColor.Green); return; } - CommandLine.WriteLine($"Pushing: {localFile.LocalPath}", ConsoleColor.DarkGray); - await sync.PushFilesAsync([localFile], new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed '{localFile.LocalPath}' to '{vault.FileSystem.Address}'.", ConsoleColor.Green); + CommandLine.WriteLine(vault, $"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); + await sync.PushFilesAsync(files, new ProgressReport(vault)); + CommandLine.WriteLine(vault, $"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); }); } } diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index e2687d0..049cc89 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -1,12 +1,15 @@ // Copyright 2025 Kyle Ebbinga using System.Text; +using Parallel.Core.Settings; using Parallel.Core.Utils; namespace Parallel.Cli.Utils { public class CommandLine { + private static readonly object _consoleLock = new(); + public static string ReadString(object value, ConsoleColor color = ConsoleColor.Gray) { Console.ForegroundColor = color; @@ -71,28 +74,56 @@ public static void Write(object value, ConsoleColor color = ConsoleColor.Gray) Console.ResetColor(); } - public static void WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) + public static void WriteLine(VaultConfig vault, object value, ConsoleColor color = ConsoleColor.Gray) { - Log.Information(value.ToString()!); - Console.ForegroundColor = color; - Console.WriteLine($"> {value}"); - Console.ResetColor(); - } + string baseLog = $"[{vault.Id}] {value}"; + switch(color) + { + default: + Log.Information(baseLog); + break; - public static void WriteWarning(object value) - { - Log.Warning(value.ToString()!); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"> {value}"); - Console.ResetColor(); + case ConsoleColor.Yellow: + Log.Warning(baseLog); + break; + + case ConsoleColor.Red: + Log.Error(baseLog); + break; + } + + lock (_consoleLock) + { + Console.ForegroundColor = color; + Console.WriteLine($"> {baseLog}"); + Console.ResetColor(); + } } - public static void WriteError(object value) + public static void WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) { - Log.Error(value.ToString()!); - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"> {value}"); - Console.ResetColor(); + string baseLog = $"{value}"; + switch(color) + { + default: + Log.Information(baseLog); + break; + + case ConsoleColor.Yellow: + Log.Warning(baseLog); + break; + + case ConsoleColor.Red: + Log.Error(baseLog); + break; + } + + lock (_consoleLock) + { + Console.ForegroundColor = color; + Console.WriteLine($"> {baseLog}"); + Console.ResetColor(); + } } public static void ProgressBar(double part, double total, TimeSpan elapsed, ConsoleColor color = ConsoleColor.Gray) diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index eacc676..9710575 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -6,17 +6,17 @@ namespace Parallel.Cli.Utils { - public class ProgressReport : IProgressReporter + public class ProgressReport(VaultConfig vault) : IProgressReporter { public void Report(ProgressOperation operation, SystemFile file, int current, int total) { int percent = current * 100 / total; - CommandLine.WriteLine($"[{percent}%] {operation}: {file.LocalPath}"); + CommandLine.WriteLine($"[{percent}%] <{vault.Id}> {operation}: {file.LocalPath}"); } public void Failed(Exception exception, SystemFile file) { - CommandLine.WriteLine($"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); + CommandLine.WriteLine(vault, $"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); } } } \ No newline at end of file diff --git a/Parallel.Core/Diagnostics/ProgressDebug.cs b/Parallel.Core/Diagnostics/ProgressLogger.cs similarity index 95% rename from Parallel.Core/Diagnostics/ProgressDebug.cs rename to Parallel.Core/Diagnostics/ProgressLogger.cs index 9ca0b6e..c624e39 100644 --- a/Parallel.Core/Diagnostics/ProgressDebug.cs +++ b/Parallel.Core/Diagnostics/ProgressLogger.cs @@ -7,7 +7,7 @@ namespace Parallel.Core.Diagnostics /// /// Represents a basic progress report debugger. /// - public class ProgressDebug : IProgressReporter + public class ProgressLogger : IProgressReporter { private ProgressOperation currentOperation; private int progressPercentage; diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 013ae05..699af81 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.Backup; +using Parallel.Core.IO.Syncing; using Parallel.Core.Models; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -105,6 +106,12 @@ public async Task GetFileChangesAsync(string path, string[] ignore return scannedFiles.ToArray(); } + /// + /// Gets if a file has changed. + /// + /// The base file to compare. + /// The remote file to compare to. + /// True is success, otherwise false. public static bool HasChanged(SystemFile localFile, SystemFile? remoteFile) { return remoteFile == null || (localFile.LastWrite.TotalMilliseconds > remoteFile.LastWrite.TotalMilliseconds && !localFile.CheckSum.SequenceEqual(remoteFile.CheckSum)); @@ -207,7 +214,7 @@ public static FileInfo[] GetCleanableFiles(string path, UnixTime start, bool rec public static IEnumerable GetFiles(string root, string searchPattern) { - return GetFiles(root, searchPattern, Array.Empty()); + return GetFiles(root, searchPattern, []); } public static IEnumerable GetFiles(string root, string searchPattern, string[] exempt) @@ -217,7 +224,7 @@ public static IEnumerable GetFiles(string root, string searchPattern, st while (pending.Count != 0) { string path = pending.Pop(); - IEnumerable next = null; + IEnumerable? next = null; try { if (!IsIgnored(path, exempt)) diff --git a/Parallel.Core/IO/Syncing/ISyncManager.cs b/Parallel.Core/IO/Syncing/ISyncManager.cs index d634a85..21a5641 100644 --- a/Parallel.Core/IO/Syncing/ISyncManager.cs +++ b/Parallel.Core/IO/Syncing/ISyncManager.cs @@ -2,12 +2,11 @@ using Parallel.Core.Database; using Parallel.Core.Diagnostics; -using Parallel.Core.Events; using Parallel.Core.IO.FileSystem; using Parallel.Core.Models; using Parallel.Core.Settings; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// /// Defines the methods needed for backing up a file system. diff --git a/Parallel.Core/IO/Syncing/SyncManager.cs b/Parallel.Core/IO/Syncing/SyncManager.cs index f5b4655..4b5124f 100644 --- a/Parallel.Core/IO/Syncing/SyncManager.cs +++ b/Parallel.Core/IO/Syncing/SyncManager.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Parallel.Core.IO.Syncing; namespace Parallel.Core.IO.Backup { diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index bb9c834..d323473 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -80,17 +80,31 @@ public void ForEachVault(Action action) } } - public async Task ForEachVaultAsync(Func actionAsync) + /// + /// Asynchronously runs an for each with a default of 3 at a time. + /// + /// + /// + public static async Task ForEachVaultAsync(Func actionAsync, int maxDegreeOfParallelism = 3) { - List tasks = new(); - foreach (string path in Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly)) + string[] vaultPaths = Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly); + SemaphoreSlim semaphore = new SemaphoreSlim(maxDegreeOfParallelism); + IEnumerable tasks = vaultPaths.Select(path => Task.Run(async () => { - VaultConfig? vault = VaultConfig.Load(path); - if (vault != null) + await semaphore.WaitAsync(); + try { - tasks.Add(actionAsync(vault)); + VaultConfig? vault = VaultConfig.Load(path); + if (vault != null) + { + await actionAsync(vault); + } } - } + finally + { + semaphore.Release(); + } + })); await Task.WhenAll(tasks); } diff --git a/Parallel.Core/Settings/VaultConfig.cs b/Parallel.Core/Settings/VaultConfig.cs index 60f2b84..c1a480d 100644 --- a/Parallel.Core/Settings/VaultConfig.cs +++ b/Parallel.Core/Settings/VaultConfig.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; using Parallel.Core.Security; using Parallel.Core.Utils; From 8b5d7b3f2d4c1551b41a308a68474307786cb0ed Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:46:33 -0500 Subject: [PATCH 13/33] Moved service to separate branch --- Parallel.Core.Net/Communication.cs | 70 ---------- Parallel.Core.Net/Connections/IConnection.cs | 10 -- .../Connections/TcpConnection.cs | 88 ------------- Parallel.Core.Net/MessageResult.cs | 8 -- Parallel.Core.Net/Parallel.Core.Net.csproj | 18 --- Parallel.Core.Net/ServerRequest.cs | 31 ----- Parallel.Core.Net/ServerResponse.cs | 41 ------ Parallel.Core.Net/Sockets/ISocketHandler.cs | 42 ------ Parallel.Core.Net/Sockets/TcpSocketHandler.cs | 82 ------------ Parallel.Service/Parallel.Service.csproj | 40 ------ Parallel.Service/Program.cs | 66 ---------- Parallel.Service/RequestHandler.cs | 76 ----------- Parallel.Service/Requests/BaseRequest.cs | 63 --------- Parallel.Service/Requests/HelpRequest.cs | 50 ------- Parallel.Service/Requests/IRequest.cs | 19 --- Parallel.Service/Requests/PingRequest.cs | 14 -- Parallel.Service/Responses/ErrorResponse.cs | 16 --- Parallel.Service/Responses/IResponse.cs | 9 -- Parallel.Service/Responses/MessageResponse.cs | 16 --- Parallel.Service/Responses/ObjectResponse.cs | 16 --- .../Services/FileBackupService.cs | 14 -- .../Services/FileCleanupService.cs | 14 -- Parallel.Service/Services/LoggingService.cs | 28 ---- .../Services/TcpRequestService.cs | 122 ------------------ Parallel.Service/Utils/UdpReporting.cs | 24 ---- Parallel.Service/parallel-red.ico | Bin 180837 -> 0 bytes 26 files changed, 977 deletions(-) delete mode 100644 Parallel.Core.Net/Communication.cs delete mode 100644 Parallel.Core.Net/Connections/IConnection.cs delete mode 100644 Parallel.Core.Net/Connections/TcpConnection.cs delete mode 100644 Parallel.Core.Net/MessageResult.cs delete mode 100644 Parallel.Core.Net/Parallel.Core.Net.csproj delete mode 100644 Parallel.Core.Net/ServerRequest.cs delete mode 100644 Parallel.Core.Net/ServerResponse.cs delete mode 100644 Parallel.Core.Net/Sockets/ISocketHandler.cs delete mode 100644 Parallel.Core.Net/Sockets/TcpSocketHandler.cs delete mode 100644 Parallel.Service/Parallel.Service.csproj delete mode 100644 Parallel.Service/Program.cs delete mode 100644 Parallel.Service/RequestHandler.cs delete mode 100644 Parallel.Service/Requests/BaseRequest.cs delete mode 100644 Parallel.Service/Requests/HelpRequest.cs delete mode 100644 Parallel.Service/Requests/IRequest.cs delete mode 100644 Parallel.Service/Requests/PingRequest.cs delete mode 100644 Parallel.Service/Responses/ErrorResponse.cs delete mode 100644 Parallel.Service/Responses/IResponse.cs delete mode 100644 Parallel.Service/Responses/MessageResponse.cs delete mode 100644 Parallel.Service/Responses/ObjectResponse.cs delete mode 100644 Parallel.Service/Services/FileBackupService.cs delete mode 100644 Parallel.Service/Services/FileCleanupService.cs delete mode 100644 Parallel.Service/Services/LoggingService.cs delete mode 100644 Parallel.Service/Services/TcpRequestService.cs delete mode 100644 Parallel.Service/Utils/UdpReporting.cs delete mode 100644 Parallel.Service/parallel-red.ico diff --git a/Parallel.Core.Net/Communication.cs b/Parallel.Core.Net/Communication.cs deleted file mode 100644 index eb9f480..0000000 --- a/Parallel.Core.Net/Communication.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using System.Net.Sockets; -using System.Text; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Parallel.Core.Events; -using Parallel.Core.Utils; - -namespace Parallel.Core.Net -{ - /// - /// Represents UDP communication between services. - /// - public class Communication - { - private readonly CancellationTokenSource _exit = new(); - private bool _active; - - /// - /// The primary client for network communication. - /// - public UdpClient Client { get; } = new UdpClient(); - - public event EventHandler RecievedMessage; - - public Communication() - { - Client = new UdpClient(); - } - - public Communication(int port) - { - Client = new UdpClient(new IPEndPoint(IPAddress.Any, port)); - } - - /// - /// Starts listening for messages. - /// - public async Task Start() - { - _active = true; - while (_active && !_exit.IsCancellationRequested) - { - UdpReceiveResult result = await Client.ReceiveAsync(_exit.Token); - RecievedMessage?.Invoke(this, new MessageRecievedEventArgs(result)); - } - } - - /// - /// Stops listening for messages. - /// - public void Stop() - { - _active = false; - _exit.Cancel(); - } - - /// - /// Sends a message to a specified port on a specified remote host. - /// - /// - /// - public void Send(string message, IPEndPoint endPoint) - { - Client.Send(Encoding.UTF8.GetBytes(Encryption.Encode(message)), endPoint); - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Connections/IConnection.cs b/Parallel.Core.Net/Connections/IConnection.cs deleted file mode 100644 index b87b4e4..0000000 --- a/Parallel.Core.Net/Connections/IConnection.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Core.Net.Connections -{ - public interface IConnection - { - ServerResponse SendRequest(ServerRequest request); - //Task SendRequestAsync(ServerRequest request); - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Connections/TcpConnection.cs b/Parallel.Core.Net/Connections/TcpConnection.cs deleted file mode 100644 index 7e75d9c..0000000 --- a/Parallel.Core.Net/Connections/TcpConnection.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net.Sockets; -using System.Text; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Parallel.Core.Utils; -using Serilog; -using Serilog.Core; - -namespace Parallel.Core.Net.Connections -{ - public class TcpConnection : IConnection - { - private readonly string _address; - private readonly int _port; - - /// - /// Initializes a new instance of the class with the saved settings. - /// - public TcpConnection() - { - _address = "127.0.0.1"; - _port = 8192; - } - - /// - /// Initializes a new instance of the class with a address and port. - /// - public TcpConnection(string address, int port) - { - _address = address; - _port = port; - } - - public ServerResponse SendRequest(ServerRequest request) - { - Socket socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.DontLinger, true); - ServerResponse response = new(request); - - try - { - socket.Connect(_address, _port); - if (socket.Connected) - { - // Sends an encrypted json request to the server. - string rawJson = JsonConvert.SerializeObject(request) + ";"; - Log.Debug($"Sending request: '{rawJson}'"); - socket.Send(Encoding.UTF8.GetBytes(rawJson)); - - // The encrypted returned json - string returnedData = string.Empty; - using (NetworkStream ns = new(socket)) - { - while (!returnedData.EndsWith(';')) - { - Console.WriteLine("Waiting for response..."); - byte[] buffer = new byte[socket.ReceiveBufferSize]; - int bytesRead = ns.Read(buffer, 0, socket.ReceiveBufferSize); - returnedData += Encoding.UTF8.GetString(buffer, 0, bytesRead); - } - } - - Log.Debug($"Response: {returnedData}"); - JToken? json = JToken.Parse(returnedData.TrimEnd(';')); - response = ServerResponse.Parse(request, json); - - // Closes the socket. - socket.Shutdown(SocketShutdown.Both); - socket.Close(); - return response; - } - else - { - Log.Warning($"Failed to connect to server '{_address}:{_port}'"); - return response; - } - } - catch (Exception ex) - { - Log.Warning(ex.Message); - return response; - } - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/MessageResult.cs b/Parallel.Core.Net/MessageResult.cs deleted file mode 100644 index 0bd428a..0000000 --- a/Parallel.Core.Net/MessageResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Core.Net -{ - public struct MessageResult - { - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Parallel.Core.Net.csproj b/Parallel.Core.Net/Parallel.Core.Net.csproj deleted file mode 100644 index be6daf4..0000000 --- a/Parallel.Core.Net/Parallel.Core.Net.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - diff --git a/Parallel.Core.Net/ServerRequest.cs b/Parallel.Core.Net/ServerRequest.cs deleted file mode 100644 index 932865c..0000000 --- a/Parallel.Core.Net/ServerRequest.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json; - -namespace Parallel.Core.Net -{ - public class ServerRequest - { - /// - /// The request name. - /// - public string Name { get; } - - /// - /// The request parameters. - /// - public Dictionary Parameters { get; } - - /// - /// Initializes new instance of the class with a request name and a of parameters. - /// - /// The request name. - /// A collection of parameter keys and values. - [JsonConstructor] - public ServerRequest(string name, Dictionary parameters) - { - Name = name.ToLower(); - Parameters = parameters; - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/ServerResponse.cs b/Parallel.Core.Net/ServerResponse.cs deleted file mode 100644 index 103438c..0000000 --- a/Parallel.Core.Net/ServerResponse.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json.Linq; - -namespace Parallel.Core.Net.Connections -{ - public class ServerResponse - { - public ServerRequest Request { get; } - public int StatusCode { get; } - public bool Success { get; } = false; - public JToken? Data { get; } - public string? Message { get; } - public string? Error { get; } - - public ServerResponse(ServerRequest request) - { - Request = request; - } - - private ServerResponse(ServerRequest request, JToken? data, string? message, string? error, int statusCode) - { - Request = request; - StatusCode = statusCode; - Success = statusCode == 200; - Data = data; - Message = message; - Error = error; - } - - public static ServerResponse Parse(ServerRequest request, JToken? json) - { - int statusCode = json?["status"]?.Value() ?? 408; - JToken? data = json?["data"]; - string? message = json?.Value("message"); - string? error = json?.Value("error"); - - return new ServerResponse(request, data, message, error, statusCode); - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Sockets/ISocketHandler.cs b/Parallel.Core.Net/Sockets/ISocketHandler.cs deleted file mode 100644 index 08398c3..0000000 --- a/Parallel.Core.Net/Sockets/ISocketHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using Parallel.Core.Utils; - -namespace Parallel.Core.Net.Sockets -{ - public interface ISocketHandler - { - /// - /// The time the socket was received. - /// - UnixTime ReceivedAt { get; } - - /// - /// The raw string of incoming data decrypted. - /// - string RawData { get; set; } - - /// - /// The remote client that sent the request. - /// - IPEndPoint RemoteEndPoint { get; } - - /// - /// Shuts down the , closes the connection, and releases all resources. - /// - void Close(); - - /// - /// Reads the incoming encrypted data as formatted JSON string. - /// - /// - ServerRequest? Parse(); - - /// - /// Responds to the current request. - /// - /// - Task RespondAsync(object? data); - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Sockets/TcpSocketHandler.cs b/Parallel.Core.Net/Sockets/TcpSocketHandler.cs deleted file mode 100644 index de68a70..0000000 --- a/Parallel.Core.Net/Sockets/TcpSocketHandler.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using System.Net.Sockets; -using System.Text; -using Newtonsoft.Json; -using Parallel.Core.Utils; -using Serilog; -using Serilog.Core; - -namespace Parallel.Core.Net.Sockets -{ - public class TcpSocketHandler : ISocketHandler - { - /// - /// - /// - public Socket Socket { get; } - - /// - public UnixTime ReceivedAt { get; } - - /// - public string RawData { get; set; } = string.Empty; - - /// - public IPEndPoint RemoteEndPoint { get; } - - /// - /// Initializes a new instance of the class for the specified socket. - /// - /// The socket to handle. - public TcpSocketHandler(Socket socket) - { - ReceivedAt = UnixTime.Now; - Socket = socket; - RemoteEndPoint = (IPEndPoint)socket.RemoteEndPoint; - } - - public void Close() - { - Socket.Shutdown(SocketShutdown.Both); - Socket.Close(); - } - - public ServerRequest? Parse() - { - using (NetworkStream ns = new(Socket)) - { - while (!RawData.EndsWith(';')) - { - byte[] buffer = new byte[Socket.ReceiveBufferSize]; - int bytesRead = ns.Read(buffer, 0, Socket.ReceiveBufferSize); - RawData += Encoding.UTF8.GetString(buffer, 0, bytesRead); - Log.Debug(RawData); - } - } - - return JsonConvert.DeserializeObject(RawData.TrimEnd(';')); - } - - public Task RespondAsync(object? data) - { - try - { - string json = JsonConvert.SerializeObject(data, Formatting.Indented); - Socket?.Send(Encoding.UTF8.GetBytes(json + ";")); - Close(); - return Task.CompletedTask; - } - catch (ObjectDisposedException) - { - throw; - } - catch (Exception ex) - { - Log.Error(ex.Message); - return Task.CompletedTask; - } - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Parallel.Service.csproj b/Parallel.Service/Parallel.Service.csproj deleted file mode 100644 index bfde18a..0000000 --- a/Parallel.Service/Parallel.Service.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - Exe - net9.0 - enable - parallel-red.ico - enable - ParallelService - 1.0.0 - Kyle Ebbinga - Copyright $(Company). All Rights Reserved. - $(AssemblyVersion) - $(VersionPrefix)$(AssemblyVersion) - $(Company) - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Parallel.Service/Program.cs b/Parallel.Service/Program.cs deleted file mode 100644 index 8950aa8..0000000 --- a/Parallel.Service/Program.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Reflection; -using System.Runtime.InteropServices; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Parallel.Core.IO; -using Parallel.Core.Settings; -using Parallel.Core.Utils; -using Parallel.Service.Requests; -using Parallel.Service.Services; - -namespace Parallel.Service -{ - internal class Program - { - internal static readonly string LogFile = Path.Combine(PathBuilder.ProgramData, "Logs", "latest.txt"); - - static async Task Main(string[] args) - { - HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); - - // Logging - Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().WriteTo.File(LogFile).CreateLogger(); - builder.Logging.ClearProviders(); - builder.Logging.AddSerilog(); - - AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); - Log.Information($"{assembly.Name} v{assembly.Version}"); - - // Add Windows services - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - builder.Services.AddWindowsService(); - } - - // Add Linux systemd - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - builder.Services.AddSystemd(); - } - - // Background services - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - - // Other services - builder.Services.AddSingleton(ParallelSettings.Load()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - IHost host = builder.Build(); - IHostApplicationLifetime lifetime = host.Services.GetRequiredService(); - ParallelSettings settings = host.Services.GetRequiredService(); - lifetime.ApplicationStopped.Register(() => - { - settings.Save(); - }); - - // Starts the application - await host.RunAsync(); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/RequestHandler.cs b/Parallel.Service/RequestHandler.cs deleted file mode 100644 index a121c6e..0000000 --- a/Parallel.Service/RequestHandler.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using Parallel.Core.Net; -using Parallel.Service.Requests; - -namespace Parallel.Service -{ - public class RequestHandler - { - public Dictionary Requests { get; } - - public RequestHandler() - { - Type[] types = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).Where(t => typeof(BaseRequest).IsAssignableFrom(t) && !t.IsAbstract).ToArray(); - Requests = types.ToDictionary(t => t.Name.Replace("Request", ""), t => t, StringComparer.OrdinalIgnoreCase); - - // Logs if any requests failed - if (Requests.Count != types.Length) - { - int remaining = types.Length - Requests.Count; - Log.Warning($"Failed to register {remaining} requests"); - } - } - - /// - /// Creates an to be handled. - /// - /// The name of the request. - /// The corresponding . If none was found a help request will be returned. - public IRequest? CreateNew(ServerRequest request) - { - Dictionary headers = new Dictionary(request.Parameters, StringComparer.OrdinalIgnoreCase); - if (!Requests.TryGetValue(request.Name, out Type? requestType)) - { - Log.Warning($"Unknown command: {request.Name}"); - return null; - } - - // Instantiate the request object - object? instance = Activator.CreateInstance(requestType); - if (instance is not IRequest requestInstance) return null; - - // Map parameters to object properties - foreach (PropertyInfo prop in requestType.GetProperties()) - { - if (headers.TryGetValue(prop.Name, out string? value)) - { - try - { - object? converted = Convert.ChangeType(value, prop.PropertyType); - prop.SetValue(instance, converted); - } - catch (Exception ex) - { - Log.Warning($"Failed to convert '{value}' to {prop.PropertyType.Name} for property '{prop.Name}': {ex.Message}"); - } - } - } - - - // Validate required properties - List? validationResults = new List(); - ValidationContext? context = new ValidationContext(instance, serviceProvider: null, items: null); - if (!Validator.TryValidateObject(instance, context, validationResults, validateAllProperties: true)) - { - string? errors = string.Join("; ", validationResults.Select(r => r.ErrorMessage)); - Log.Warning($"Validation failed for '{request.Name}': {errors}"); - return null; - } - - return requestInstance; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/BaseRequest.cs b/Parallel.Service/Requests/BaseRequest.cs deleted file mode 100644 index f515432..0000000 --- a/Parallel.Service/Requests/BaseRequest.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Net.Sockets; -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - /// - /// The base implementation for an - /// - public abstract class BaseRequest : IRequest - { - protected ISocketHandler Handler { get; } - - public abstract Task ExecuteAsync(); - - public virtual void Dispose() - { - Handler.Close(); - GC.SuppressFinalize(this); - } - - public static MessageResponse Ok() - { - return new MessageResponse("Success", 200); - } - - public static MessageResponse Ok(string message) - { - return new MessageResponse(message, 200); - } - - public static ObjectResponse Json(object data) - { - return new ObjectResponse(data, 200); - } - - public static MessageResponse BadRequest(string message) - { - return new MessageResponse(message, 401); - } - - public static MessageResponse Unauthorized() - { - return new MessageResponse("Unauthorized", 401); - } - - public static MessageResponse Forbidden() - { - return new MessageResponse("Forbidden", 403); - } - - public static ErrorResponse InternalServerError(Exception exception) - { - return new ErrorResponse(exception, 500); - } - - public static MessageResponse NotImplemented() - { - return new MessageResponse("Function not implemented", 501); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/HelpRequest.cs b/Parallel.Service/Requests/HelpRequest.cs deleted file mode 100644 index 7d4ee43..0000000 --- a/Parallel.Service/Requests/HelpRequest.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using Newtonsoft.Json.Linq; -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - [Description("Lists all avalible requests to the server.")] - public class HelpRequest : BaseRequest - { - public override Task ExecuteAsync() - { - RequestHandler handler = new RequestHandler(); - - JArray jsonArray = new JArray(); - foreach (KeyValuePair request in handler.Requests.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) - { - Type type = request.Value; - DescriptionAttribute? descAttr = type.GetCustomAttribute(); - string description = descAttr?.Description ?? "No description provided."; - - JArray parameters = new JArray(); - foreach (PropertyInfo prop in type.GetProperties()) - { - parameters.Add(new JObject - { - ["name"] = prop.Name, - ["type"] = prop.PropertyType.Name, - ["required"] = prop.GetCustomAttribute() != null - }); - } - - // Build JObject for this request - JObject summary = new JObject - { - ["name"] = request.Key, - ["description"] = description, - ["parameters"] = parameters - }; - - jsonArray.Add(summary); - } - - return Task.FromResult(Json(jsonArray)); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/IRequest.cs b/Parallel.Service/Requests/IRequest.cs deleted file mode 100644 index 88e6286..0000000 --- a/Parallel.Service/Requests/IRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Net; -using Parallel.Core.Net.Sockets; -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - /// - /// Defines the request class. - /// - public interface IRequest : IDisposable - { - /// - /// Executes a request and responds with an . - /// - Task ExecuteAsync(); - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/PingRequest.cs b/Parallel.Service/Requests/PingRequest.cs deleted file mode 100644 index ea918b6..0000000 --- a/Parallel.Service/Requests/PingRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - public class PingRequest : BaseRequest - { - public override Task ExecuteAsync() - { - return Task.FromResult(Ok()); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/ErrorResponse.cs b/Parallel.Service/Responses/ErrorResponse.cs deleted file mode 100644 index c726119..0000000 --- a/Parallel.Service/Responses/ErrorResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public class ErrorResponse : IResponse - { - public int Status { get; } - public string Error { get; } - - public ErrorResponse(Exception exception, int status) - { - Status = status; - Error = $"{exception.GetType().FullName}: {exception.Message}"; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/IResponse.cs b/Parallel.Service/Responses/IResponse.cs deleted file mode 100644 index f48de5e..0000000 --- a/Parallel.Service/Responses/IResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public interface IResponse - { - int Status { get; } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/MessageResponse.cs b/Parallel.Service/Responses/MessageResponse.cs deleted file mode 100644 index f03a76f..0000000 --- a/Parallel.Service/Responses/MessageResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public class MessageResponse : IResponse - { - public int Status { get; } - public string Message { get; } - - public MessageResponse(string message, int status) - { - Message = message; - Status = status; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/ObjectResponse.cs b/Parallel.Service/Responses/ObjectResponse.cs deleted file mode 100644 index 1e3577b..0000000 --- a/Parallel.Service/Responses/ObjectResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public sealed class ObjectResponse : IResponse - { - public int Status { get; } - public object? Data { get; } - - public ObjectResponse(object? data, int status) - { - Status = status; - Data = data; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/FileBackupService.cs b/Parallel.Service/Services/FileBackupService.cs deleted file mode 100644 index 2c91ca5..0000000 --- a/Parallel.Service/Services/FileBackupService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Microsoft.Extensions.Hosting; - -namespace Parallel.Service.Services -{ - public class FileBackupService : BackgroundService - { - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/FileCleanupService.cs b/Parallel.Service/Services/FileCleanupService.cs deleted file mode 100644 index e1abcc1..0000000 --- a/Parallel.Service/Services/FileCleanupService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Microsoft.Extensions.Hosting; - -namespace Parallel.Service.Services -{ - public class FileCleanupService : BackgroundService - { - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/LoggingService.cs b/Parallel.Service/Services/LoggingService.cs deleted file mode 100644 index 2e1c644..0000000 --- a/Parallel.Service/Services/LoggingService.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Microsoft.Extensions.Hosting; -using Parallel.Core.IO; - -namespace Parallel.Service.Services -{ - public class LoggingService : BackgroundService - { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - await Task.Delay(GetTimeUntilNextDay(), stoppingToken); - await Log.CloseAndFlushAsync(); - - File.Move(Program.LogFile, Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log")); - } - } - - private static TimeSpan GetTimeUntilNextDay() - { - DateTime current = DateTime.Now; - DateTime nextMidnight = current.AddDays(1); - return nextMidnight - current; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/TcpRequestService.cs b/Parallel.Service/Services/TcpRequestService.cs deleted file mode 100644 index 7b45152..0000000 --- a/Parallel.Service/Services/TcpRequestService.cs +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using System.Net.Sockets; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Parallel.Core.Net; -using Parallel.Core.Net.Sockets; -using Parallel.Core.Settings; -using Parallel.Service.Requests; -using Parallel.Service.Responses; - -namespace Parallel.Service.Services -{ - public class TcpRequestService : BackgroundService - { - // Privates - private readonly CancellationTokenSource _exit = new(); - private readonly ILogger _logger; - private readonly Socket _listener = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - private readonly ParallelSettings _settings; - private readonly RequestHandler _requests; - private readonly List _requestPool = new List(); - - public TcpRequestService(ILogger logger, ParallelSettings settings, RequestHandler requests) - { - _logger = logger; - _settings = settings; - _requests = requests; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - // Starts listening for requests over the TCP socket. - IPAddress address = string.IsNullOrEmpty(_settings.Address) ? IPAddress.Any : IPAddress.Parse(_settings.Address); - _listener.Bind(new IPEndPoint(address, _settings.ListenerPort)); - _listener.Listen(5); - } - catch - { - _logger.LogError("Failed to start server! This usually means either the port is already in use or another instance of Parallel is currently running."); - Environment.Exit(1); - } - - // Starts listening for connections - _logger.LogInformation($"Listening for commands on: {_listener.LocalEndPoint}"); - while (!stoppingToken.IsCancellationRequested && !_exit.IsCancellationRequested) - { - _requestPool.RemoveAll(c => c.IsCompleted); - Socket requestSocket = await _listener.AcceptAsync(_exit.Token); - StartHandlingRequests(requestSocket, stoppingToken); - } - } - - private void StartHandlingRequests(Socket socket, CancellationToken token) - { - TcpSocketHandler handler = new(socket); - Task handleTask = AcceptRequestAsync(handler); - Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(30), token); - - Task wrappedTask = Task.Run(async () => - { - Task completed = await Task.WhenAny(handleTask, timeoutTask); - IResponse response; - - if (completed == handleTask) - { - try - { - response = await handleTask; - } - catch (OperationCanceledException) - { - _logger.LogInformation($"[{handler.RemoteEndPoint}]: Request cancelled."); - response = new MessageResponse("Request cancelled", 503); - } - catch (Exception ex) - { - _logger.LogError(ex, $"[{handler.RemoteEndPoint}]: Handler failed."); - response = new ErrorResponse(ex, 500); - } - } - else - { - _logger.LogWarning($"[{handler.RemoteEndPoint}]: Timed out after 30 seconds."); - response = new MessageResponse("Request timed out", 408); - } - - await handler.RespondAsync(response); - handler.Close(); - }, token); - - _requestPool.Add(wrappedTask); - } - - private async Task AcceptRequestAsync(ISocketHandler handler) - { - ServerRequest? request = handler.Parse(); - if (request == null) return new MessageResponse("Unable to parse request", 401); - - Log.Debug($"Received request '{handler.RawData}' from '{handler.RemoteEndPoint}' ({_requestPool.Count} active request{(_requestPool.Count == 1 ? string.Empty : "s")})"); - IRequest? requestInstance = _requests.CreateNew(request); - if (requestInstance == null) return new MessageResponse("Required fields are missing", 401); - return await requestInstance.ExecuteAsync(); - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - // Stops listening for requests. - await _exit.CancelAsync(); - - // Checks if any requests are still being processed. - _requestPool.RemoveAll(c => c.IsCompleted); - if (_requestPool.Count > 0) _logger.LogInformation($"Shutdown received. Still processing {_requestPool.Count} request{(_requestPool.Count == 1 ? string.Empty : "s")}!"); - await Task.WhenAll(_requestPool); - - return base.StopAsync(cancellationToken); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Utils/UdpReporting.cs b/Parallel.Service/Utils/UdpReporting.cs deleted file mode 100644 index 3d10d00..0000000 --- a/Parallel.Service/Utils/UdpReporting.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Diagnostics; -using Parallel.Core.Models; -using Parallel.Core.Net; - -namespace Parallel.Service.Utils -{ - public class UdpReporting : IProgressReporter - { - private readonly Communication _comms = new Communication(); - - public void Report(ProgressOperation operation, SystemFile file, int current, int total) - { - int percent = current * 100 / total; - //_comms.Send($"[{percent}%] {operation}: {file.LocalPath}"); - } - - public void Failed(Exception exception, SystemFile file) - { - //_comms.Send($"Failed to upload file: '{file.LocalPath}'"); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/parallel-red.ico b/Parallel.Service/parallel-red.ico deleted file mode 100644 index 27a395dd3aeaddc603993a067aef2d4f5e755748..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180837 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

tYWpos4X*=^( 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 Date: Mon, 1 Sep 2025 15:50:02 -0500 Subject: [PATCH 14/33] Removed service from sln --- Parallel.sln | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Parallel.sln b/Parallel.sln index f5b080f..329e4be 100644 --- a/Parallel.sln +++ b/Parallel.sln @@ -7,10 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Core", "Parallel.C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Cli", "Parallel.Cli\Parallel.Cli.csproj", "{4BFE65E9-9534-4C85-B59F-1F64A998C76D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Core.Net", "Parallel.Core.Net\Parallel.Core.Net.csproj", "{157A0A8F-A393-4577-AD3B-DF5FB49A7331}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Service", "Parallel.Service\Parallel.Service.csproj", "{DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -25,14 +21,6 @@ Global {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Debug|Any CPU.Build.0 = Debug|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Release|Any CPU.ActiveCfg = Release|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Release|Any CPU.Build.0 = Release|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Debug|Any CPU.Build.0 = Debug|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Release|Any CPU.ActiveCfg = Release|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Release|Any CPU.Build.0 = Release|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From b2db4ebedafcfde0f101459e098c43005aa44472 Mon Sep 17 00:00:00 2001 From: Kyle Ebbinga <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:55:49 -0500 Subject: [PATCH 15/33] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 33b571b..75fe5f7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# [Parallel Icon](https://github.com/TheGuitarleader/Parallel) Parallel +# [Parallel Icon](https://github.com/TheGuitarleader/Parallel) Parallel [![.NET](https://img.shields.io/github/actions/workflow/status/TheGuitarleader/Parallel/dotnet.yml?label=Main%20build&style=for-the-badge)](https://github.com/TheGuitarleader/Parallel/actions/workflows/dotnet.yml) [![latest version](https://img.shields.io/github/v/release/TheGuitarleader/Parallel?label=Latest%20release&style=for-the-badge)](https://github.com/TheGuitarleader/Parallel/releases/latest) [![GitHub Downloads](https://img.shields.io/github/downloads/TheGuitarleader/Parallel/total?style=for-the-badge)](https://github.com/TheGuitarleader/Parallel/releases/latest) From a067ebf6636e65dc4138a6c47802a095bec8056b Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:47:07 -0500 Subject: [PATCH 16/33] Massive changes to file pushing --- Parallel.Cli/Commands/DecryptCommand.cs | 104 ----------- Parallel.Cli/Commands/EncryptCommand.cs | 109 ----------- Parallel.Cli/Commands/HistoryCommand.cs | 14 +- Parallel.Cli/Commands/PushCommand.cs | 6 +- Parallel.Cli/Commands/VaultsCommand.cs | 31 +--- Parallel.Cli/Program.cs | 4 +- Parallel.Cli/Utils/CommandLine.cs | 5 +- Parallel.Cli/Utils/ProgressReport.cs | 15 +- .../Database/Contexts/SqliteContext.cs | 12 +- Parallel.Core/Database/DatabaseConnection.cs | 33 ---- .../Events/MessageRecievedEventArgs.cs | 1 + .../IO/FileSystem/DotNetFileSystem.cs | 111 ++++-------- .../IO/FileSystem/FileSystemManager.cs | 10 +- Parallel.Core/IO/FileSystem/IFileSystem.cs | 31 +--- Parallel.Core/IO/FileSystem/SftpFileSystem.cs | 170 ++++++------------ Parallel.Core/IO/PathBuilder.cs | 107 ++++++++--- Parallel.Core/IO/Recovery/RecoveryManager.cs | 86 --------- Parallel.Core/IO/Recovery/RecoveryPoint.cs | 55 ------ Parallel.Core/IO/Scanning/FileScanner.cs | 5 +- Parallel.Core/IO/Syncing/BaseSyncManager.cs | 61 +++++-- Parallel.Core/IO/Syncing/DeltaSyncManager.cs | 4 +- Parallel.Core/IO/Syncing/FileSyncManager.cs | 23 ++- Parallel.Core/IO/Syncing/ISyncManager.cs | 16 +- Parallel.Core/IO/Syncing/SyncManager.cs | 6 +- Parallel.Core/Models/SystemFile.cs | 5 + Parallel.Core/Security/Encryption.cs | 5 +- Parallel.Core/Settings/DatabaseCredentials.cs | 45 ----- Parallel.Core/Settings/LocalVaultConfig.cs | 67 +++++++ Parallel.Core/Settings/ParallelConfig.cs | 101 +++++++++++ Parallel.Core/Settings/ParallelSettings.cs | 112 ------------ .../{VaultConfig.cs => RemoteVaultConfig.cs} | 103 +---------- 31 files changed, 476 insertions(+), 981 deletions(-) delete mode 100644 Parallel.Cli/Commands/DecryptCommand.cs delete mode 100644 Parallel.Cli/Commands/EncryptCommand.cs delete mode 100644 Parallel.Core/Database/DatabaseConnection.cs delete mode 100644 Parallel.Core/IO/Recovery/RecoveryManager.cs delete mode 100644 Parallel.Core/IO/Recovery/RecoveryPoint.cs delete mode 100644 Parallel.Core/Settings/DatabaseCredentials.cs create mode 100644 Parallel.Core/Settings/LocalVaultConfig.cs create mode 100644 Parallel.Core/Settings/ParallelConfig.cs delete mode 100644 Parallel.Core/Settings/ParallelSettings.cs rename Parallel.Core/Settings/{VaultConfig.cs => RemoteVaultConfig.cs} (52%) diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs deleted file mode 100644 index b451fba..0000000 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.CommandLine; -using System.Diagnostics; -using System.Text; -using Parallel.Cli.Utils; -using Parallel.Core.Database; -using Parallel.Core.IO; -using Parallel.Core.Models; -using Parallel.Core.Settings; -using Parallel.Core.Utils; - -namespace Parallel.Cli.Commands -{ - public class DecryptCommand : Command - { - private readonly Argument _sourceArg = new("path", "The source path of files to zip."); - private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); - - private IDatabase? _database; - private Stopwatch _sw = new Stopwatch(); - private List _tasks = new List(); - private int _totalTasks = 0; - - public DecryptCommand() : base("decrypt", "Decrypts a file or directory.") - { - this.AddArgument(_sourceArg); - this.SetHandler(async (path, config) => - { - VaultConfig? vault = VaultConfig.Load(Program.Settings, config); - if (vault == null) - { - CommandLine.WriteLine("No active vault was found!", ConsoleColor.Yellow); - return; - } - - _database = DatabaseConnection.CreateNew(vault); - string masterKey = vault.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); - if (PathBuilder.IsDirectory(path)) - { - await DecryptDirectoryAsync(path, masterKey); - } - else if (PathBuilder.IsFile(path)) - { - CommandLine.WriteLine($"Decrypting {path}...", ConsoleColor.DarkGray); - await DecryptFileAsync(path, masterKey); - - CommandLine.WriteLine($"Successfully decrypted file: {path}", ConsoleColor.Green); - } - else - { - CommandLine.WriteLine("The specified path is invalid.", ConsoleColor.Red); - } - }, _sourceArg, _configOpt); - } - - private async Task DecryptDirectoryAsync(string path, string masterKey) - { - CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray); - string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).ToArray(); - if (files.Length == 0) - { - CommandLine.WriteLine("No files found to decrypt!", ConsoleColor.Yellow); - return; - } - - CommandLine.WriteLine($"Decrypting {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); - _totalTasks = files.Length; - _tasks = files.Select(file => Task.Run(async () => - { - await DecryptFileAsync(file, masterKey); - CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); - })).ToList(); - - await Task.WhenAll(_tasks); - CommandLine.WriteLine($"Successfully decrypted {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); - } - - private async Task DecryptFileAsync(string path, string masterKey) - { - SystemFile systemFile = await _database?.GetFileAsync(path)! ?? new SystemFile(path); - if (File.Exists(systemFile.LocalPath) && systemFile.Encrypted) - { - string tempFile = Path.Combine(PathBuilder.TempDirectory, Path.GetFileName(systemFile.LocalPath)) + ".tmp"; - await using (FileStream openFile = new FileStream(systemFile.LocalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - await using (FileStream createFile = new FileStream(tempFile, FileMode.OpenOrCreate)) - { - systemFile.Encrypted = false; - - Encryption.DecryptStream(openFile, createFile, masterKey, systemFile.LastWrite, systemFile.Salt, systemFile.IV); - if (!await _database?.AddFileAsync(systemFile)!) - { - CommandLine.WriteLine($"Failed to decrypt file: {systemFile.LocalPath}", ConsoleColor.Red); - if(File.Exists(tempFile)) File.Delete(tempFile); - return; - } - } - - File.Copy(tempFile, systemFile.LocalPath, true); - if(File.Exists(tempFile)) File.Delete(tempFile); - } - } - } -} \ No newline at end of file diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs deleted file mode 100644 index f76caf0..0000000 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.CommandLine; -using System.Diagnostics; -using System.IO.Compression; -using Parallel.Cli.Utils; -using Parallel.Core.Database; -using Parallel.Core.IO; -using Parallel.Core.Models; -using Parallel.Core.Security; -using Parallel.Core.Settings; -using Parallel.Core.Utils; - -namespace Parallel.Cli.Commands -{ - public class EncryptCommand : Command - { - private readonly Argument _sourceArg = new("path", "The source path to encrypt."); - private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); - - private IDatabase? _database; - private Stopwatch _sw = new Stopwatch(); - private List _tasks = new List(); - private int _totalTasks = 0; - - public EncryptCommand() : base("encrypt", "Encrypts a file or directory.") - { - this.AddArgument(_sourceArg); - this.SetHandler(async (path, config) => - { - _sw = Stopwatch.StartNew(); - VaultConfig? vault = VaultConfig.Load(Program.Settings, config); - if (vault == null) - { - CommandLine.WriteLine("No active vault was found!", ConsoleColor.Yellow); - return; - } - - _database = DatabaseConnection.CreateNew(vault); - string masterKey = vault.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); - if (PathBuilder.IsDirectory(path)) - { - await EncryptDirectoryAsync(path, masterKey); - } - else if (PathBuilder.IsFile(path)) - { - CommandLine.WriteLine($"Encrypting {path}...", ConsoleColor.DarkGray); - await EncryptFileAsync(path, masterKey); - - CommandLine.WriteLine($"Successfully encrypted file: {path}", ConsoleColor.Green); - } - else - { - CommandLine.WriteLine("The specified path is invalid.", ConsoleColor.Red); - } - - }, _sourceArg, _configOpt); - } - - private async Task EncryptDirectoryAsync(string path, string masterKey) - { - CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray); - string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).ToArray(); - if (files.Length == 0) - { - CommandLine.WriteLine("No files found to encrypt!", ConsoleColor.Yellow); - return; - } - - CommandLine.WriteLine($"Encrypting {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); - _totalTasks = files.Length; - _tasks = files.Select(file => Task.Run(async () => - { - await EncryptFileAsync(file, masterKey); - CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); - })).ToList(); - - await Task.WhenAll(_tasks); - CommandLine.WriteLine($"Successfully encrypted {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); - } - - private async Task EncryptFileAsync(string path, string masterKey) - { - SystemFile systemFile = await _database?.GetFileAsync(path)! ?? new SystemFile(path); - if (File.Exists(systemFile.LocalPath) && !systemFile.Encrypted) - { - string tempFile = Path.Combine(PathBuilder.TempDirectory, Path.GetFileName(systemFile.LocalPath)) + ".tmp"; - await using (FileStream openFile = new FileStream(systemFile.LocalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - await using (FileStream createFile = new FileStream(tempFile, FileMode.OpenOrCreate)) - { - systemFile.Salt = HashGenerator.GenerateHash(16); - systemFile.IV = HashGenerator.GenerateHash(16); - systemFile.Encrypted = true; - - Encryption.EncryptStream(openFile, createFile, masterKey, systemFile.LastWrite, systemFile.Salt, systemFile.IV); - if (!await _database?.AddFileAsync(systemFile)!) - { - CommandLine.WriteLine($"Failed to encrypt file: {systemFile.LocalPath}", ConsoleColor.Red); - if(File.Exists(tempFile)) File.Delete(tempFile); - return; - } - } - - File.Copy(tempFile, systemFile.LocalPath, true); - if(File.Exists(tempFile)) File.Delete(tempFile); - } - } - } -} \ No newline at end of file diff --git a/Parallel.Cli/Commands/HistoryCommand.cs b/Parallel.Cli/Commands/HistoryCommand.cs index eabd7cb..e18e8bf 100644 --- a/Parallel.Cli/Commands/HistoryCommand.cs +++ b/Parallel.Cli/Commands/HistoryCommand.cs @@ -42,7 +42,7 @@ public HistoryCommand() : base("history", "Shows the history of files related to this.SetHandler((path, config, limit) => { CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); if (limit == 0) limit = Limit; DisplayHistories(db?.GetHistory(path, limit).ToArray()); @@ -54,7 +54,7 @@ public HistoryCommand() : base("history", "Shows the history of files related to _pushCmd.SetHandler((path, config, limit) => { CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); if (limit == 0) limit = Limit; DisplayHistories(db?.GetHistory(path, HistoryType.Pushed, limit).ToArray()); @@ -66,7 +66,7 @@ public HistoryCommand() : base("history", "Shows the history of files related to _deleteCmd.SetHandler((path, config, limit) => { CommandLine.WriteLine($"Retrieving archive information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); if (limit == 0) limit = Limit; DisplayHistories(db?.GetHistory(path, HistoryType.Archived, limit).ToArray()); @@ -78,7 +78,7 @@ public HistoryCommand() : base("history", "Shows the history of files related to _cleanCmd.SetHandler((path, config, limit) => { CommandLine.WriteLine($"Retrieving clean information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); if (limit == 0) limit = Limit; DisplayHistories(db?.GetHistory(path, HistoryType.Cleaned, limit).ToArray()); @@ -90,7 +90,7 @@ public HistoryCommand() : base("history", "Shows the history of files related to _cloneCmd.SetHandler((path, config, limit) => { CommandLine.WriteLine($"Retrieving clone information...", ConsoleColor.DarkGray); - IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + IDatabase db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); if (limit == 0) limit = Limit; DisplayHistories(db?.GetHistory(path, HistoryType.Cloned, limit).ToArray()); @@ -102,7 +102,7 @@ public HistoryCommand() : base("history", "Shows the history of files related to _pruneCmd.SetHandler((path, config, limit) => { CommandLine.WriteLine($"Retrieving prune information...", ConsoleColor.DarkGray); - IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + IDatabase db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); if (limit == 0) limit = Limit; DisplayHistories(db?.GetHistory(path, HistoryType.Pruned, limit).ToArray()); @@ -114,7 +114,7 @@ public HistoryCommand() : base("history", "Shows the history of files related to _pullCmd.SetHandler((path, config, limit) => { CommandLine.WriteLine($"Retrieving restore information...", ConsoleColor.DarkGray); - IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + IDatabase db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); if (limit == 0) limit = Limit; DisplayHistories(db?.GetHistory(path, HistoryType.Pulled, limit).ToArray()); diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index 7d7961c..49e3bfc 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -47,10 +47,10 @@ private async Task SyncSystemAsync() private async Task SyncPathAsync(string path) { - await ParallelSettings.ForEachVaultAsync(async vault => + await ParallelConfig.ForEachVaultAsync(async vault => { ISyncManager sync = SyncManager.CreateNew(vault); - if (!sync.Initialize()) + if (!await sync.InitializeAsync()) { CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); return; @@ -87,7 +87,9 @@ await ParallelSettings.ForEachVaultAsync(async vault => CommandLine.WriteLine(vault, $"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); await sync.PushFilesAsync(files, new ProgressReport(vault)); + CommandLine.WriteLine(vault, $"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); + await sync.DisconnectAsync(); }); } } diff --git a/Parallel.Cli/Commands/VaultsCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs index 6413342..b1c8fa0 100644 --- a/Parallel.Cli/Commands/VaultsCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -25,31 +25,17 @@ public VaultsCommand() : base("vaults", "View or edit the vaults.") this.SetHandler(() => { CommandLine.WriteLine("Active vaults:"); - Program.Settings.ForEachVault(vault => + for (int i = 0; i < Program.Settings.Vaults.Count; i++) { - CommandLine.WriteLine(vault.Name); - }); + LocalVaultConfig vault = Program.Settings.Vaults.ElementAt(i); + CommandLine.WriteLine($"{i + 1}: {vault.Name} ({vault.Id})"); + } }); this.AddCommand(addCmd); addCmd.SetHandler(() => { - CommandLine.WriteLine("Creating new database credentials...", ConsoleColor.DarkGray); - DatabaseCredentials dbc = new DatabaseCredentials(); - dbc.Provider = Enum.Parse(CommandLine.ReadString($"Provider ({string.Join(", ", Enum.GetNames(typeof(DatabaseProvider)))})"), true); - if (dbc.Provider == DatabaseProvider.Local) - { - dbc = DatabaseCredentials.Local; - } - else - { - dbc.Address = CommandLine.ReadString("Address"); - dbc.Username = CommandLine.ReadString("Username"); - dbc.Password = CommandLine.ReadPassword("Password"); - dbc.Name = CommandLine.ReadString("Name"); - } - - CommandLine.WriteLine("Creating new file system credentials...", ConsoleColor.DarkGray); + CommandLine.WriteLine("Creating new storage vault...", ConsoleColor.DarkGray); FileSystemCredentials fsc = new FileSystemCredentials(); fsc.Service = Enum.Parse(CommandLine.ReadString($"Service ({string.Join(", ", Enum.GetNames(typeof(FileService)))})"), true); if (fsc.Service == FileService.Local) @@ -74,10 +60,11 @@ public VaultsCommand() : base("vaults", "View or edit the vaults.") fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); string? profileName = CommandLine.ReadString("Profile Name"); - VaultConfig vault = new VaultConfig(profileName, dbc, fsc); - vault.SaveToFile(); + LocalVaultConfig localVault = new LocalVaultConfig(profileName, fsc); + Program.Settings.Vaults.Add(localVault); + Program.Settings.Save(); - CommandLine.WriteLine($"Saved new connection vault: '{vault.Name}'"); + CommandLine.WriteLine($"Saved new storage vault: '{localVault.Name}' ({localVault.Id})"); }); this.AddCommand(setCmd); diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index 3c54b91..7369bd9 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -9,11 +9,11 @@ namespace Parallel.Cli { internal class Program { - internal static ParallelSettings Settings = new ParallelSettings(); + internal static ParallelConfig Settings = new ParallelConfig(); public static async Task Main(string[] args) { - Settings = ParallelSettings.Load(); + Settings = ParallelConfig.Load(); string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log"); Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.File(logFile).CreateLogger(); diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index 049cc89..90e9dfc 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -1,6 +1,7 @@ // Copyright 2025 Kyle Ebbinga using System.Text; +using Parallel.Core.Security; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -74,9 +75,9 @@ public static void Write(object value, ConsoleColor color = ConsoleColor.Gray) Console.ResetColor(); } - public static void WriteLine(VaultConfig vault, object value, ConsoleColor color = ConsoleColor.Gray) + public static void WriteLine(LocalVaultConfig localVault, object value, ConsoleColor color = ConsoleColor.Gray) { - string baseLog = $"[{vault.Id}] {value}"; + string baseLog = $"[{localVault.Id}] {value}"; switch(color) { default: diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index 9710575..f2c2f9b 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -6,17 +6,26 @@ namespace Parallel.Cli.Utils { - public class ProgressReport(VaultConfig vault) : IProgressReporter + public class ProgressReport : IProgressReporter { + private readonly LocalVaultConfig _localVault; + private readonly int _totalFiles; + + public ProgressReport(LocalVaultConfig localVault, int totalFiles) + { + _localVault = localVault; + _totalFiles = totalFiles; + } + public void Report(ProgressOperation operation, SystemFile file, int current, int total) { int percent = current * 100 / total; - CommandLine.WriteLine($"[{percent}%] <{vault.Id}> {operation}: {file.LocalPath}"); + CommandLine.WriteLine($"[{percent}%] <{_localVault.Id}> {operation}: {file.LocalPath}"); } public void Failed(Exception exception, SystemFile file) { - CommandLine.WriteLine(vault, $"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); + CommandLine.WriteLine(_localVault, $"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); } } } \ No newline at end of file diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index d0b4c2c..aaf6525 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -4,6 +4,7 @@ using System.Data; using System.Diagnostics; using Dapper; +using Parallel.Core.IO; using Parallel.Core.Models; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -21,10 +22,9 @@ public class SqliteContext : IDatabase ///

/// /// - public SqliteContext(DatabaseCredentials credentials, string profileId) + public SqliteContext(LocalVaultConfig localVault) { - FilePath = credentials.Address; - ProfileId = profileId; + FilePath = PathBuilder.GetDatabaseFile(localVault); } #region Base @@ -38,13 +38,13 @@ public IDbConnection CreateConnection() /// public async Task InitializeAsync() { - Log.Information("Creating local database..."); + Log.Information("Creating index database..."); File.Create(FilePath).Close(); File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`vault` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` TEXT, `iv` TEXT, `checksum` TEXT, PRIMARY KEY(`vault`, `id`));"); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`vault` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`vault`, `timestamp`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `checksum` TEXT, PRIMARY KEY(`id`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`timestamp`));"); } #endregion diff --git a/Parallel.Core/Database/DatabaseConnection.cs b/Parallel.Core/Database/DatabaseConnection.cs deleted file mode 100644 index 3fd19f0..0000000 --- a/Parallel.Core/Database/DatabaseConnection.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Settings; - -namespace Parallel.Core.Database -{ - /// - /// The supported file service types. - /// - public enum DatabaseProvider - { - Local - } - - /// - /// Represents a way to connect to different . - /// - public class DatabaseConnection - { - public static IDatabase? CreateNew(VaultConfig? vault) - { - switch(vault?.Database.Provider) - { - default: return null; - - case DatabaseProvider.Local: - IDatabase db = new SqliteContext(vault.Database, vault.Id); - if (!File.Exists(vault.Database.Address)) db.InitializeAsync(); - return db; - } - } - } -} \ No newline at end of file diff --git a/Parallel.Core/Events/MessageRecievedEventArgs.cs b/Parallel.Core/Events/MessageRecievedEventArgs.cs index a8ae445..aeab27d 100644 --- a/Parallel.Core/Events/MessageRecievedEventArgs.cs +++ b/Parallel.Core/Events/MessageRecievedEventArgs.cs @@ -2,6 +2,7 @@ using System.Net.Sockets; using System.Text; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.Events diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index 941d721..d608956 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -17,17 +17,20 @@ namespace Parallel.Core.IO.FileSystem ///
public class DotNetFileSystem : IFileSystem { - private readonly VaultConfig _vault; + private readonly LocalVaultConfig _vaultConfig; /// /// Represents an for interacting with physical machine hardware. /// - /// The vault to use. - public DotNetFileSystem(VaultConfig vault) + /// The vault to use. + public DotNetFileSystem(LocalVaultConfig vaultConfig) { - _vault = vault; + _vaultConfig = vaultConfig; } + /// + public void Dispose() { } + /// public Task CreateDirectoryAsync(string path) { @@ -54,66 +57,24 @@ public Task DeleteFileAsync(string path) return Task.CompletedTask; } - /// - public async Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) + public Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) { - if (!files.Any()) return; - for (int i = 0; i < files.Length; i++) - { - SystemFile file = files[i]; - Stopwatch sw = new Stopwatch(); - - progress.Report(ProgressOperation.Downloading, file, i, files.Length); - await using FileStream createStream = File.Create(file.LocalPath); - await using FileStream openStream = File.OpenRead(file.RemotePath); - await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); - await gzipStream.CopyToAsync(createStream); - - Log.Debug($"Downloaded '{file.LocalPath}' in {sw.ElapsedMilliseconds}ms"); - } + throw new NotImplementedException(); } /// - public Task GetDirectoryNameAsync(string path) + public async Task DownloadFileAsync(string sourcePath, string destPath) { - return Task.FromResult(Path.GetDirectoryName(path)); + await using FileStream openStream = File.OpenRead(sourcePath); + await using FileStream createStream = File.Create(destPath); + await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(createStream); } - /// - public Task> GetFilesAsync() - { - Dictionary files = new Dictionary(); - foreach (string file in Directory.GetFiles(PathBuilder.RootDirectory(_vault), "*.gz", SearchOption.AllDirectories)) - { - FileInfo fi = new(file); - files.Add(fi.FullName, new SystemFile(file) - { - Name = fi.Name, - RemotePath = fi.FullName, - LastWrite = new UnixTime(fi.LastWriteTime), - RemoteSize = fi.Length - }); - } - - return Task.FromResult(files); - } - - /// - public Task GetFilesAsync(string path) + /// + public Task ExistsAsync(string path) { - List list = new(); - foreach (string file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) - { - FileInfo fi = new(file); - list.Add(new SystemFile(file) - { - Name = fi.Name, - RemotePath = fi.FullName, - RemoteSize = fi.Length - }); - } - - return Task.FromResult(list.ToArray()); + return Task.FromResult(Directory.Exists(path) || File.Exists(path)); } /// @@ -128,35 +89,29 @@ public Task GetFileAsync(string path) }); } - /// - public Task PingAsync() + public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - Stopwatch sw = Stopwatch.StartNew(); - if (!Directory.Exists(PathBuilder.RootDirectory(_vault))) return Task.FromResult(-1); - return Task.FromResult(sw.ElapsedMilliseconds); + await System.Threading.Tasks.Parallel.ForEachAsync(files, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount / 2 }, async (file, ct) => + { + + }); } - /// - public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) + /// + public async Task UploadFileAsync(string sourcePath, string destPath) { - if (!files.Any()) return; - await Task.WhenAll(files.Select(file => Task.Run(async () => - { - Stopwatch sw = new Stopwatch(); - file.RemotePath = PathBuilder.Remote(file.LocalPath, _vault); - if (File.Exists(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); - string parent = Path.GetDirectoryName(file.RemotePath); - if (!Directory.Exists(parent)) Directory.CreateDirectory(parent); - await using FileStream createStream = File.Create(file.RemotePath); - await using FileStream openStream = File.OpenRead(file.LocalPath); - await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); - await openStream.CopyToAsync(gzipStream); + if (await ExistsAsync(destPath)) File.SetAttributes(destPath, ~FileAttributes.ReadOnly & File.GetAttributes(destPath)); + string? parent = Path.GetDirectoryName(destPath); + if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); + + await using FileStream openStream = File.OpenRead(sourcePath); + await using FileStream createStream = File.Create(destPath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream); - Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); - File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); - }))); + File.SetAttributes(destPath, File.GetAttributes(destPath) | FileAttributes.ReadOnly); } } } \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/FileSystemManager.cs b/Parallel.Core/IO/FileSystem/FileSystemManager.cs index 1e93b4d..adb410f 100644 --- a/Parallel.Core/IO/FileSystem/FileSystemManager.cs +++ b/Parallel.Core/IO/FileSystem/FileSystemManager.cs @@ -33,13 +33,13 @@ public static class FileSystemManager /// /// Creates a new file system association. /// - /// The vault needed for the associated file system. - public static IFileSystem CreateNew(VaultConfig vault) + /// The vault needed for the associated file system. + public static IFileSystem CreateNew(LocalVaultConfig vaultConfig) { - return vault.FileSystem.Service switch + return vaultConfig.FileSystem.Service switch { - FileService.Local => new DotNetFileSystem(vault), - FileService.Remote => new SftpFileSystem(vault), + FileService.Local => new DotNetFileSystem(vaultConfig), + FileService.Remote => new SftpFileSystem(vaultConfig), //FileService.Cloud => new AmazonS3FileSystem(credentials), _ => null }; diff --git a/Parallel.Core/IO/FileSystem/IFileSystem.cs b/Parallel.Core/IO/FileSystem/IFileSystem.cs index 7f043da..7346d81 100644 --- a/Parallel.Core/IO/FileSystem/IFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/IFileSystem.cs @@ -13,7 +13,7 @@ namespace Parallel.Core.IO.FileSystem /// /// Defines the way for communicating with a file system. /// - public interface IFileSystem + public interface IFileSystem : IDisposable { /// /// Creates all directories and subdirectories in the specified path unless they already exist. @@ -34,31 +34,18 @@ public interface IFileSystem Task DeleteFileAsync(string path); /// - /// Downloads a file from the associated file system. + /// Downloads an array of files from the associated file system. /// /// /// Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress); /// - /// Returns the parent directory name. + /// Checks if a path exists on the associated file system. /// /// - /// - Task GetDirectoryNameAsync(string path); - - /// - /// Gets all the files in the backup. - /// - /// A dictionary of s with the key being the backup path adn the value being the associated . - Task> GetFilesAsync(); - - /// - /// Gets all the files in the current directory. - /// - /// - /// A read-only collection of s. - Task GetFilesAsync(string path); + /// True if path exists, otherwise false. + Task ExistsAsync(string path); /// /// Gets a file on the associated file system. @@ -68,13 +55,7 @@ public interface IFileSystem Task GetFileAsync(string path); /// - /// Pings the remote file system. - /// - /// The time, in milliseconds, of the database latency. -1 if disconnected. - Task PingAsync(); - - /// - /// Uploads a file to the associated file system. + /// Uploads an array of files to the associated file system. /// /// /// diff --git a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs index 3a6a503..f00c498 100644 --- a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Diagnostics; using Parallel.Core.Models; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.IO.FileSystem @@ -18,68 +19,59 @@ namespace Parallel.Core.IO.FileSystem public class SftpFileSystem : IFileSystem { private readonly ConnectionInfo _connectionInfo; - private readonly VaultConfig _vault; + private readonly SftpClient _client; /// /// Represents an for interacting with an SSH server. /// - /// The credentials to log in with. - public SftpFileSystem(VaultConfig vault) + /// The credentials to log in with. + public SftpFileSystem(LocalVaultConfig localVault) { - _connectionInfo = new ConnectionInfo(vault.FileSystem.Address, vault.FileSystem.Username, new PasswordAuthenticationMethod(vault.FileSystem.Username, Encryption.Decode(vault.FileSystem.Password))); - _vault = vault; + _connectionInfo = new ConnectionInfo(localVault.FileSystem.Address, localVault.FileSystem.Username, new PasswordAuthenticationMethod(localVault.FileSystem.Username, Encryption.Decode(localVault.FileSystem.Password))); + _client = new SftpClient(_connectionInfo); + _client.Connect(); + } + + + /// + public void Dispose() + { + if (_client.IsConnected) _client.Disconnect(); + _client.Dispose(); } /// public async Task CreateDirectoryAsync(string path) { - using (SftpClient sftp = new SftpClient(_connectionInfo)) + if (_client.IsConnected) { - sftp.Connect(); - if (sftp.IsConnected) + string parentDir = string.Empty; + foreach (string subPath in path.Split('/')) { - string parentDir = string.Empty; - foreach (string subPath in path.Split('/')) + parentDir += $"/{subPath}"; + if (!await _client.ExistsAsync(parentDir)) { - parentDir += $"/{subPath}"; - if (!await sftp.ExistsAsync(parentDir)) - { - await sftp.CreateDirectoryAsync(parentDir); - } + await _client.CreateDirectoryAsync(parentDir); } } - - sftp.Disconnect(); } } /// public async Task DeleteDirectoryAsync(string path) { - using (SftpClient sftp = new SftpClient(_connectionInfo)) + if (await ExistsAsync(path)) { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) - { - await sftp.DeleteDirectoryAsync(path); - } - - sftp.Disconnect(); + await _client.DeleteDirectoryAsync(path); } } /// public async Task DeleteFileAsync(string path) { - using (SftpClient sftp = new SftpClient(_connectionInfo)) + if (await ExistsAsync(path)) { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) - { - await sftp.DeleteAsync(path); - } - - sftp.Disconnect(); + await _client.DeleteAsync(path); } } @@ -88,116 +80,56 @@ public Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) throw new NotImplementedException(); } - public Task GetDirectoryNameAsync(string path) - { - throw new NotImplementedException(); - } - - public Task> GetFilesAsync() + public async Task ExistsAsync(string path) { - throw new NotImplementedException(); - } - - /// - public async Task GetFilesAsync(string path) - { - List list = new(); - using (SftpClient sftp = new SftpClient(_connectionInfo)) - { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) - { - foreach (ISftpFile file in sftp.ListDirectory(path)) - { - list.Add(new SystemFile(file.FullName) - { - Name = file.Name, - RemotePath = file.FullName, - RemoteSize = file.Length - }); - } - } - - sftp.Disconnect(); - } - - return list.ToArray(); + return _client.IsConnected && await _client.ExistsAsync(path); } /// public async Task GetFileAsync(string path) { SystemFile file = new SystemFile(path); - using (SftpClient sftp = new SftpClient(_connectionInfo)) + if (await ExistsAsync(path)) { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) + ISftpFile sf = _client.Get(path); + file = new SystemFile(sf.FullName) { - ISftpFile sf = sftp.Get(path); - file = new SystemFile(sf.FullName) - { - Name = sf.Name, - RemotePath = sf.FullName, - RemoteSize = sf.Length, - }; - } - - sftp.Disconnect(); + Name = sf.Name, + RemoteSize = sf.Length, + }; } return file; } - /// - public async Task PingAsync() - { - CancellationTokenSource cts = new(); - Stopwatch sw = Stopwatch.StartNew(); - using (SftpClient sftp = new SftpClient(_connectionInfo)) - { - await sftp.ConnectAsync(cts.Token); - if (!sftp.IsConnected) return -1; - sftp.Disconnect(); - } - - return sw.ElapsedMilliseconds; - } - /// public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - using SftpClient sftp = new SftpClient(_connectionInfo); - sftp.Connect(); - if (sftp.IsConnected) + for (int i = 0; i < files.Length; i++) { - for (int i = 0; i < files.Length; i++) - { - SystemFile file = files[i]; - Stopwatch sw = new Stopwatch(); - progress.Report(ProgressOperation.Uploading, file, i, files.Length); - if (await sftp.ExistsAsync(file.RemotePath)) sftp.ChangePermissions(file.RemotePath, 644); + SystemFile file = files[i]; + Stopwatch sw = new Stopwatch(); + progress.Report(ProgressOperation.Uploading, file, i, files.Length); + if (await _client.ExistsAsync(file.RemotePath)) _client.ChangePermissions(file.RemotePath, 644); - string parentDir = string.Empty; - foreach (string subPath in file.RemotePath.Split('/')) + string parentDir = string.Empty; + foreach (string subPath in file.RemotePath.Split('/')) + { + parentDir += $"/{subPath}"; + if (!await _client.ExistsAsync(parentDir)) { - parentDir += $"/{subPath}"; - if (!await sftp.ExistsAsync(parentDir)) - { - await sftp.CreateDirectoryAsync(parentDir); - } + await _client.CreateDirectoryAsync(parentDir); } + } - await using SftpFileStream createStream = sftp.Create(file.RemotePath); - await using FileStream openStream = File.OpenRead(file.LocalPath); - await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); - await openStream.CopyToAsync(gzipStream); - sftp.ChangePermissions(file.RemotePath, 444); + await using SftpFileStream createStream = _client.Create(file.RemotePath); + await using FileStream openStream = File.OpenRead(file.LocalPath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream); + _client.ChangePermissions(file.RemotePath, 444); - Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); - } + Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); } - - sftp.Disconnect(); } } } \ No newline at end of file diff --git a/Parallel.Core/IO/PathBuilder.cs b/Parallel.Core/IO/PathBuilder.cs index 99fe668..db16c8a 100644 --- a/Parallel.Core/IO/PathBuilder.cs +++ b/Parallel.Core/IO/PathBuilder.cs @@ -1,6 +1,8 @@ // Copyright 2025 Kyle Ebbinga using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; using Parallel.Core.IO.FileSystem; using Parallel.Core.Models; using Parallel.Core.Settings; @@ -9,15 +11,17 @@ namespace Parallel.Core.IO { /// - /// Represents the way to build paths on different operating systems. + /// Represents the way to build paths on different operating systems. This class cannot be inherited. /// public class PathBuilder { + private static readonly Regex DriveLetterRegex = new(@"^[a-zA-Z]:", RegexOptions.Compiled); + public static string TempDirectory { get { - string tempFolder = Path.Combine(Path.GetTempPath(), $"parallel_{UnixTime.Now.TotalSeconds}"); + string tempFolder = Path.Combine(Path.GetTempPath(), "Parallel"); if (!Directory.Exists(tempFolder)) Directory.CreateDirectory(tempFolder); return tempFolder; } @@ -50,37 +54,90 @@ public static string ProgramData } /// - /// Builds the path for the local file system. + /// Combines an array of strings into a path. This differs from by using the string context for combining paths instead of using the path operator environment variable. /// - /// - /// + /// /// - public static string Local(string path, FileSystemCredentials credentials) + public static string Combine(params string[] paths) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName); - string main = path.Replace("/", "\\").Replace(root, string.Empty).Replace(".gz", string.Empty); + ArgumentNullException.ThrowIfNull(paths); + if (paths.Length == 0) return string.Empty; - Console.WriteLine(root); - Console.WriteLine(main); + // Detect context from the first path + bool isWindowsStyle = DriveLetterRegex.IsMatch(paths[0]); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + char separator = isWindowsStyle ? '\\' : '/'; + char altSeparator = isWindowsStyle ? '/' : '\\'; + + StringBuilder sb = new StringBuilder(); + foreach (string p in paths) { - return main.Substring(1, main.Length - 1).Insert(1, ":"); + if (string.IsNullOrWhiteSpace(p)) continue; + + string part = p.Replace(altSeparator, separator); + + if (sb.Length == 0) + { + sb.Append(part.TrimEnd(separator)); + } + else + { + sb.Append(separator); + sb.Append(part.Trim(separator)); + } } - return main.Replace(@"\", "/"); + return sb.ToString(); } - public static string RootDirectory(VaultConfig vault) + /// + /// Gets the root directory of the vault. + /// + /// + /// + public static string GetRootDirectory(LocalVaultConfig localVault) { - string root = Path.Combine(vault.FileSystem.RootDirectory, "Parallel", vault.Id); - Log.Debug($"Root directory: {root}"); - return vault.FileSystem.Service switch - { - FileService.Local => root, - FileService.Remote => root.Replace('\\', '/'), - _ => string.Empty - }; + return Combine(localVault.FileSystem.RootDirectory, "Parallel", localVault.Id); + } + + /// + /// Gets the primary location where files are stored in the vault. + /// + /// + /// + public static string GetFilesDirectory(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "Files"); + } + + /// + /// Gets the location where snapshots are stored in the vault. + /// + /// + /// + public static string GetSnapshotsDirectory(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "Snapshots"); + } + + /// + /// Gets the path to the vault's configuration file. + /// + /// + /// + public static string GetConfigurationFile(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "config.json"); + } + + /// + /// Gets the path to the vault's database file. + /// + /// + /// + public static string GetDatabaseFile(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "index.db"); } /// @@ -89,10 +146,10 @@ public static string RootDirectory(VaultConfig vault) /// /// /// - public static string Remote(string path, VaultConfig vault) + public static string Remote(string path, RemoteVaultConfig remoteVaultConfig) { - string root = Path.Combine(vault.FileSystem.RootDirectory, "Parallel", vault.Id, "Files", path.Replace(":", string.Empty)) + ".gz"; - return vault.FileSystem.Service switch + string root = Path.Combine(remoteVaultConfig.FileSystem.RootDirectory, "Parallel", remoteVaultConfig.Id, "Files", path.Replace(":", string.Empty)) + ".gz"; + return remoteVaultConfig.FileSystem.Service switch { FileService.Local => root, FileService.Remote => root.Replace('\\', '/'), diff --git a/Parallel.Core/IO/Recovery/RecoveryManager.cs b/Parallel.Core/IO/Recovery/RecoveryManager.cs deleted file mode 100644 index 43f1ad7..0000000 --- a/Parallel.Core/IO/Recovery/RecoveryManager.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Data; -using Parallel.Core.Database; -using Parallel.Core.IO.FileSystem; -using Parallel.Core.Models; -using Parallel.Core.Settings; - -namespace Parallel.Core.IO.Recovery -{ - /// - /// Represents the way to manage recovery points on the system. - /// - public class RecoveryManager - { - private readonly string _dbPath = Path.Combine(PathBuilder.ProgramData, Environment.MachineName + ".db"); - - public IDatabase Database { get; set; } - public IFileSystem FileSystem { get; set; } - public VaultConfig Vault { get; set; } - public string MachineName { get; } = Environment.MachineName; - public string RootFolder { get; set; } - - /// - /// Initializes a new instance of the class. - /// - /// - public RecoveryManager(VaultConfig vault) - { - Vault = vault; - Database = DatabaseConnection.CreateNew(vault); - FileSystem = FileSystemManager.CreateNew(vault); - } - - public bool Initialize() - { - try - { - Database = DatabaseConnection.CreateNew(Vault); - bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; - Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); - if (Vault != null) Vault.SaveToFile(); - return fsInit; - } - catch (Exception ex) - { - Log.Error(ex.GetBaseException().ToString()); - return false; - } - } - - /// - /// Loads a to restore the - /// - /// - public void Load(RecoveryPoint recoveryPoint) - { - - } - - /// - /// Saves the current file system state as a . - /// - /// - public RecoveryPoint Save() - { - /*RecoveryPoint rp = new(Profile.BackupDirectories, Profile.IgnoreDirectories); - DataTable dt = Database.GetFiles(); - foreach (DataRow row in dt.Rows) - { - SystemFile lf = new SystemFile(row); - if (lf.Deleted) - { - rp.DeletedFiles.Add(lf); - } - else - { - rp.LocalFiles.Add(lf); - } - } - - return rp;*/ - return new RecoveryPoint(ArraySegment.Empty, ArraySegment.Empty); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Recovery/RecoveryPoint.cs b/Parallel.Core/IO/Recovery/RecoveryPoint.cs deleted file mode 100644 index ea57c4d..0000000 --- a/Parallel.Core/IO/Recovery/RecoveryPoint.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Models; - -namespace Parallel.Core.IO.Recovery -{ - /// - /// Represents a collection of files at an instance of time on the local machine. - /// - public class RecoveryPoint - { - /// - /// The unique identifier. - /// - public string Id { get; } - - /// - /// The time of creation. - /// - public DateTime CreatedAt { get; } - - /// - /// An array of folders to back up. - /// - public string[] BackupFolders { get; } - - /// - /// An array of folders to ignore. - /// - public string[] IgnoreFolders { get; } - - /// - /// A collection of files that exist in the local machine. - /// - public List LocalFiles { get; } - - /// - /// A collection of deleted files that don't exist on the local machine. - /// - public List DeletedFiles { get; } - - /// - /// Initializes a new instance of the class. - /// - public RecoveryPoint(IEnumerable backupFolders, IEnumerable ignoreFolders) - { - Id = Guid.NewGuid().ToString(); - CreatedAt = DateTime.Now; - BackupFolders = backupFolders.ToArray(); - IgnoreFolders = ignoreFolders.ToArray(); - LocalFiles = new List(); - DeletedFiles = new List(); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 699af81..aea7a6b 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -19,18 +19,15 @@ namespace Parallel.Core.IO.Scanning /// public class FileScanner { - private readonly VaultConfig _vault; private readonly IDatabase _db; - public FileScanner(VaultConfig vault, IDatabase database) + public FileScanner(IDatabase database) { - _vault = vault; _db = database; } public FileScanner(ISyncManager sync) { - _vault = sync.Vault; _db = sync.Database; } diff --git a/Parallel.Core/IO/Syncing/BaseSyncManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs index c2f3362..d39ea53 100644 --- a/Parallel.Core/IO/Syncing/BaseSyncManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -2,7 +2,6 @@ using Parallel.Core.Database; using Parallel.Core.Diagnostics; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.FileSystem; using Parallel.Core.Models; using Parallel.Core.Settings; @@ -14,8 +13,15 @@ namespace Parallel.Core.IO.Syncing /// public abstract class BaseSyncManager : ISyncManager { + protected string TempDirectory = PathBuilder.TempDirectory; + protected string TempConfigFile => Path.Combine(TempDirectory, $"{LocalVault.Id}.json"); + protected string TempDbFile => Path.Combine(TempDirectory, $"{LocalVault.Id}.db"); + + /// + public LocalVaultConfig LocalVault { get; private set; } + /// - public VaultConfig Vault { get; } + public RemoteVaultConfig RemoteVault { get; private set; } /// public IDatabase Database { get; set; } @@ -26,23 +32,45 @@ public abstract class BaseSyncManager : ISyncManager /// /// /// - /// - public BaseSyncManager(VaultConfig vault) + /// + public BaseSyncManager(LocalVaultConfig localVault) + { + FileSystem = FileSystemManager.CreateNew(localVault); + LocalVault = localVault; + } + + /// + public void Dispose() { - FileSystem = FileSystemManager.CreateNew(vault); - Vault = vault; + FileSystem.Dispose(); } /// - public virtual bool Initialize() + public async Task InitializeAsync() { try { - Database = DatabaseConnection.CreateNew(Vault); - FileSystem.CreateDirectoryAsync(PathBuilder.RootDirectory(Vault)); - Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); - Vault.SaveToFile(); - return FileSystem.PingAsync().Result >= 0; + string root = PathBuilder.GetRootDirectory(LocalVault); + if (!await FileSystem.ExistsAsync(root)) + { + // Creates the root directory and default configuration. + await FileSystem.CreateDirectoryAsync(root); + RemoteVault = new RemoteVaultConfig(LocalVault); + } + else + { + SystemFile[] files = + [ + new SystemFile(TempConfigFile) { RemotePath = PathBuilder.GetConfigurationFile(LocalVault) }, + new SystemFile(TempDbFile) { RemotePath = PathBuilder.GetDatabaseFile(LocalVault) }, + ]; + + await FileSystem.DownloadFilesAsync(files, new ProgressLogger()); + } + + Database = new SqliteContext(LocalVault); + RemoteVault.IgnoreDirectories.Add(PathBuilder.GetRootDirectory(LocalVault)); + return true; } catch (Exception ex) { @@ -51,6 +79,15 @@ public virtual bool Initialize() } } + /// + public Task DisconnectAsync() + { + string configFile = PathBuilder.GetConfigurationFile(LocalVault); + + FileSystem.Dispose(); + return Task.CompletedTask; + } + /// public abstract Task PushFilesAsync(SystemFile[] files, IProgressReporter progress); diff --git a/Parallel.Core/IO/Syncing/DeltaSyncManager.cs b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs index 10663d6..a9155a8 100644 --- a/Parallel.Core/IO/Syncing/DeltaSyncManager.cs +++ b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs @@ -14,8 +14,8 @@ public class DeltaSyncManager : BaseSyncManager /// /// Initializes a new instance of the class. /// - /// - public DeltaSyncManager(VaultConfig vault) : base(vault) { } + /// + public DeltaSyncManager(RemoteVaultConfig remoteVaultConfig) : base(remoteVaultConfig) { } /// public override Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index 57ecf45..66cfe71 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -16,14 +16,14 @@ namespace Parallel.Core.IO.Backup /// public class FileSyncManager : BaseSyncManager { - private List _tasks = new List(); - private int _totalFiles; - /// /// Initializes a new instance of the class. /// - /// - public FileSyncManager(VaultConfig vault) : base(vault) { } + /// + public FileSyncManager(LocalVaultConfig localVault) : base(localVault) + { + + } /// public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) @@ -32,8 +32,12 @@ public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); Log.Information($"Backing up {backupFiles.Length} files..."); await FileSystem.UploadFilesAsync(backupFiles, progress); + await System.Threading.Tasks.Parallel.ForEachAsync(files, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount / 2 }, async (file, ct) => + { + + }); + - Console.WriteLine($"Successfully pushed {backupFiles.Length} files.", ConsoleColor.Green); for (int i = 0; i < files.Length; i++) { SystemFile file = files.ElementAt(i); @@ -64,13 +68,6 @@ public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter if (!restoreFiles.Any()) return; await FileSystem.DownloadFilesAsync(restoreFiles, progress); - - for (int i = 0; i < files.Length; i++) - { - SystemFile file = files[i]; - Log.Information($"Restoring file: {file.LocalPath}..."); - file.RemotePath = PathBuilder.Remote(file.LocalPath, Vault); - } } } } \ No newline at end of file diff --git a/Parallel.Core/IO/Syncing/ISyncManager.cs b/Parallel.Core/IO/Syncing/ISyncManager.cs index 21a5641..9092bce 100644 --- a/Parallel.Core/IO/Syncing/ISyncManager.cs +++ b/Parallel.Core/IO/Syncing/ISyncManager.cs @@ -11,12 +11,17 @@ namespace Parallel.Core.IO.Syncing /// /// Defines the methods needed for backing up a file system. /// - public interface ISyncManager + public interface ISyncManager : IDisposable { /// - /// The back-up connection vault. + /// Gets the local vault configuration. /// - public VaultConfig Vault { get; } + public LocalVaultConfig LocalVault { get; } + + /// + /// Gets the remote vault configuration. + /// + public RemoteVaultConfig RemoteVault { get; } /// /// The associated database connection. @@ -29,10 +34,9 @@ public interface ISyncManager IFileSystem FileSystem { get; set; } /// - /// Initializes the backup manager by logging into the and + /// Initializes the associated and downloads the needed files. /// - /// - bool Initialize(); + Task InitializeAsync(); /// /// Pushes an array of files to a vault. diff --git a/Parallel.Core/IO/Syncing/SyncManager.cs b/Parallel.Core/IO/Syncing/SyncManager.cs index 4b5124f..49f4b8c 100644 --- a/Parallel.Core/IO/Syncing/SyncManager.cs +++ b/Parallel.Core/IO/Syncing/SyncManager.cs @@ -19,11 +19,11 @@ public static class SyncManager /// /// Creates a new instance of an . /// - /// + /// /// - public static ISyncManager CreateNew(VaultConfig vault) + public static ISyncManager CreateNew(LocalVaultConfig localVault) { - return new FileSyncManager(vault); + return new FileSyncManager(localVault); } } } \ No newline at end of file diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index 514abbd..5e2a144 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -156,6 +156,11 @@ public SystemFile(string vault, string id, string name, string localpath, string CheckSum = checksum; } + /// + /// Determines if this instance and another have the same values. + /// + /// + /// True if equal, otherwise false. public bool Equals(SystemFile value) { bool?[] results = diff --git a/Parallel.Core/Security/Encryption.cs b/Parallel.Core/Security/Encryption.cs index 3a7fc45..d49edcf 100644 --- a/Parallel.Core/Security/Encryption.cs +++ b/Parallel.Core/Security/Encryption.cs @@ -2,10 +2,9 @@ using System.Security.Cryptography; using System.Text; -using Parallel.Core.Models; -using Parallel.Core.Security; +using Parallel.Core.Utils; -namespace Parallel.Core.Utils +namespace Parallel.Core.Security { /// /// Provides functionality for encryption. This class cannot be inherited. diff --git a/Parallel.Core/Settings/DatabaseCredentials.cs b/Parallel.Core/Settings/DatabaseCredentials.cs deleted file mode 100644 index 761427b..0000000 --- a/Parallel.Core/Settings/DatabaseCredentials.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Database; -using Parallel.Core.IO; - -namespace Parallel.Core.Settings -{ - /// - /// Represents credentials used to gain access to various s. - /// - public class DatabaseCredentials - { - /// - /// The associated provider of this database. - /// - public DatabaseProvider Provider { get; set; } = DatabaseProvider.Local; - - /// - /// The hostname or address of the database. - /// If using a , this will be a file path. - /// - public string Address { get; set; } = string.Empty; - - /// - /// The username of the database. - /// - public string? Username { get; set; } - - /// - /// The password of the database. - /// - public string? Password { get; set; } - - /// - /// The database name. - /// - public string Name { get; set; } = string.Empty; - - public static DatabaseCredentials Local => new() - { - Provider = DatabaseProvider.Local, - Address = Path.Combine(PathBuilder.ProgramData, $"{Environment.MachineName}.db") - }; - } -} \ No newline at end of file diff --git a/Parallel.Core/Settings/LocalVaultConfig.cs b/Parallel.Core/Settings/LocalVaultConfig.cs new file mode 100644 index 0000000..a5a405d --- /dev/null +++ b/Parallel.Core/Settings/LocalVaultConfig.cs @@ -0,0 +1,67 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Security; + +namespace Parallel.Core.Settings +{ + /// + /// Represents a localized vault connection configuration. + /// + public class LocalVaultConfig + { + /// + /// A unique hash used to identify the vault. + /// + public string Id { get; } = HashGenerator.GenerateHash(12, true); + + /// + /// The name of the vault. + /// + public string Name { get; set; } = "Default"; + + /// + /// The credentials needed to log in to the associated . + /// + public FileSystemCredentials FileSystem { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + [JsonConstructor] + public LocalVaultConfig(string id, string name, FileSystemCredentials fileSystem) + { + Id = id; + Name = name; + FileSystem = fileSystem; + } + + public LocalVaultConfig(string name, FileSystemCredentials fileSystem) + { + Id = HashGenerator.GenerateHash(12, true); + Name = name; + FileSystem = fileSystem; + } + + /// + /// Loads settings from a file. + /// + public static LocalVaultConfig? Load(string path) + { + return !File.Exists(path) ? null : JsonConvert.DeserializeObject(File.ReadAllText(path)); + } + + /// + /// Saves credentials to a file. + /// + /// + /// + public static void Save(ParallelConfig config, LocalVaultConfig localVault) + { + config.Vaults.Add(localVault); + config.Save(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Settings/ParallelConfig.cs b/Parallel.Core/Settings/ParallelConfig.cs new file mode 100644 index 0000000..2083801 --- /dev/null +++ b/Parallel.Core/Settings/ParallelConfig.cs @@ -0,0 +1,101 @@ +// Copyright 2025 Kyle Ebbinga + +using Newtonsoft.Json; +using Org.BouncyCastle.Math.EC; +using Parallel.Core.IO; + +namespace Parallel.Core.Settings +{ + /// + /// + /// + public class ParallelConfig + { + /// + /// The location to the application configuration file. + /// + private static string ConfigFile { get; } = Path.Combine(PathBuilder.ProgramData, "Configuration.json"); + + /// + /// The location of files for different file system credentials./>. + /// + public static string VaultsDir { get; } = Path.Combine(PathBuilder.ProgramData, "Vaults"); + + // /// + // /// The address that will accept incoming commands. + // /// Default: 127.0.0.1 + // /// + // public string Address { get; set; } = "127.0.0.1"; + // + // /// + // /// The port number to listen for commands on. + // /// Default: 8192 + // /// + // public int ListenerPort { get; set; } = 8192; + + /// + /// Gets or sets the maximum number of concurrent vaults that can run. + /// Default: 2 + /// + public int MaxConcurrentVaults { get; set; } = 2; + + /// + /// Gets or sets the maximum number of concurrent processes that can run. + /// Default: Half the processor count. + /// + public int MaxConcurrentProcesses { get; set; } = Environment.ProcessorCount / 2; + + /// + /// The profiles to use. + /// When pulling, the CLI defaults to the first in the list. + /// + public HashSet Vaults { get; } = []; + + + /// + /// Loads settings from a file. + /// + public static ParallelConfig Load() + { + Log.Debug($"Loading config file: {ConfigFile}"); + if (File.Exists(ConfigFile)) + { + string json = File.ReadAllText(ConfigFile); + return JsonConvert.DeserializeObject(json); + } + else + { + return new ParallelConfig(); + } + } + + /// + /// Saves settings to a file. + /// + public void Save() + { + Log.Debug($"Saving config file: {ConfigFile}"); + if (!Directory.Exists(PathBuilder.ProgramData)) Directory.CreateDirectory(PathBuilder.ProgramData); + File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(this, Formatting.Indented)); + } + + /// + /// Asynchronously runs an for each using the limiter. + /// + /// + /// + public async Task ForEachVaultAsync(Func actionAsync, CancellationToken cancellationToken = default) + { + ParallelOptions options = new ParallelOptions + { + MaxDegreeOfParallelism = MaxConcurrentVaults, + CancellationToken = cancellationToken + }; + + await System.Threading.Tasks.Parallel.ForEachAsync(Vaults, options, async (vault, ct) => + { + await actionAsync(vault); + }); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs deleted file mode 100644 index d323473..0000000 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json; -using Org.BouncyCastle.Math.EC; -using Parallel.Core.IO; - -namespace Parallel.Core.Settings -{ - /// - /// - /// - public class ParallelSettings - { - /// - /// The location to the application configuration file. - /// - private static string ConfigFile { get; } = Path.Combine(PathBuilder.ProgramData, "settings.json"); - - /// - /// The location of files for different file system credentials./>. - /// - public static string VaultsDir { get; } = Path.Combine(PathBuilder.ProgramData, "Vaults"); - - /// - /// The address that will accept incoming commands. - /// Default: 127.0.0.1 - /// - public string Address { get; set; } = "127.0.0.1"; - - /// - /// The port number to listen for commands on. - /// Default: 8192 - /// - public int ListenerPort { get; set; } = 8192; - - /// - /// The profiles to use. - /// The CLI defaults to the first in the list. - /// - public HashSet Vaults { get; } = new HashSet(); - - - /// - /// Loads settings from a file. - /// - public static ParallelSettings Load() - { - Log.Debug($"Loading config file: {ConfigFile}"); - if (File.Exists(ConfigFile)) - { - string json = File.ReadAllText(ConfigFile); - return JsonConvert.DeserializeObject(json); - } - else - { - return new ParallelSettings(); - } - } - - /// - /// Saves settings to a file. - /// - public void Save() - { - Log.Debug($"Saving config file: {ConfigFile}"); - if (!Directory.Exists(PathBuilder.ProgramData)) Directory.CreateDirectory(PathBuilder.ProgramData); - File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(this, Formatting.Indented)); - } - - /// - /// - /// - /// - public void ForEachVault(Action action) - { - foreach (string path in Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly)) - { - VaultConfig? vault = VaultConfig.Load(path); - if (vault != null) action(vault); - } - } - - /// - /// Asynchronously runs an for each with a default of 3 at a time. - /// - /// - /// - public static async Task ForEachVaultAsync(Func actionAsync, int maxDegreeOfParallelism = 3) - { - string[] vaultPaths = Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly); - SemaphoreSlim semaphore = new SemaphoreSlim(maxDegreeOfParallelism); - IEnumerable tasks = vaultPaths.Select(path => Task.Run(async () => - { - await semaphore.WaitAsync(); - try - { - VaultConfig? vault = VaultConfig.Load(path); - if (vault != null) - { - await actionAsync(vault); - } - } - finally - { - semaphore.Release(); - } - })); - - await Task.WhenAll(tasks); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/Settings/VaultConfig.cs b/Parallel.Core/Settings/RemoteVaultConfig.cs similarity index 52% rename from Parallel.Core/Settings/VaultConfig.cs rename to Parallel.Core/Settings/RemoteVaultConfig.cs index c1a480d..a9dab5a 100644 --- a/Parallel.Core/Settings/VaultConfig.cs +++ b/Parallel.Core/Settings/RemoteVaultConfig.cs @@ -12,30 +12,10 @@ namespace Parallel.Core.Settings { /// - /// Represents a back-up connection. + /// Represents the configuration for the vault. /// - public class VaultConfig + public class RemoteVaultConfig : LocalVaultConfig { - /// - /// A unique hash used to identify the vault. - /// - public string Id { get; } = HashGenerator.GenerateHash(12, true); - - /// - /// The name of the vault. - /// - public string Name { get; set; } = "Default"; - - /// - /// The credentials needed to log in to the associated . - /// - public DatabaseCredentials Database { get; } - - /// - /// The credentials needed to log in to the associated . - /// - public FileSystemCredentials FileSystem { get; } - /// /// The amount of time, in minutes, between backup cycles. /// Default: 60 minutes @@ -78,84 +58,11 @@ public class VaultConfig /// Recommended when using a cloud-based to save on storage costs. /// Default: Empty /// - public HashSet PruneDirectories { get; } = new HashSet(); - - - /// - /// Initializes a new instance of the class. - /// - /// - /// - /// - /// - [JsonConstructor] - public VaultConfig(string id, string name, DatabaseCredentials database, FileSystemCredentials fileSystem) - { - Id = id; - Name = name; - Database = database; - FileSystem = fileSystem; - } - - /// - /// Initializes a new instance of the class. - /// - /// - /// - /// - public VaultConfig(string name, DatabaseCredentials database, FileSystemCredentials fileSystem) - { - Id = HashGenerator.GenerateHash(12, true); - Name = name; - Database = database; - FileSystem = fileSystem; - } - - /// - /// Loads settings from a file. - /// - public static VaultConfig? Load(string path) - { - if (!File.Exists(path)) return null; - string json = File.ReadAllText(path); - return JsonConvert.DeserializeObject(json); - } - - /// - /// Loads credentials from the app configuration. - /// - /// A instance. - public static VaultConfig? Load(ParallelSettings settings, string name) - { - VaultConfig? vault = Load(settings.Vaults.First()); - return string.IsNullOrEmpty(name) ? vault : Load(Path.Combine(ParallelSettings.VaultsDir, name + ".json")); - } - - /// - /// Saves credentials to a file. - /// - /// The current vault to save. - public static void Save(VaultConfig vault) - { - if (!Directory.Exists(ParallelSettings.VaultsDir)) Directory.CreateDirectory(ParallelSettings.VaultsDir); - string path = Path.Combine(ParallelSettings.VaultsDir, vault.Name + ".json"); - Log.Debug($"Saving vault file: {path}"); - if (!File.Exists(path)) - { - Log.Debug("Creating file -> " + path); - File.Create(path).Close(); - } + public HashSet PruneDirectories { get; } = []; - File.WriteAllText(path, JsonConvert.SerializeObject(vault, Formatting.Indented)); - } + public RemoteVaultConfig(LocalVaultConfig localVault) : base(localVault.Id, localVault.Name, localVault.FileSystem) { } - /// - /// Saves the current instance to a file. - /// - public void SaveToFile() - { - Save(this); - } + public RemoteVaultConfig(string profileName, FileSystemCredentials fsc) : base(profileName, fsc) { } #region Privates From 637c1fba3caea17a6fbc9a8cc646bd2e3dea4dd7 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 2 Sep 2025 02:58:22 -0500 Subject: [PATCH 17/33] IT BUILDS --- Parallel.Cli/Commands/HistoryCommand.cs | 139 ------------------ Parallel.Cli/Commands/PushCommand.cs | 17 +-- Parallel.Cli/Program.cs | 5 +- .../Database/Contexts/SqliteContext.cs | 6 +- .../IO/FileSystem/DotNetFileSystem.cs | 47 +++--- Parallel.Core/IO/PathBuilder.cs | 5 +- Parallel.Core/IO/Syncing/BaseSyncManager.cs | 67 +++++---- Parallel.Core/IO/Syncing/FileSyncManager.cs | 3 + Parallel.Core/IO/Syncing/ISyncManager.cs | 11 +- Parallel.Core/Models/SystemFile.cs | 2 + Parallel.Core/Settings/LocalVaultConfig.cs | 2 +- Parallel.Core/Settings/ParallelConfig.cs | 7 +- Parallel.Core/Settings/RemoteVaultConfig.cs | 26 ++++ 13 files changed, 122 insertions(+), 215 deletions(-) delete mode 100644 Parallel.Cli/Commands/HistoryCommand.cs diff --git a/Parallel.Cli/Commands/HistoryCommand.cs b/Parallel.Cli/Commands/HistoryCommand.cs deleted file mode 100644 index e18e8bf..0000000 --- a/Parallel.Cli/Commands/HistoryCommand.cs +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2025 Entex Interactive, LLC - -using System.CommandLine; -using System.Data; -using Parallel.Cli.Utils; -using Parallel.Core.Database; -using Parallel.Core.IO; -using Parallel.Core.IO.Backup; -using Parallel.Core.Models; -using Parallel.Core.Settings; -using Parallel.Core.Utils; -using Formatter = Parallel.Cli.Utils.Formatter; - -namespace Parallel.Cli.Commands -{ - public class HistoryCommand : Command - { - private const int Limit = 25; - - private Command _pullCmd = new("pull", "Shows the history related to pulling files from vaults."); - private Command _pushCmd = new("push", "Shows the history related to pushing files from vaults."); - private Command _deleteCmd = new("archive", "Shows the history related to file deletions."); - private Command _cleanCmd = new("cleaned", "Shows the history related to file cleaning."); - private Command _cloneCmd = new("cloned", "Shows the history related to file cloning."); - private Command _pruneCmd = new("pruned", "Shows the history related to file pruning."); - - private Option _sourceOpt = new(["--path", "-p"], "The source path."); - private Option _vaultOpt = new(["--vault", "-v"], "The vault to use."); - private Option _limitOpt = new(["--limit", "-l"], "The number of entries to show."); - - public HistoryCommand() : base("history", "Shows the history of files related to the archive.") - { - this.AddOption(_sourceOpt); - this.AddOption(_vaultOpt); - this.AddOption(_limitOpt); - this.AddCommand(_pullCmd); - this.AddCommand(_pushCmd); - this.AddCommand(_deleteCmd); - this.AddCommand(_cleanCmd); - this.AddCommand(_cloneCmd); - this.AddCommand(_pruneCmd); - this.SetHandler((path, config, limit) => - { - CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); - - if (limit == 0) limit = Limit; - DisplayHistories(db?.GetHistory(path, limit).ToArray()); - }, _sourceOpt, _vaultOpt, _limitOpt); - - _pushCmd.AddOption(_sourceOpt); - _pushCmd.AddOption(_vaultOpt); - _pushCmd.AddOption(_limitOpt); - _pushCmd.SetHandler((path, config, limit) => - { - CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); - - if (limit == 0) limit = Limit; - DisplayHistories(db?.GetHistory(path, HistoryType.Pushed, limit).ToArray()); - }, _sourceOpt, _vaultOpt, _limitOpt); - - _deleteCmd.AddOption(_sourceOpt); - _deleteCmd.AddOption(_vaultOpt); - _deleteCmd.AddOption(_limitOpt); - _deleteCmd.SetHandler((path, config, limit) => - { - CommandLine.WriteLine($"Retrieving archive information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); - - if (limit == 0) limit = Limit; - DisplayHistories(db?.GetHistory(path, HistoryType.Archived, limit).ToArray()); - }, _sourceOpt, _vaultOpt, _limitOpt); - - _cleanCmd.AddOption(_sourceOpt); - _cleanCmd.AddOption(_vaultOpt); - _cleanCmd.AddOption(_limitOpt); - _cleanCmd.SetHandler((path, config, limit) => - { - CommandLine.WriteLine($"Retrieving clean information...", ConsoleColor.DarkGray); - IDatabase? db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); - - if (limit == 0) limit = Limit; - DisplayHistories(db?.GetHistory(path, HistoryType.Cleaned, limit).ToArray()); - }, _sourceOpt, _vaultOpt, _limitOpt); - - _cloneCmd.AddOption(_sourceOpt); - _cloneCmd.AddOption(_vaultOpt); - _cloneCmd.AddOption(_limitOpt); - _cloneCmd.SetHandler((path, config, limit) => - { - CommandLine.WriteLine($"Retrieving clone information...", ConsoleColor.DarkGray); - IDatabase db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); - - if (limit == 0) limit = Limit; - DisplayHistories(db?.GetHistory(path, HistoryType.Cloned, limit).ToArray()); - }, _sourceOpt, _vaultOpt, _limitOpt); - - _pruneCmd.AddOption(_sourceOpt); - _pruneCmd.AddOption(_vaultOpt); - _pruneCmd.AddOption(_limitOpt); - _pruneCmd.SetHandler((path, config, limit) => - { - CommandLine.WriteLine($"Retrieving prune information...", ConsoleColor.DarkGray); - IDatabase db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); - - if (limit == 0) limit = Limit; - DisplayHistories(db?.GetHistory(path, HistoryType.Pruned, limit).ToArray()); - }, _sourceOpt, _vaultOpt, _limitOpt); - - _pullCmd.AddOption(_sourceOpt); - _pullCmd.AddOption(_vaultOpt); - _pullCmd.AddOption(_limitOpt); - _pullCmd.SetHandler((path, config, limit) => - { - CommandLine.WriteLine($"Retrieving restore information...", ConsoleColor.DarkGray); - IDatabase db = DatabaseConnection.CreateNew(RemoteVaultConfig.Load(Program.Settings, config)); - - if (limit == 0) limit = Limit; - DisplayHistories(db?.GetHistory(path, HistoryType.Pulled, limit).ToArray()); - }, _sourceOpt, _vaultOpt, _limitOpt); - } - - private void DisplayHistories(HistoryEvent[]? histories) - { - if (histories?.Length == 0) - { - CommandLine.WriteLine("No backup history found!", ConsoleColor.Yellow); - return; - } - - foreach (HistoryEvent history in histories.ToArray()) - { - string typeStr = (history.Type + ":").PadRight(9); - CommandLine.WriteLine($"[{Formatter.FromDateTime(history.CreatedAt.ToLocalTime())}] <{history.Vault}> {typeStr} {history.Fullname}", ConsoleColor.White); - } - } - } -} \ No newline at end of file diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index 49e3bfc..97b869d 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -47,10 +47,10 @@ private async Task SyncSystemAsync() private async Task SyncPathAsync(string path) { - await ParallelConfig.ForEachVaultAsync(async vault => + await Program.Settings.ForEachVaultAsync(async vault => { - ISyncManager sync = SyncManager.CreateNew(vault); - if (!await sync.InitializeAsync()) + FileSyncManager syncManager = new FileSyncManager(vault); + if (!await syncManager.ConnectAsync()) { CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); return; @@ -58,8 +58,8 @@ await ParallelConfig.ForEachVaultAsync(async vault => // Normalize paths for safe comparison string fullPath = Path.GetFullPath(path); - string[] backupFolders = vault.BackupDirectories.ToArray(); - string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); + string[] backupFolders = syncManager.RemoteVault.BackupDirectories.ToArray(); + string[] ignoredFolders = syncManager.RemoteVault.IgnoreDirectories.ToArray(); bool isFile = PathBuilder.IsFile(fullPath); if (!backupFolders.Any(dir => fullPath.StartsWith(dir, StringComparison.OrdinalIgnoreCase))) @@ -75,8 +75,7 @@ await ParallelConfig.ForEachVaultAsync(async vault => } CommandLine.WriteLine(vault, $"Scanning for file changes in {path}...", ConsoleColor.DarkGray); - - FileScanner scanner = new FileScanner(sync); + FileScanner scanner = new FileScanner(syncManager); SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); int successFiles = files.Length; if (successFiles == 0) @@ -86,10 +85,10 @@ await ParallelConfig.ForEachVaultAsync(async vault => } CommandLine.WriteLine(vault, $"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); - await sync.PushFilesAsync(files, new ProgressReport(vault)); + await syncManager.PushFilesAsync(files, new ProgressReport(vault, successFiles)); + await syncManager.DisconnectAsync(); CommandLine.WriteLine(vault, $"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); - await sync.DisconnectAsync(); }); } } diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index 7369bd9..b5b3c43 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -14,7 +14,10 @@ internal class Program public static async Task Main(string[] args) { Settings = ParallelConfig.Load(); - string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log"); + //string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log"); + + string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", "latest.txt"); + if (File.Exists(logFile)) File.Delete(logFile); Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.File(logFile).CreateLogger(); AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index aaf6525..c691094 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -22,9 +22,9 @@ public class SqliteContext : IDatabase /// /// /// - public SqliteContext(LocalVaultConfig localVault) + public SqliteContext(string filePath) { - FilePath = PathBuilder.GetDatabaseFile(localVault); + FilePath = filePath; } #region Base @@ -40,7 +40,7 @@ public async Task InitializeAsync() { Log.Information("Creating index database..."); File.Create(FilePath).Close(); - File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); + //File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `checksum` TEXT, PRIMARY KEY(`id`));"); diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index d608956..fea83db 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -57,18 +57,16 @@ public Task DeleteFileAsync(string path) return Task.CompletedTask; } - public Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) - { - throw new NotImplementedException(); - } - /// - public async Task DownloadFileAsync(string sourcePath, string destPath) + public async Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) { - await using FileStream openStream = File.OpenRead(sourcePath); - await using FileStream createStream = File.Create(destPath); - await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); - await gzipStream.CopyToAsync(createStream); + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + { + await using FileStream openStream = File.OpenRead(file.RemotePath); + await using FileStream createStream = File.Create(file.LocalPath); + await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(createStream, ct); + }); } /// @@ -89,29 +87,22 @@ public Task GetFileAsync(string path) }); } + /// public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - await System.Threading.Tasks.Parallel.ForEachAsync(files, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount / 2 }, async (file, ct) => + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => { + if (await ExistsAsync(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); + string? parent = Path.GetDirectoryName(file.RemotePath); + if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); - }); - } + await using FileStream openStream = File.OpenRead(file.LocalPath); + await using FileStream createStream = File.Create(file.RemotePath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream, ct); - /// - public async Task UploadFileAsync(string sourcePath, string destPath) - { - - - if (await ExistsAsync(destPath)) File.SetAttributes(destPath, ~FileAttributes.ReadOnly & File.GetAttributes(destPath)); - string? parent = Path.GetDirectoryName(destPath); - if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); - - await using FileStream openStream = File.OpenRead(sourcePath); - await using FileStream createStream = File.Create(destPath); - await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); - await openStream.CopyToAsync(gzipStream); - - File.SetAttributes(destPath, File.GetAttributes(destPath) | FileAttributes.ReadOnly); + File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); + }); } } } \ No newline at end of file diff --git a/Parallel.Core/IO/PathBuilder.cs b/Parallel.Core/IO/PathBuilder.cs index db16c8a..4cf7b6d 100644 --- a/Parallel.Core/IO/PathBuilder.cs +++ b/Parallel.Core/IO/PathBuilder.cs @@ -22,6 +22,7 @@ public static string TempDirectory get { string tempFolder = Path.Combine(Path.GetTempPath(), "Parallel"); + Log.Debug($"Temp directory: {tempFolder}"); if (!Directory.Exists(tempFolder)) Directory.CreateDirectory(tempFolder); return tempFolder; } @@ -127,7 +128,7 @@ public static string GetSnapshotsDirectory(LocalVaultConfig localVault) /// public static string GetConfigurationFile(LocalVaultConfig localVault) { - return Combine(GetRootDirectory(localVault), "config.json"); + return Combine(GetRootDirectory(localVault), "config.json.gz"); } /// @@ -137,7 +138,7 @@ public static string GetConfigurationFile(LocalVaultConfig localVault) /// public static string GetDatabaseFile(LocalVaultConfig localVault) { - return Combine(GetRootDirectory(localVault), "index.db"); + return Combine(GetRootDirectory(localVault), "index.db.gz"); } /// diff --git a/Parallel.Core/IO/Syncing/BaseSyncManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs index d39ea53..264c1fe 100644 --- a/Parallel.Core/IO/Syncing/BaseSyncManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -39,37 +39,11 @@ public BaseSyncManager(LocalVaultConfig localVault) LocalVault = localVault; } - /// - public void Dispose() - { - FileSystem.Dispose(); - } - /// public async Task InitializeAsync() { try { - string root = PathBuilder.GetRootDirectory(LocalVault); - if (!await FileSystem.ExistsAsync(root)) - { - // Creates the root directory and default configuration. - await FileSystem.CreateDirectoryAsync(root); - RemoteVault = new RemoteVaultConfig(LocalVault); - } - else - { - SystemFile[] files = - [ - new SystemFile(TempConfigFile) { RemotePath = PathBuilder.GetConfigurationFile(LocalVault) }, - new SystemFile(TempDbFile) { RemotePath = PathBuilder.GetDatabaseFile(LocalVault) }, - ]; - - await FileSystem.DownloadFilesAsync(files, new ProgressLogger()); - } - - Database = new SqliteContext(LocalVault); - RemoteVault.IgnoreDirectories.Add(PathBuilder.GetRootDirectory(LocalVault)); return true; } catch (Exception ex) @@ -79,11 +53,48 @@ public async Task InitializeAsync() } } + public async Task ConnectAsync() + { + string root = PathBuilder.GetRootDirectory(LocalVault); + if (!await FileSystem.ExistsAsync(root)) + { + await FileSystem.CreateDirectoryAsync(root); + Log.Debug($"Created root directory: {root}"); + } + + if (!await FileSystem.ExistsAsync(PathBuilder.GetConfigurationFile(LocalVault))) + { + RemoteVault = new RemoteVaultConfig(LocalVault); + RemoteVault.IgnoreDirectories.Add(PathBuilder.GetRootDirectory(LocalVault)); + RemoteVault.Save(TempConfigFile); + + Log.Debug($"Created config file: {TempConfigFile}"); + } + else + { + await FileSystem.DownloadFilesAsync([new SystemFile { LocalPath = TempConfigFile, RemotePath = PathBuilder.GetConfigurationFile(LocalVault) }], new ProgressLogger()); + RemoteVaultConfig? config = RemoteVaultConfig.Load(TempConfigFile); + if(config == null) return false; + RemoteVault = config; + } + + if (!await FileSystem.ExistsAsync(PathBuilder.GetDatabaseFile(LocalVault))) + { + Database = new SqliteContext(TempDbFile); + await Database.InitializeAsync(); + } + else + { + await FileSystem.DownloadFilesAsync([new SystemFile { LocalPath = TempDbFile, RemotePath = PathBuilder.GetDatabaseFile(LocalVault) }], new ProgressLogger()); + Database = new SqliteContext(TempDbFile); + } + + return true; + } + /// public Task DisconnectAsync() { - string configFile = PathBuilder.GetConfigurationFile(LocalVault); - FileSystem.Dispose(); return Task.CompletedTask; } diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index 66cfe71..3e6304a 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -16,6 +16,9 @@ namespace Parallel.Core.IO.Backup /// public class FileSyncManager : BaseSyncManager { + protected string[] BackupDirectories => RemoteVault.BackupDirectories.ToArray(); + protected string[] IgnoreDirectories => RemoteVault.IgnoreDirectories.ToArray(); + /// /// Initializes a new instance of the class. /// diff --git a/Parallel.Core/IO/Syncing/ISyncManager.cs b/Parallel.Core/IO/Syncing/ISyncManager.cs index 9092bce..15d1395 100644 --- a/Parallel.Core/IO/Syncing/ISyncManager.cs +++ b/Parallel.Core/IO/Syncing/ISyncManager.cs @@ -11,7 +11,7 @@ namespace Parallel.Core.IO.Syncing /// /// Defines the methods needed for backing up a file system. /// - public interface ISyncManager : IDisposable + public interface ISyncManager { /// /// Gets the local vault configuration. @@ -34,9 +34,14 @@ public interface ISyncManager : IDisposable IFileSystem FileSystem { get; set; } /// - /// Initializes the associated and downloads the needed files. + /// Establishes a connection to the associated and downloads the needed files. /// - Task InitializeAsync(); + Task ConnectAsync(); + + /// + /// Closes the current connection and releases its resources. + /// + Task DisconnectAsync(); /// /// Pushes an array of files to a vault. diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index 5e2a144..a98dab6 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -156,6 +156,8 @@ public SystemFile(string vault, string id, string name, string localpath, string CheckSum = checksum; } + public SystemFile() { } + /// /// Determines if this instance and another have the same values. /// diff --git a/Parallel.Core/Settings/LocalVaultConfig.cs b/Parallel.Core/Settings/LocalVaultConfig.cs index a5a405d..9d9ef39 100644 --- a/Parallel.Core/Settings/LocalVaultConfig.cs +++ b/Parallel.Core/Settings/LocalVaultConfig.cs @@ -40,7 +40,7 @@ public LocalVaultConfig(string id, string name, FileSystemCredentials fileSystem public LocalVaultConfig(string name, FileSystemCredentials fileSystem) { - Id = HashGenerator.GenerateHash(12, true); + Id = HashGenerator.GenerateHash(8, true); Name = name; FileSystem = fileSystem; } diff --git a/Parallel.Core/Settings/ParallelConfig.cs b/Parallel.Core/Settings/ParallelConfig.cs index 2083801..ada6eb7 100644 --- a/Parallel.Core/Settings/ParallelConfig.cs +++ b/Parallel.Core/Settings/ParallelConfig.cs @@ -21,6 +21,11 @@ public class ParallelConfig /// public static string VaultsDir { get; } = Path.Combine(PathBuilder.ProgramData, "Vaults"); + public static ParallelOptions Options { get; } = new ParallelOptions + { + MaxDegreeOfParallelism = Load().MaxConcurrentProcesses + }; + // /// // /// The address that will accept incoming commands. // /// Default: 127.0.0.1 @@ -43,7 +48,7 @@ public class ParallelConfig /// Gets or sets the maximum number of concurrent processes that can run. /// Default: Half the processor count. /// - public int MaxConcurrentProcesses { get; set; } = Environment.ProcessorCount / 2; + public int MaxConcurrentProcesses { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 1, Environment.ProcessorCount); /// /// The profiles to use. diff --git a/Parallel.Core/Settings/RemoteVaultConfig.cs b/Parallel.Core/Settings/RemoteVaultConfig.cs index a9dab5a..e885429 100644 --- a/Parallel.Core/Settings/RemoteVaultConfig.cs +++ b/Parallel.Core/Settings/RemoteVaultConfig.cs @@ -60,10 +60,23 @@ public class RemoteVaultConfig : LocalVaultConfig /// public HashSet PruneDirectories { get; } = []; + public RemoteVaultConfig(LocalVaultConfig localVault) : base(localVault.Id, localVault.Name, localVault.FileSystem) { } public RemoteVaultConfig(string profileName, FileSystemCredentials fsc) : base(profileName, fsc) { } + [JsonConstructor] + public RemoteVaultConfig(string id, string name, FileSystemCredentials fileSystem, int backupInterval, int retentionPeriod, int prunePeriod, IEnumerable backupDirectories, IEnumerable ignoreDirectories, IEnumerable cleanDirectories, IEnumerable pruneDirectories) : base(id, name, fileSystem) + { + BackupInterval = backupInterval; + RetentionPeriod = retentionPeriod; + PrunePeriod = prunePeriod; + BackupDirectories = new HashSet(backupDirectories); + IgnoreDirectories = new HashSet(ignoreDirectories); + CleanDirectories = new HashSet(cleanDirectories); + PruneDirectories = new HashSet(pruneDirectories); + } + #region Privates private static HashSet CreateBackupDirectories() @@ -116,5 +129,18 @@ private static HashSet CreateCleanDirectories() } #endregion + + /// + /// Loads settings from a file. + /// + public new static RemoteVaultConfig? Load(string path) + { + return !File.Exists(path) ? null : JsonConvert.DeserializeObject(File.ReadAllText(path)); + } + + public void Save(string path) + { + File.WriteAllText(path, JsonConvert.SerializeObject(this, Formatting.Indented)); + } } } \ No newline at end of file From f29df6ae29a870c339eda3b26abda8bfdd4ba6fe Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 2 Sep 2025 03:00:50 -0500 Subject: [PATCH 18/33] Now that databases are instanced there is no need to store a vault id --- .../Database/Contexts/SqliteContext.cs | 17 +++++++++-------- Parallel.Core/Database/IDatabase.cs | 5 ----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index c691094..8ebcac2 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -14,8 +14,7 @@ namespace Parallel.Core.Database /// public class SqliteContext : IDatabase { - public string FilePath { get; } - public string ProfileId { get; } + private string FilePath { get; } /// /// Initializes a new instance of the class. @@ -55,15 +54,15 @@ public async Task InitializeAsync() public async Task AddFileAsync(SystemFile file) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO files (vault, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv, checksum) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV, @CheckSum);"; - return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV, file.CheckSum }) > 0; + string sql = @"INSERT OR REPLACE INTO files (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv, checksum) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV, @CheckSum);"; + return await connection.ExecuteAsync(sql, new { file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV, file.CheckSum }) > 0; } /// public async Task> GetFilesAsync(string path, bool deleted) { using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE vault = \"{ProfileId}\" AND deleted = {deleted} ORDER BY lastupdate DESC"; + string sql = $"SELECT * FROM files WHERE deleted = {deleted} ORDER BY lastupdate DESC"; return await connection.QueryAsync(sql); } @@ -71,7 +70,7 @@ public async Task> GetFilesAsync(string path, bool delet public async Task GetFileAsync(string path) { using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE vault = \"{ProfileId}\" AND localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; + string sql = $"SELECT * FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; return await connection.QuerySingleOrDefaultAsync(sql); } @@ -83,15 +82,17 @@ public async Task> GetFilesAsync(string path, bool delet public async Task AddHistoryAsync(string path, HistoryType type) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO history (vault, timestamp, name, path, type) VALUES(@ProfileId, @Timestamp, @Name, @Path, @Type);"; - return await connection.ExecuteAsync(sql, new { ProfileId, Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; + string sql = @"INSERT OR REPLACE INTO history (timestamp, name, path, type) VALUES(@ProfileId, @Timestamp, @Name, @Path, @Type);"; + return await connection.ExecuteAsync(sql, new { Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; } + /// public IEnumerable? GetHistory(string path, int limit) { throw new NotImplementedException(); } + /// public IEnumerable? GetHistory(string path, HistoryType type, int limit) { throw new NotImplementedException(); diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index 65fea25..2b2c055 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -49,11 +49,6 @@ public enum HistoryType /// public interface IDatabase { - /// - /// The identifier to the vault for this database. - /// - string ProfileId { get; } - #region Base /// From 8f74ee50266a76daea57769377dcfe71ab16a89b Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 2 Sep 2025 04:16:17 -0500 Subject: [PATCH 19/33] Works fine except for uploading the database --- Parallel.Cli/Commands/PushCommand.cs | 1 + Parallel.Cli/Utils/ProgressReport.cs | 1 + .../Database/Contexts/SqliteContext.cs | 40 +++++++++++++------ Parallel.Core/Database/IDatabase.cs | 2 +- .../IO/FileSystem/DotNetFileSystem.cs | 23 +++++++---- Parallel.Core/IO/Scanning/FileScanner.cs | 15 ++++--- Parallel.Core/IO/Syncing/BaseSyncManager.cs | 14 +++++-- Parallel.Core/IO/Syncing/FileSyncManager.cs | 23 +++-------- Parallel.Core/Models/SystemFile.cs | 35 ++++------------ 9 files changed, 74 insertions(+), 80 deletions(-) diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index 97b869d..d618d16 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -81,6 +81,7 @@ await Program.Settings.ForEachVaultAsync(async vault => if (successFiles == 0) { CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is already up to date.", ConsoleColor.Green); + await syncManager.DisconnectAsync(); return; } diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index f2c2f9b..4f2df48 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -26,6 +26,7 @@ public void Report(ProgressOperation operation, SystemFile file, int current, in public void Failed(Exception exception, SystemFile file) { CommandLine.WriteLine(_localVault, $"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); + Log.Error(exception.GetBaseException().ToString()); } } } \ No newline at end of file diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 8ebcac2..70c1e6d 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -26,6 +26,11 @@ public SqliteContext(string filePath) FilePath = filePath; } + public void Dispose() + { + // TODO release managed resources here + } + #region Base /// @@ -38,10 +43,11 @@ public IDbConnection CreateConnection() public async Task InitializeAsync() { Log.Information("Creating index database..."); - File.Create(FilePath).Close(); + //File.Create(FilePath).Close(); //File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); + await connection.ExecuteAsync("PRAGMA journal_mode=WAL;"); await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `checksum` TEXT, PRIMARY KEY(`id`));"); await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`timestamp`));"); } @@ -53,25 +59,31 @@ public async Task InitializeAsync() /// public async Task AddFileAsync(SystemFile file) { - using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO files (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv, checksum) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV, @CheckSum);"; - return await connection.ExecuteAsync(sql, new { file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV, file.CheckSum }) > 0; + using (IDbConnection connection = CreateConnection()) + { + string sql = @"INSERT OR REPLACE INTO files (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) VALUES (@Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @CheckSum);"; + return await connection.ExecuteAsync(sql, new { file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.CheckSum }) > 0; + } } /// public async Task> GetFilesAsync(string path, bool deleted) { - using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE deleted = {deleted} ORDER BY lastupdate DESC"; - return await connection.QueryAsync(sql); + using (IDbConnection connection = CreateConnection()) + { + string sql = $"SELECT * FROM files WHERE deleted = {deleted} ORDER BY lastupdate DESC"; + return await connection.QueryAsync(sql); + } } /// public async Task GetFileAsync(string path) { - using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; - return await connection.QuerySingleOrDefaultAsync(sql); + using (IDbConnection connection = CreateConnection()) + { + string sql = $"SELECT (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; + return await connection.QuerySingleOrDefaultAsync(sql); + } } #endregion @@ -81,9 +93,11 @@ public async Task> GetFilesAsync(string path, bool delet /// public async Task AddHistoryAsync(string path, HistoryType type) { - using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO history (timestamp, name, path, type) VALUES(@ProfileId, @Timestamp, @Name, @Path, @Type);"; - return await connection.ExecuteAsync(sql, new { Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; + using (IDbConnection connection = CreateConnection()) + { + string sql = @"INSERT OR REPLACE INTO history (timestamp, name, path, type) VALUES(@Timestamp, @Name, @Path, @Type);"; + return await connection.ExecuteAsync(sql, new { Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; + } } /// diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index 2b2c055..698be33 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -47,7 +47,7 @@ public enum HistoryType /// /// An interface for interacting with client data storage. /// - public interface IDatabase + public interface IDatabase : IDisposable { #region Base diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index fea83db..d095a33 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -92,16 +92,23 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres { await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => { - if (await ExistsAsync(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); - string? parent = Path.GetDirectoryName(file.RemotePath); - if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); + try + { + if (await ExistsAsync(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); + string? parent = Path.GetDirectoryName(file.RemotePath); + if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); - await using FileStream openStream = File.OpenRead(file.LocalPath); - await using FileStream createStream = File.Create(file.RemotePath); - await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); - await openStream.CopyToAsync(gzipStream, ct); + await using FileStream openStream = File.OpenRead(file.LocalPath); + await using FileStream createStream = File.Create(file.RemotePath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream, ct); - File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); + File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); + } + catch (Exception ex) + { + progress.Failed(ex, file); + } }); } } diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index aea7a6b..993ce9f 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -19,16 +19,13 @@ namespace Parallel.Core.IO.Scanning /// public class FileScanner { + private readonly RemoteVaultConfig _config; private readonly IDatabase _db; - public FileScanner(IDatabase database) + public FileScanner(ISyncManager syncManager) { - _db = database; - } - - public FileScanner(ISyncManager sync) - { - _db = sync.Database; + _config = syncManager.RemoteVault; + _db = syncManager.Database; } /*/// @@ -69,12 +66,14 @@ public async Task GetFileChangesAsync(string path, string[] ignore if (IsIgnored(localFile.LocalPath, ignoreFolders)) { Log.Debug($"Ignored -> {localFile.LocalPath}"); + localFile.RemotePath = remoteFile.RemotePath; localFile.Deleted = true; scannedFiles.Add(localFile); } else if (HasChanged(localFile, remoteFile)) { Log.Debug($"Changed -> {localFile.LocalPath}"); + localFile.RemotePath = remoteFile.RemotePath; scannedFiles.Add(localFile); } @@ -94,7 +93,7 @@ public async Task GetFileChangesAsync(string path, string[] ignore if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) { Log.Debug($"Created -> {file}"); - scannedFiles.Add(new SystemFile(file)); + scannedFiles.Add(new SystemFile(file) { RemotePath = PathBuilder.Remote(file, _config) }); } } diff --git a/Parallel.Core/IO/Syncing/BaseSyncManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs index 264c1fe..c9745c4 100644 --- a/Parallel.Core/IO/Syncing/BaseSyncManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -72,31 +72,37 @@ public async Task ConnectAsync() } else { - await FileSystem.DownloadFilesAsync([new SystemFile { LocalPath = TempConfigFile, RemotePath = PathBuilder.GetConfigurationFile(LocalVault) }], new ProgressLogger()); + await FileSystem.DownloadFilesAsync([new SystemFile(TempConfigFile, PathBuilder.GetConfigurationFile(LocalVault))], new ProgressLogger()); RemoteVaultConfig? config = RemoteVaultConfig.Load(TempConfigFile); if(config == null) return false; RemoteVault = config; + + Log.Debug($"Downloaded config file: {TempConfigFile}"); } if (!await FileSystem.ExistsAsync(PathBuilder.GetDatabaseFile(LocalVault))) { Database = new SqliteContext(TempDbFile); await Database.InitializeAsync(); + + Log.Debug($"Create db file: {TempDbFile}"); } else { - await FileSystem.DownloadFilesAsync([new SystemFile { LocalPath = TempDbFile, RemotePath = PathBuilder.GetDatabaseFile(LocalVault) }], new ProgressLogger()); + await FileSystem.DownloadFilesAsync([new SystemFile(TempDbFile, PathBuilder.GetDatabaseFile(LocalVault))], new ProgressLogger()); Database = new SqliteContext(TempDbFile); + + Log.Debug($"Downloaded db file: {TempDbFile}"); } return true; } /// - public Task DisconnectAsync() + public async Task DisconnectAsync() { + await FileSystem.UploadFilesAsync([new SystemFile(TempConfigFile, PathBuilder.GetConfigurationFile(LocalVault)), new SystemFile(TempDbFile, PathBuilder.GetDatabaseFile(LocalVault))], new ProgressLogger()); FileSystem.Dispose(); - return Task.CompletedTask; } /// diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index 3e6304a..a1f388e 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -16,17 +16,11 @@ namespace Parallel.Core.IO.Backup /// public class FileSyncManager : BaseSyncManager { - protected string[] BackupDirectories => RemoteVault.BackupDirectories.ToArray(); - protected string[] IgnoreDirectories => RemoteVault.IgnoreDirectories.ToArray(); - /// /// Initializes a new instance of the class. /// /// - public FileSyncManager(LocalVaultConfig localVault) : base(localVault) - { - - } + public FileSyncManager(LocalVaultConfig localVault) : base(localVault) { } /// public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) @@ -35,24 +29,17 @@ public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); Log.Information($"Backing up {backupFiles.Length} files..."); await FileSystem.UploadFilesAsync(backupFiles, progress); - await System.Threading.Tasks.Parallel.ForEachAsync(files, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount / 2 }, async (file, ct) => - { - - }); - - - for (int i = 0; i < files.Length; i++) + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => { - SystemFile file = files.ElementAt(i); if (file.Deleted) { - progress.Report(ProgressOperation.Archiving, file, i, files.Length); + progress.Report(ProgressOperation.Archiving, file, 0, files.Length); await Database.AddHistoryAsync(file.LocalPath, HistoryType.Archived); await Database.AddFileAsync(file); } else { - progress.Report(ProgressOperation.Syncing, file, i, files.Length); + progress.Report(ProgressOperation.Syncing, file, 0, files.Length); SystemFile remote = await FileSystem.GetFileAsync(file.RemotePath); if (remote is not null) { @@ -61,7 +48,7 @@ public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter await Database.AddFileAsync(file); } } - } + }); } /// diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index a98dab6..2b9f6a9 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -73,21 +73,6 @@ public class SystemFile /// public bool Deleted { get; set; } = false; - /// - /// If the file is encrypted in the backup. - /// - public bool Encrypted { get; set; } = false; - - /// - /// The salt used to encrypt the file. - /// - public string Salt { get; set; } - - /// - /// The initialization vector used to encrypt the file. - /// - public string IV { get; set; } - /// /// The checksum used to check if the file has changed. /// @@ -111,16 +96,18 @@ public SystemFile(string path) Hidden = fileInfo.Attributes.HasFlag(FileAttributes.Hidden); ReadOnly = fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly); Deleted = !fileInfo.Exists; - Encrypted = false; - Salt = HashGenerator.GenerateHash(16); - IV = HashGenerator.GenerateHash(16); CheckSum = HashGenerator.CheckSum(path); } + public SystemFile(string localPath, string remotePath) + { + LocalPath = localPath; + RemotePath = remotePath; + } + /// /// Initializes a new instance of the class. /// - /// /// /// /// @@ -137,7 +124,7 @@ public SystemFile(string path) /// /// /// - public SystemFile(string vault, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, string salt, string iv, string checksum) + public SystemFile(string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, string checksum) { Id = id; Name = name; @@ -150,14 +137,9 @@ public SystemFile(string vault, string id, string name, string localpath, string Hidden = Converter.ToBool(hidden); ReadOnly = Converter.ToBool(readOnly); Deleted = Converter.ToBool(deleted); - Encrypted = Converter.ToBool(encrypted); - Salt = salt; - IV = iv; CheckSum = checksum; } - public SystemFile() { } - /// /// Determines if this instance and another have the same values. /// @@ -177,9 +159,6 @@ public bool Equals(SystemFile value) value?.Hidden != null ? this.Hidden.Equals(value.Hidden) : (bool?)null, value?.ReadOnly != null ? this.ReadOnly.Equals(value.ReadOnly) : (bool?)null, value?.Deleted != null ? this.Deleted.Equals(value.Deleted) : (bool?)null, - value?.Encrypted != null ? this.Encrypted.Equals(value.Encrypted) : (bool?)null, - this?.Salt != null && value?.Salt != null ? this.Salt.SequenceEqual(value.Salt) : (bool?)null, - this?.IV != null && value?.IV != null ? this.IV.SequenceEqual(value.IV) : (bool?)null, this?.CheckSum != null && value?.CheckSum != null ? this.CheckSum.SequenceEqual(value.CheckSum) : (bool?)null, ]; From bf7832bb90269e5aedc39fb3ff429c7ea03cbd16 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:23:50 -0500 Subject: [PATCH 20/33] Successfully pushes files --- Parallel.Cli/Commands/PushCommand.cs | 3 +- Parallel.Cli/Utils/ProgressReport.cs | 17 +++++++--- .../Database/Contexts/SqliteContext.cs | 5 +-- .../Diagnostics/IProgressReporter.cs | 7 +++- Parallel.Core/Diagnostics/ProgressLogger.cs | 24 ++++++++----- .../IO/FileSystem/DotNetFileSystem.cs | 34 ++++++++++++++++++- Parallel.Core/IO/FileSystem/IFileSystem.cs | 14 ++++++++ Parallel.Core/IO/FileSystem/SftpFileSystem.cs | 32 ++++++++++++++++- Parallel.Core/IO/Syncing/FileSyncManager.cs | 6 ++-- 9 files changed, 119 insertions(+), 23 deletions(-) diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index d618d16..1b6204e 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -2,6 +2,7 @@ using System.CommandLine; using Parallel.Cli.Utils; +using Parallel.Core.Diagnostics; using Parallel.Core.IO; using Parallel.Core.IO.Backup; using Parallel.Core.IO.Scanning; @@ -89,7 +90,7 @@ await Program.Settings.ForEachVaultAsync(async vault => await syncManager.PushFilesAsync(files, new ProgressReport(vault, successFiles)); await syncManager.DisconnectAsync(); - CommandLine.WriteLine(vault, $"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); + CommandLine.WriteLine(vault, $"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.RootDirectory}'.", ConsoleColor.Green); }); } } diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index 4f2df48..e9c587c 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -9,24 +9,31 @@ namespace Parallel.Cli.Utils public class ProgressReport : IProgressReporter { private readonly LocalVaultConfig _localVault; - private readonly int _totalFiles; + private int _current; + private int _total; public ProgressReport(LocalVaultConfig localVault, int totalFiles) { _localVault = localVault; - _totalFiles = totalFiles; + _current = 0; + _total = totalFiles; } - public void Report(ProgressOperation operation, SystemFile file, int current, int total) + public void Report(ProgressOperation operation, SystemFile file) { - int percent = current * 100 / total; + int percent = _current++ * 100 / _total; CommandLine.WriteLine($"[{percent}%] <{_localVault.Id}> {operation}: {file.LocalPath}"); } + /// + public void Reset() + { + _current = 0; + } + public void Failed(Exception exception, SystemFile file) { CommandLine.WriteLine(_localVault, $"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); - Log.Error(exception.GetBaseException().ToString()); } } } \ No newline at end of file diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 70c1e6d..f4ca34d 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -36,18 +36,15 @@ public void Dispose() /// public IDbConnection CreateConnection() { - return new SqliteConnection("Data Source=" + FilePath); + return new SqliteConnection($"Data Source={FilePath};Pooling=false;"); } /// public async Task InitializeAsync() { Log.Information("Creating index database..."); - //File.Create(FilePath).Close(); - //File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); - await connection.ExecuteAsync("PRAGMA journal_mode=WAL;"); await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `checksum` TEXT, PRIMARY KEY(`id`));"); await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`timestamp`));"); } diff --git a/Parallel.Core/Diagnostics/IProgressReporter.cs b/Parallel.Core/Diagnostics/IProgressReporter.cs index a774bd1..943e0da 100644 --- a/Parallel.Core/Diagnostics/IProgressReporter.cs +++ b/Parallel.Core/Diagnostics/IProgressReporter.cs @@ -22,7 +22,12 @@ public interface IProgressReporter /// /// Reports a progress update. /// - void Report(ProgressOperation operation, SystemFile file, int current, int total); + void Report(ProgressOperation operation, SystemFile file); + + /// + /// Resets the ticking. + /// + void Reset(); /// /// Reports a failed update. diff --git a/Parallel.Core/Diagnostics/ProgressLogger.cs b/Parallel.Core/Diagnostics/ProgressLogger.cs index c624e39..8dd8463 100644 --- a/Parallel.Core/Diagnostics/ProgressLogger.cs +++ b/Parallel.Core/Diagnostics/ProgressLogger.cs @@ -10,27 +10,35 @@ namespace Parallel.Core.Diagnostics public class ProgressLogger : IProgressReporter { private ProgressOperation currentOperation; - private int progressPercentage; + private int _percentage; + private int _current; + private int _total; /// - public void Report(ProgressOperation operation, SystemFile file, int current, int total) + public void Report(ProgressOperation operation, SystemFile file) { - int num = (int)(current / (double)total * 100.0 + 0.5); + int num = (int)(_current++ / (double)_total * 100.0 + 0.5); if (currentOperation != operation) { - progressPercentage = -1; + _percentage = -1; currentOperation = operation; } - if (progressPercentage == num || num % 10 != 0) return; - Log.Information($"{operation}: {current} out of {total} ({progressPercentage}%)"); - progressPercentage = num; + if (_percentage == num || num % 10 != 0) return; + Log.Information($"{operation}: {_current} out of {_total} ({_percentage}%)"); + _percentage = num; + } + + /// + public void Reset() + { + _current = 0; } /// public void Failed(Exception exception, SystemFile file) { - Log.Error($"{exception.GetType().FullName}: {exception.Message}. Failed to upload file: '{file.LocalPath}'"); + Log.Error($"{exception.GetType().FullName}: {exception.Message} Failed to upload file: '{file.LocalPath}'"); } } } \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index d095a33..c3bdfb2 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -69,6 +69,15 @@ await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options }); } + /// + public async Task DownloadFileAsync(string sourcePath, string destinationPath) + { + await using FileStream openStream = File.OpenRead(destinationPath); + await using FileStream createStream = File.Create(sourcePath); + await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(createStream); + } + /// public Task ExistsAsync(string path) { @@ -98,6 +107,7 @@ await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options string? parent = Path.GetDirectoryName(file.RemotePath); if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); + progress.Report(ProgressOperation.Uploading, file); await using FileStream openStream = File.OpenRead(file.LocalPath); await using FileStream createStream = File.Create(file.RemotePath); await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); @@ -107,9 +117,31 @@ await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options } catch (Exception ex) { - progress.Failed(ex, file); + Log.Error(ex.GetBaseException().ToString()); } }); } + + /// + public async Task UploadFileAsync(string sourcePath, string destinationPath) + { + try + { + if (await ExistsAsync(destinationPath)) File.SetAttributes(destinationPath, ~FileAttributes.ReadOnly & File.GetAttributes(destinationPath)); + string? parent = Path.GetDirectoryName(destinationPath); + if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); + + await using FileStream openStream = File.OpenRead(sourcePath); + await using FileStream createStream = File.Create(destinationPath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream); + + File.SetAttributes(destinationPath, File.GetAttributes(destinationPath) | FileAttributes.ReadOnly); + } + catch (Exception ex) + { + Log.Error(ex.GetBaseException().ToString()); + } + } } } \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/IFileSystem.cs b/Parallel.Core/IO/FileSystem/IFileSystem.cs index 7346d81..2bd797a 100644 --- a/Parallel.Core/IO/FileSystem/IFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/IFileSystem.cs @@ -40,6 +40,13 @@ public interface IFileSystem : IDisposable /// Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress); + /// + /// Downloads a file from the associated file system. + /// + /// + /// + Task DownloadFileAsync(string sourcePath, string destinationPath); + /// /// Checks if a path exists on the associated file system. /// @@ -60,5 +67,12 @@ public interface IFileSystem : IDisposable /// /// Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress); + + /// + /// Uploads a file to the associated file system. + /// + /// + /// + Task UploadFileAsync(string sourcePath, string destinationPath); } } \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs index f00c498..964bca6 100644 --- a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs @@ -75,11 +75,19 @@ public async Task DeleteFileAsync(string path) } } + /// public Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) { throw new NotImplementedException(); } + /// + public Task DownloadFileAsync(string sourcePath, string destinationPath) + { + throw new NotImplementedException(); + } + + /// public async Task ExistsAsync(string path) { return _client.IsConnected && await _client.ExistsAsync(path); @@ -109,7 +117,7 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres { SystemFile file = files[i]; Stopwatch sw = new Stopwatch(); - progress.Report(ProgressOperation.Uploading, file, i, files.Length); + progress.Report(ProgressOperation.Uploading, file); if (await _client.ExistsAsync(file.RemotePath)) _client.ChangePermissions(file.RemotePath, 644); string parentDir = string.Empty; @@ -131,5 +139,27 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); } } + + /// + public async Task UploadFileAsync(string sourcePath, string destinationPath) + { + if (await ExistsAsync(destinationPath)) _client.ChangePermissions(destinationPath, 644); + + string parentDir = string.Empty; + foreach (string subPath in destinationPath.Split('/')) + { + parentDir += $"/{subPath}"; + if (!await _client.ExistsAsync(parentDir)) + { + await _client.CreateDirectoryAsync(parentDir); + } + } + + await using SftpFileStream createStream = _client.Create(destinationPath); + await using FileStream openStream = File.OpenRead(sourcePath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream); + _client.ChangePermissions(destinationPath, 444); + } } } \ No newline at end of file diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index a1f388e..6d4c7a2 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -29,17 +29,19 @@ public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); Log.Information($"Backing up {backupFiles.Length} files..."); await FileSystem.UploadFilesAsync(backupFiles, progress); + + progress.Reset(); await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => { if (file.Deleted) { - progress.Report(ProgressOperation.Archiving, file, 0, files.Length); + progress.Report(ProgressOperation.Archiving, file); await Database.AddHistoryAsync(file.LocalPath, HistoryType.Archived); await Database.AddFileAsync(file); } else { - progress.Report(ProgressOperation.Syncing, file, 0, files.Length); + progress.Report(ProgressOperation.Syncing, file); SystemFile remote = await FileSystem.GetFileAsync(file.RemotePath); if (remote is not null) { From c0a5f2b0f9742142ad030704140d0d9dc5b51545 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Sun, 26 Oct 2025 04:28:53 -0500 Subject: [PATCH 21/33] Now can push via SSH --- Parallel.Cli/Program.cs | 2 +- Parallel.Cli/Utils/ProgressReport.cs | 2 +- .../IO/FileSystem/DotNetFileSystem.cs | 20 ++++-- Parallel.Core/IO/FileSystem/IFileSystem.cs | 2 +- Parallel.Core/IO/FileSystem/SftpFileSystem.cs | 66 +++++++++---------- Parallel.Core/IO/Syncing/FileSyncManager.cs | 4 +- Parallel.Core/Models/SystemFile.cs | 10 +++ Parallel.Core/Settings/LocalVaultConfig.cs | 5 +- 8 files changed, 64 insertions(+), 47 deletions(-) diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index b5b3c43..83caf15 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -18,7 +18,7 @@ public static async Task Main(string[] args) string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", "latest.txt"); if (File.Exists(logFile)) File.Delete(logFile); - Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.File(logFile).CreateLogger(); + Log.Logger = new LoggerConfiguration().MinimumLevel.Warning().WriteTo.File(logFile).CreateLogger(); AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); Log.Information($"{assembly.Name} [Version {assembly.Version}]"); diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index e9c587c..5fe0409 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -22,7 +22,7 @@ public ProgressReport(LocalVaultConfig localVault, int totalFiles) public void Report(ProgressOperation operation, SystemFile file) { int percent = _current++ * 100 / _total; - CommandLine.WriteLine($"[{percent}%] <{_localVault.Id}> {operation}: {file.LocalPath}"); + CommandLine.WriteLine($"[{_localVault.Id}] ({percent}%) {operation}: {file.LocalPath}"); } /// diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index c3bdfb2..b41630c 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -85,21 +85,29 @@ public Task ExistsAsync(string path) } /// - public Task GetFileAsync(string path) + public Task GetFileAsync(string path) { + if(!File.Exists(path)) return Task.FromResult(null); + FileInfo fi = new(path); - return Task.FromResult(new SystemFile(path) + SystemFile file = new SystemFile(path) { Name = fi.Name, RemotePath = fi.FullName, RemoteSize = fi.Length - }); + }; + return Task.FromResult(file); } /// public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + // await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + // { + // + // }); + + foreach (SystemFile file in files) { try { @@ -111,7 +119,7 @@ await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options await using FileStream openStream = File.OpenRead(file.LocalPath); await using FileStream createStream = File.Create(file.RemotePath); await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); - await openStream.CopyToAsync(gzipStream, ct); + await openStream.CopyToAsync(gzipStream); File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); } @@ -119,7 +127,7 @@ await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options { Log.Error(ex.GetBaseException().ToString()); } - }); + } } /// diff --git a/Parallel.Core/IO/FileSystem/IFileSystem.cs b/Parallel.Core/IO/FileSystem/IFileSystem.cs index 2bd797a..76ebe7f 100644 --- a/Parallel.Core/IO/FileSystem/IFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/IFileSystem.cs @@ -59,7 +59,7 @@ public interface IFileSystem : IDisposable /// /// /// - Task GetFileAsync(string path); + Task GetFileAsync(string path); /// /// Uploads an array of files to the associated file system. diff --git a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs index 964bca6..64ae552 100644 --- a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs @@ -76,9 +76,15 @@ public async Task DeleteFileAsync(string path) } /// - public Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) + public async Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) { - throw new NotImplementedException(); + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + { + await using SftpFileStream openStream = _client.OpenRead(file.RemotePath); + await using FileStream createStream = File.Create(file.LocalPath); + await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(createStream, ct); + }); } /// @@ -94,49 +100,41 @@ public async Task ExistsAsync(string path) } /// - public async Task GetFileAsync(string path) + public async Task GetFileAsync(string path) { - SystemFile file = new SystemFile(path); - if (await ExistsAsync(path)) - { - ISftpFile sf = _client.Get(path); - file = new SystemFile(sf.FullName) - { - Name = sf.Name, - RemoteSize = sf.Length, - }; - } + if (!await ExistsAsync(path)) return null; - return file; + ISftpFile sf = _client.Get(path); + return new SystemFile(sf.Name, sf.FullName, sf.Length, sf.LastWriteTime); } /// public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - for (int i = 0; i < files.Length; i++) + foreach (SystemFile file in files) { - SystemFile file = files[i]; - Stopwatch sw = new Stopwatch(); - progress.Report(ProgressOperation.Uploading, file); - if (await _client.ExistsAsync(file.RemotePath)) _client.ChangePermissions(file.RemotePath, 644); - - string parentDir = string.Empty; - foreach (string subPath in file.RemotePath.Split('/')) + try { - parentDir += $"/{subPath}"; - if (!await _client.ExistsAsync(parentDir)) - { - await _client.CreateDirectoryAsync(parentDir); - } - } + Stopwatch sw = new Stopwatch(); + progress.Report(ProgressOperation.Uploading, file); + if (await _client.ExistsAsync(file.RemotePath)) _client.ChangePermissions(file.RemotePath, 644); - await using SftpFileStream createStream = _client.Create(file.RemotePath); - await using FileStream openStream = File.OpenRead(file.LocalPath); - await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); - await openStream.CopyToAsync(gzipStream); - _client.ChangePermissions(file.RemotePath, 444); + string[] subDirs = file.RemotePath.Split('/'); + string parentDir = string.Join("/", subDirs.Take(subDirs.Length - 1)); + if(!await _client.ExistsAsync(parentDir)) await CreateDirectoryAsync(parentDir); - Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); + await using SftpFileStream createStream = _client.Create(file.RemotePath); + await using FileStream openStream = File.OpenRead(file.LocalPath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream); + _client.ChangePermissions(file.RemotePath, 444); + + Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + Log.Error(ex.GetBaseException().ToString()); + } } } diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index 6d4c7a2..c2a135a 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -41,8 +41,8 @@ await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options } else { - progress.Report(ProgressOperation.Syncing, file); - SystemFile remote = await FileSystem.GetFileAsync(file.RemotePath); + //progress.Report(ProgressOperation.Syncing, file); + SystemFile? remote = await FileSystem.GetFileAsync(file.RemotePath); if (remote is not null) { file.RemoteSize = remote.RemoteSize; diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index 2b9f6a9..9ad23ca 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -140,6 +140,16 @@ public SystemFile(string id, string name, string localpath, string remotepath, l CheckSum = checksum; } + public SystemFile(string name, string remotePath, long length, DateTime lastWriteTime) + { + Name = name; + RemotePath = remotePath; + RemoteSize = length; + LastWrite = new UnixTime(lastWriteTime); + } + + //public SystemFile() { } + /// /// Determines if this instance and another have the same values. /// diff --git a/Parallel.Core/Settings/LocalVaultConfig.cs b/Parallel.Core/Settings/LocalVaultConfig.cs index 9d9ef39..76f5652 100644 --- a/Parallel.Core/Settings/LocalVaultConfig.cs +++ b/Parallel.Core/Settings/LocalVaultConfig.cs @@ -1,5 +1,6 @@ // Copyright 2025 Kyle Ebbinga +using Parallel.Core.IO.FileSystem; using Parallel.Core.Security; namespace Parallel.Core.Settings @@ -12,12 +13,12 @@ public class LocalVaultConfig /// /// A unique hash used to identify the vault. /// - public string Id { get; } = HashGenerator.GenerateHash(12, true); + public string Id { get; } /// /// The name of the vault. /// - public string Name { get; set; } = "Default"; + public string Name { get; set; } /// /// The credentials needed to log in to the associated . From ab92e55b5b8e37e86b3948a1cb10ee8960c032d1 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 10 Nov 2025 04:24:07 -0600 Subject: [PATCH 22/33] Various performance increases --- Parallel.Cli/Commands/PushCommand.cs | 2 +- Parallel.Cli/Commands/UnzipCommand.cs | 4 ++-- .../IO/FileSystem/DotNetFileSystem.cs | 11 +++-------- Parallel.Core/IO/Scanning/FileScanner.cs | 18 ++++++++++++++---- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index 1b6204e..4662efc 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -41,7 +41,7 @@ public PushCommand() : base("push", "Pushes changed files to vaults.") }, _sourceArg, _configOpt, _verboseOpt); } - private async Task SyncSystemAsync() + private Task SyncSystemAsync() { throw new NotImplementedException(); } diff --git a/Parallel.Cli/Commands/UnzipCommand.cs b/Parallel.Cli/Commands/UnzipCommand.cs index 97c0030..396a646 100644 --- a/Parallel.Cli/Commands/UnzipCommand.cs +++ b/Parallel.Cli/Commands/UnzipCommand.cs @@ -12,7 +12,7 @@ public class UnzipCommand : Command private readonly Argument sourceArg = new("path", "The source path of files to unzip."); private readonly Option keepOpt = new(["--keep", "-k"], "If the original files should be kept."); - private Stopwatch _sw; + private Stopwatch? _sw; private readonly List _tasks = new List(); private int _totalTasks = 0; @@ -58,7 +58,7 @@ private void DecompressFile(string path, bool keep) } } - CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); + CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw?.Elapsed ?? TimeSpan.Zero, ConsoleColor.DarkGray); } } } \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index b41630c..799f181 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -102,12 +102,7 @@ public Task ExistsAsync(string path) /// public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - // await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => - // { - // - // }); - - foreach (SystemFile file in files) + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => { try { @@ -119,7 +114,7 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres await using FileStream openStream = File.OpenRead(file.LocalPath); await using FileStream createStream = File.Create(file.RemotePath); await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); - await openStream.CopyToAsync(gzipStream); + await openStream.CopyToAsync(gzipStream, ct); File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); } @@ -127,7 +122,7 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres { Log.Error(ex.GetBaseException().ToString()); } - } + }); } /// diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 993ce9f..2ad7211 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -58,7 +58,7 @@ public async Task GetFileChangesAsync(string path, string[] ignore List scannedFiles = new List(); HashSet localFiles = FileScanner.GetFiles(path, ".", ignoreFolders).ToHashSet(); IEnumerable remoteFiles = await _db.GetFilesAsync(path, false); - foreach (SystemFile remoteFile in remoteFiles) + await System.Threading.Tasks.Parallel.ForEachAsync(remoteFiles, ParallelConfig.Options, async (remoteFile, ct) => { if (File.Exists(remoteFile.LocalPath) && remoteFile.RemotePath != null) { @@ -85,17 +85,27 @@ public async Task GetFileChangesAsync(string path, string[] ignore remoteFile.Deleted = true; scannedFiles.Add(remoteFile); } - } + }); + + // foreach (SystemFile remoteFile in remoteFiles) + // { + // + // } Log.Debug($"{localFiles.Count} files are untracked! Adding..."); - foreach (var file in localFiles) + // foreach (var file in localFiles) + // { + // + // } + + await System.Threading.Tasks.Parallel.ForEachAsync(localFiles, ParallelConfig.Options, async (file, ct) => { if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) { Log.Debug($"Created -> {file}"); scannedFiles.Add(new SystemFile(file) { RemotePath = PathBuilder.Remote(file, _config) }); } - } + }); Log.Debug($"{localFiles.Count} files remaining."); Log.Information($"Found {scannedFiles.Count:N0} changes in '{path}'"); From 3057deedcaaede5939cf190ef734e44a25911b7e Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 10 Nov 2025 04:25:05 -0600 Subject: [PATCH 23/33] Added new configuration for analyzing builds --- Parallel.Cli/Parallel.Cli.csproj | 35 +++++++++++++++++++++++------- Parallel.Core/Parallel.Core.csproj | 2 ++ Parallel.sln | 5 +++++ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Parallel.Cli/Parallel.Cli.csproj b/Parallel.Cli/Parallel.Cli.csproj index d042abc..edd0157 100644 --- a/Parallel.Cli/Parallel.Cli.csproj +++ b/Parallel.Cli/Parallel.Cli.csproj @@ -3,6 +3,7 @@ Exe net9.0 + Debug;Release;Analyze parallel-red.ico enable enable @@ -15,24 +16,42 @@ $(Company) + + $(DefineConstants);TRACE + true + true + + + $(DefineConstants);DEBUG;TRACE + false + true + full + + + False + True + True + True + + - - - - + + + + - - + + - + - + diff --git a/Parallel.Core/Parallel.Core.csproj b/Parallel.Core/Parallel.Core.csproj index aa8cd3a..950da5d 100644 --- a/Parallel.Core/Parallel.Core.csproj +++ b/Parallel.Core/Parallel.Core.csproj @@ -13,6 +13,8 @@ Parallel.Core True Parallel.Core + Debug;Release;Analyze + AnyCPU diff --git a/Parallel.sln b/Parallel.sln index 329e4be..9ffbb75 100644 --- a/Parallel.sln +++ b/Parallel.sln @@ -11,16 +11,21 @@ Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU + Analyze|Any CPU = Analyze|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Debug|Any CPU.Build.0 = Debug|Any CPU {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Release|Any CPU.ActiveCfg = Release|Any CPU {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Release|Any CPU.Build.0 = Release|Any CPU + {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU + {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Analyze|Any CPU.Build.0 = Analyze|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Debug|Any CPU.Build.0 = Debug|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Release|Any CPU.ActiveCfg = Release|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Release|Any CPU.Build.0 = Release|Any CPU + {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU + {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Analyze|Any CPU.Build.0 = Analyze|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 44b6ceda470aa9ebbcea10e716799164d4d15ebb Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 11 Nov 2025 02:51:57 -0600 Subject: [PATCH 24/33] Now cleans old files off the system and moved the retention time variable to main configuration file --- Parallel.Cli/Commands/CleanCommand.cs | 127 ++++++++++++++++++++ Parallel.Cli/Commands/DuplicatesCommand.cs | 4 +- Parallel.Cli/Program.cs | 3 +- Parallel.Cli/Utils/Formatter.cs | 32 ----- Parallel.Core/IO/Scanning/FileScanner.cs | 76 ++++++------ Parallel.Core/Parallel.Core.csproj | 14 +-- Parallel.Core/Settings/ParallelConfig.cs | 39 ++++-- Parallel.Core/Settings/RemoteVaultConfig.cs | 25 +--- 8 files changed, 208 insertions(+), 112 deletions(-) create mode 100644 Parallel.Cli/Commands/CleanCommand.cs delete mode 100644 Parallel.Cli/Utils/Formatter.cs diff --git a/Parallel.Cli/Commands/CleanCommand.cs b/Parallel.Cli/Commands/CleanCommand.cs new file mode 100644 index 0000000..324a208 --- /dev/null +++ b/Parallel.Cli/Commands/CleanCommand.cs @@ -0,0 +1,127 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.IO.Backup; +using Parallel.Core.IO.Scanning; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Settings; +using Parallel.Core.Utils; + +namespace Parallel.Cli.Commands +{ + public class CleanCommand : Command + { + private long _freedBytes = 0; + + private readonly Option _sourceOpt = new(["--path", "-p"], "The source path to clean."); + private readonly Option _daysOpt = new(["--days", "-d"], "The amount of days to hang onto files."); + private readonly Option _recursiveOpt = new(["--recursive", "-R"], "If to include subdirectories."); + private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); + + public CleanCommand() : base("clean", "Cleans up the file system by removing old files.") + { + this.AddOption(_sourceOpt); + this.AddOption(_daysOpt); + this.AddOption(_recursiveOpt); + this.AddOption(_verboseOpt); + this.SetHandler(async (path, days, recursive, verbose) => + { + ParallelConfig config = ParallelConfig.Load(); + if (days <= config.RetentionPeriod) days = config.RetentionPeriod; + + if (string.IsNullOrEmpty(path)) + { + await CleanSystemAsync(config, days, recursive, verbose); + } + else + { + await CleanDirectoryAsync(config, path, days, recursive, verbose); + } + + }, _sourceOpt, _daysOpt, _recursiveOpt, _verboseOpt); + } + + private async Task CleanSystemAsync(ParallelConfig config, int days, bool recursive, bool verbose) + { + await System.Threading.Tasks.Parallel.ForEachAsync(config.CleanDirectories, ParallelConfig.Options, async (path, ct) => + { + await CleanDirectoryAsync(config, path, days, recursive, verbose); + }); + } + + private async Task CleanDirectoryAsync(ParallelConfig config, string path, int days, bool recursive, bool verbose) + { + CommandLine.WriteLine($"Scanning for cleanable files older than {days:N0} days old in {path}...", ConsoleColor.DarkGray); + if (!Directory.Exists(path)) + { + CommandLine.WriteLine($"The provided path was not found!", ConsoleColor.Yellow); + return; + } + + UnixTime minTime = UnixTime.FromMilliseconds(UnixTime.Now.TotalMilliseconds - (days * UnixTime.Day)); + IEnumerable cleanableFiles = FileScanner.GetCleanableFiles(path, minTime, recursive); + if (!cleanableFiles.Any()) CommandLine.WriteLine($"No cleanable files were found in the provided path.", ConsoleColor.Green); + int filesCount = cleanableFiles.Count(); + + await System.Threading.Tasks.Parallel.ForEachAsync(cleanableFiles, ParallelConfig.Options, (fi, ct) => + { + if (fi.Exists) + { + try + { + _freedBytes += fi.Length; + fi.Delete(); + } + catch (Exception ex) + { + CommandLine.WriteLine($"Unable to remove file: {fi.FullName}", ConsoleColor.Yellow); + Log.Warning($"{ex.GetBaseException().Message}"); + } + } + + return ValueTask.CompletedTask; + }); + + IEnumerable directories = FileScanner.GetEmptyDirectories(path, recursive); + if (!directories.Any()) CommandLine.WriteLine($"No empty directories were found in the provided path.", ConsoleColor.Green); + int directoriesCount = directories.Count(); + + await System.Threading.Tasks.Parallel.ForEachAsync(directories, ParallelConfig.Options, (di, ct) => + { + if (di.Exists && di.EnumerateFiles().Any()) + { + try + { + Log.Debug($"Removing empty directory: {di?.FullName}"); + di?.Delete(true); + } + catch (Exception ex) + { + Log.Warning($"{ex.GetBaseException().Message}"); + } + } + + return ValueTask.CompletedTask; + }); + + DirectoryInfo currentDir = new DirectoryInfo(path); + SearchOption option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + if (!currentDir.EnumerateFiles("*", option).Any()) + { + try + { + Log.Debug($"Removing empty directory: {currentDir.FullName}"); + currentDir.Delete(true); + directoriesCount++; + } + catch (Exception ex) + { + Log.Warning($"{ex.GetBaseException().Message}"); + } + } + + if(filesCount > 0 || directoriesCount > 0) CommandLine.WriteLine($"Successfully cleaned {filesCount:N0} files and {directoriesCount:N0} directories, ({Formatter.FromBytes(_freedBytes)} removed)", ConsoleColor.Green); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/DuplicatesCommand.cs b/Parallel.Cli/Commands/DuplicatesCommand.cs index 6a4ad92..ac1956d 100644 --- a/Parallel.Cli/Commands/DuplicatesCommand.cs +++ b/Parallel.Cli/Commands/DuplicatesCommand.cs @@ -6,7 +6,7 @@ using Parallel.Core.IO.Scanning; using Parallel.Core.Models; using Parallel.Core.Settings; - +using Parallel.Core.Utils; using TextWriter = Parallel.Cli.Utils.TextWriter; namespace Parallel.Cli.Commands @@ -28,7 +28,7 @@ private void ScanForDuplicateFiles(string path) Dictionary result = duplicates.ToDictionary(k => k.Key, v => v.Value.Select(l => l.LocalPath).ToArray()); long length = duplicates.Sum(kv => kv.Value.Sum(l => l.LocalSize)); - CommandLine.WriteLine($"Scan found {duplicates.Where(kv => kv.Value.Length > 1).Count().ToString("N0")} duplicate files. ({Formatter.FromBytes(length)})"); + CommandLine.WriteLine($"Scan found {duplicates.Count(kv => kv.Value.Length > 1):N0} duplicate files. ({Formatter.FromBytes(length)})"); CommandLine.WriteLine($"A detailed version was created here: {TextWriter.CreateTxtFile(JsonConvert.SerializeObject(result, Formatting.Indented))}"); } } diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index 83caf15..b179be2 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -14,11 +14,10 @@ internal class Program public static async Task Main(string[] args) { Settings = ParallelConfig.Load(); - //string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log"); - string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", "latest.txt"); if (File.Exists(logFile)) File.Delete(logFile); Log.Logger = new LoggerConfiguration().MinimumLevel.Warning().WriteTo.File(logFile).CreateLogger(); + //Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); Log.Information($"{assembly.Name} [Version {assembly.Version}]"); diff --git a/Parallel.Cli/Utils/Formatter.cs b/Parallel.Cli/Utils/Formatter.cs deleted file mode 100644 index f00ee78..0000000 --- a/Parallel.Cli/Utils/Formatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Cli.Utils -{ - public class Formatter - { - public static string FromBytes(long bytes) - { - string[] sizeSuffixes = { "Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" }; - int sizeIndex = 0; - double size = bytes; - - while (size >= 1000 && sizeIndex < sizeSuffixes.Length - 1) - { - sizeIndex++; - size /= 1000; - } - - return $"{size:N2} {sizeSuffixes[sizeIndex]}"; - } - - public static string FromDateTime(DateTime dateTime) - { - return dateTime.ToLocalTime().ToString("MM/dd/yyyy hh:mmtt"); - } - - public static string FromTimeSpan(TimeSpan timeSpan) - { - return $"{timeSpan.Hours:00}:{timeSpan.Minutes:00}:{timeSpan.Seconds:00}.{timeSpan.Milliseconds:N2}"; - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 2ad7211..dd8d36f 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -56,7 +56,7 @@ public async Task GetFileChangesAsync(string path, string[] ignore if (!Directory.Exists(path)) return Array.Empty(); List scannedFiles = new List(); - HashSet localFiles = FileScanner.GetFiles(path, ".", ignoreFolders).ToHashSet(); + HashSet localFiles = FileScanner.GetFiles(path, ignoreFolders, ".").ToHashSet(); IEnumerable remoteFiles = await _db.GetFilesAsync(path, false); await System.Threading.Tasks.Parallel.ForEachAsync(remoteFiles, ParallelConfig.Options, async (remoteFile, ct) => { @@ -152,7 +152,7 @@ public static long GetDirectorySize(string path) /// The root directory to search. /// If it should search recursively. /// An array of empty directories. - public static DirectoryInfo[] GetEmptyDirectories(string path, bool recursive = true) + public static IEnumerable GetEmptyDirectories(string path, bool recursive = true) { List list = new(); DirectoryInfo directory = new(path); @@ -164,12 +164,13 @@ public static DirectoryInfo[] GetEmptyDirectories(string path, bool recursive = foreach (DirectoryInfo di in directory.EnumerateDirectories("*", options)) { - Log.Debug($"Checking -> {di.FullName}"); - if (!di.EnumerateFileSystemInfos().Any()) list.Add(di); + var files = di.EnumerateFiles("*", options); + Log.Debug($"{files.Count()} files: {di.FullName}"); + if (!files.Any()) list.Add(di); } Log.Debug($"Found {list.Count} empty directories"); - return list.ToArray(); + return list.OrderByDescending(d => d.FullName.Count(c => c == Path.DirectorySeparatorChar)).ToArray(); } /// @@ -180,7 +181,7 @@ public static DirectoryInfo[] GetEmptyDirectories(string path, bool recursive = /// The time, as a . /// If it should search recursively. /// An array of directories in order of oldest first. - public static DirectoryInfo[] GetCleanableDirectories(string path, UnixTime start, bool recursive = true) + public static IEnumerable GetCleanableDirectories(string path, UnixTime start, bool recursive = true) { Dictionary list = new(); DirectoryInfo directory = new(path); @@ -199,14 +200,14 @@ public static DirectoryInfo[] GetCleanableDirectories(string path, UnixTime star } Log.Debug($"Found {list.Count} cleanable directories"); - return list.OrderBy(d => d.Value).ToDictionary().Keys.ToArray(); + return list.OrderBy(d => d.Value).ToDictionary().Keys; } - public static FileInfo[] GetCleanableFiles(string path, UnixTime start, bool recursive = true) + public static IEnumerable GetCleanableFiles(string path, UnixTime start, bool recursive = true) { Dictionary list = new(); Log.Debug($"Searching '{path}' for files older than {start.ToString("g")}"); - foreach (string file in GetFiles(path, "*")) + foreach (string file in GetFiles(path, [], "*", recursive)) { FileInfo fi = new FileInfo(file); DateTime compare = fi.CreationTime > fi.LastWriteTime ? fi.CreationTime : fi.LastWriteTime; @@ -215,56 +216,59 @@ public static FileInfo[] GetCleanableFiles(string path, UnixTime start, bool rec } Log.Debug($"Found {list.Count} cleanable files"); - return list.OrderBy(d => d.Value).ToDictionary().Keys.ToArray(); + return list.OrderBy(d => d.Value).ToDictionary().Keys; } public static IEnumerable GetFiles(string root, string searchPattern) { - return GetFiles(root, searchPattern, []); + return GetFiles(root, [], searchPattern); } - public static IEnumerable GetFiles(string root, string searchPattern, string[] exempt) + public static IEnumerable GetFiles(string root, string[] exempt, string searchPattern = "*", bool recursive = true) { Stack pending = new(); pending.Push(root); - while (pending.Count != 0) + + while (pending.Count > 0) { - string path = pending.Pop(); - IEnumerable? next = null; - try - { - if (!IsIgnored(path, exempt)) - { - //Log.Debug($"Searching -> {path}"); - next = Directory.EnumerateFiles(path, searchPattern); - } - // else - // { - // Log.Debug($"Ignored -> {path}"); - // } - } - catch - { - Log.Debug("No file access -> " + path); - } + string current = pending.Pop(); - if (next != null && next.Count() != 0) + if (IsIgnored(current, exempt)) { - foreach (string file in next) yield return file; + Log.Debug($"Ignored -> {current}"); + continue; } + // Get files in current directory + string[] files = []; try { - next = Directory.EnumerateDirectories(path); - foreach (string subdir in next) pending.Push(subdir); + files = Directory.GetFiles(current, searchPattern); } catch { - Log.Debug("No folder access -> " + path); + Log.Debug($"No file access -> {current}"); + } + + foreach (var file in files) yield return file; + if (recursive) + { + string[] subdirs = []; + try + { + subdirs = Directory.GetDirectories(current); + } + catch + { + Log.Debug($"No folder access -> {current}"); + } + + foreach (var dir in subdirs) pending.Push(dir); } } } + /// /// Scans a directory for duplicate files with the same name and size. /// diff --git a/Parallel.Core/Parallel.Core.csproj b/Parallel.Core/Parallel.Core.csproj index 950da5d..2844179 100644 --- a/Parallel.Core/Parallel.Core.csproj +++ b/Parallel.Core/Parallel.Core.csproj @@ -19,11 +19,11 @@ - - - - - + + + + + @@ -36,8 +36,8 @@ - - + + diff --git a/Parallel.Core/Settings/ParallelConfig.cs b/Parallel.Core/Settings/ParallelConfig.cs index ada6eb7..7d7268e 100644 --- a/Parallel.Core/Settings/ParallelConfig.cs +++ b/Parallel.Core/Settings/ParallelConfig.cs @@ -50,6 +50,19 @@ public class ParallelConfig /// public int MaxConcurrentProcesses { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 1, Environment.ProcessorCount); + /// + /// The amount of time, in days, to hold a file before it can be cleaned. + /// Default: 90 days + /// + public int RetentionPeriod { get; set; } = 90; + + /// + /// A collection of directories to be cleaned on the machine. + /// It's important to note that when using the service host this will delete any available file. + /// Default: Empty + /// + public HashSet CleanDirectories { get; } = CreateCleanDirectories(); + /// /// The profiles to use. /// When pulling, the CLI defaults to the first in the list. @@ -63,15 +76,11 @@ public class ParallelConfig public static ParallelConfig Load() { Log.Debug($"Loading config file: {ConfigFile}"); - if (File.Exists(ConfigFile)) - { - string json = File.ReadAllText(ConfigFile); - return JsonConvert.DeserializeObject(json); - } - else - { - return new ParallelConfig(); - } + if (!File.Exists(ConfigFile)) return new ParallelConfig(); + + string json = File.ReadAllText(ConfigFile); + ParallelConfig? config = JsonConvert.DeserializeObject(json); + return config ?? new ParallelConfig(); } /// @@ -84,6 +93,18 @@ public void Save() File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(this, Formatting.Indented)); } + /// + /// Creates a default array of cleanable directories. + /// + /// + private static HashSet CreateCleanDirectories() + { + return + [ + Path.GetTempPath(), + ]; + } + /// /// Asynchronously runs an for each using the limiter. /// diff --git a/Parallel.Core/Settings/RemoteVaultConfig.cs b/Parallel.Core/Settings/RemoteVaultConfig.cs index e885429..d31cba8 100644 --- a/Parallel.Core/Settings/RemoteVaultConfig.cs +++ b/Parallel.Core/Settings/RemoteVaultConfig.cs @@ -22,12 +22,6 @@ public class RemoteVaultConfig : LocalVaultConfig /// public int BackupInterval { get; set; } = 60; - /// - /// The amount of time, in days, to hold a file before it can be cleaned. - /// Default: 90 days - /// - public int RetentionPeriod { get; set; } = 90; - /// /// The amount of time, in days, to hold a file before it can be pruned. /// Default: 180 days (6 months) @@ -46,13 +40,6 @@ public class RemoteVaultConfig : LocalVaultConfig /// public HashSet IgnoreDirectories { get; } = CreateIgnoreDirectories(); - /// - /// A collection of directories to be cleaned on the machine. - /// It's important to note that when using the service host this will delete any available file. - /// Default: Empty - /// - public HashSet CleanDirectories { get; } = CreateCleanDirectories(); - /// /// A collection of deleted directories allowed to be pruned. /// Recommended when using a cloud-based to save on storage costs. @@ -66,14 +53,12 @@ public RemoteVaultConfig(LocalVaultConfig localVault) : base(localVault.Id, loca public RemoteVaultConfig(string profileName, FileSystemCredentials fsc) : base(profileName, fsc) { } [JsonConstructor] - public RemoteVaultConfig(string id, string name, FileSystemCredentials fileSystem, int backupInterval, int retentionPeriod, int prunePeriod, IEnumerable backupDirectories, IEnumerable ignoreDirectories, IEnumerable cleanDirectories, IEnumerable pruneDirectories) : base(id, name, fileSystem) + public RemoteVaultConfig(string id, string name, FileSystemCredentials fileSystem, int backupInterval, int prunePeriod, IEnumerable backupDirectories, IEnumerable ignoreDirectories, IEnumerable pruneDirectories) : base(id, name, fileSystem) { BackupInterval = backupInterval; - RetentionPeriod = retentionPeriod; PrunePeriod = prunePeriod; BackupDirectories = new HashSet(backupDirectories); IgnoreDirectories = new HashSet(ignoreDirectories); - CleanDirectories = new HashSet(cleanDirectories); PruneDirectories = new HashSet(pruneDirectories); } @@ -120,14 +105,6 @@ private static HashSet CreateIgnoreDirectories() return list; } - private static HashSet CreateCleanDirectories() - { - return - [ - Path.GetTempPath(), - ]; - } - #endregion /// From 53d9965498c633762a465c7104f4d16bc71566ed Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 11 Nov 2025 06:28:53 -0600 Subject: [PATCH 25/33] Added build script --- Build-Release.bat | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 Build-Release.bat diff --git a/Build-Release.bat b/Build-Release.bat new file mode 100644 index 0000000..11f9a88 --- /dev/null +++ b/Build-Release.bat @@ -0,0 +1,13 @@ +@echo off + +set SCRIPT_DIR=%~dp0 +set BUILDS_DIR="%SCRIPT_DIR%Builds" + +echo Building to folder: %BUILDS_DIR% +rd /s /q "%BUILDS_DIR%" + +echo Building Parallel.Cli... +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r win-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/win-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r osx-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/osx-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/linux-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-arm -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/linux-arm" \ No newline at end of file From 87e094b406e495d4217ae363895b46362a273e38 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 11 Nov 2025 07:04:01 -0600 Subject: [PATCH 26/33] Added disk command for looking at the vaults current capacity --- Parallel.Cli/Commands/DiskCommand.cs | 73 ++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 Parallel.Cli/Commands/DiskCommand.cs diff --git a/Parallel.Cli/Commands/DiskCommand.cs b/Parallel.Cli/Commands/DiskCommand.cs new file mode 100644 index 0000000..2444a13 --- /dev/null +++ b/Parallel.Cli/Commands/DiskCommand.cs @@ -0,0 +1,73 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using System.Data; +using Newtonsoft.Json.Linq; +using Parallel.Cli.Utils; +using Parallel.Core.Database; +using Parallel.Core.IO.Backup; +using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Settings; +using Parallel.Core.Utils; + +namespace Parallel.Cli.Commands +{ + public class DiskCommand : Command + { + private readonly Argument vaultArg = new("vault", "The vault config to use."); + + public DiskCommand() : base("disk", "Shows the current disk usage.") + { + this.AddArgument(vaultArg); + this.SetHandler(async (vault) => + { + CommandLine.WriteLine($"Retrieving disk information...", ConsoleColor.DarkGray); + LocalVaultConfig? config = ParallelConfig.GetVault(vault); + if (config == null) + { + CommandLine.WriteLine($"Unable to find vault with name: '{vault}'", ConsoleColor.Yellow); + return; + } + + await DisplayDiskInformationAsync(config); + }, vaultArg); + } + + private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) + { + ISyncManager syncManager = new FileSyncManager(vault); + if (!await syncManager.ConnectAsync()) + { + CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); + return; + } + + IDatabase db = syncManager.Database; + + long localSize = await db.GetLocalSizeAsync(); + long remoteSize = await db.GetRemoteSizeAsync(); + long totalLocalFiles = await db.GetTotalFilesAsync(false); + long totalDeletedFiles = await db.GetTotalFilesAsync(true); + + CommandLine.WriteLine($"Using profile '{vault.Name}' ({vault.Id}):"); + CommandLine.WriteLine($"Root Directory: {vault.FileSystem.RootDirectory}"); + CommandLine.WriteLine($"Managed Files: {(totalLocalFiles + totalDeletedFiles).ToString("N0")}"); + CommandLine.WriteLine($"Local Files: {totalLocalFiles.ToString("N0")}"); + CommandLine.WriteLine($"Deleted Files: {totalDeletedFiles.ToString("N0")}"); + CommandLine.WriteLine($"Local Size: {Formatter.FromBytes(localSize)}"); + CommandLine.WriteLine($"Remote Size: {Formatter.FromBytes(remoteSize)}"); + CommandLine.WriteLine($"Space Saved: {Math.Round((localSize - remoteSize) / (double)localSize * 100, 1)}%"); + + if (vault.FileSystem.Service.Equals(FileService.Local)) + { + DriveInfo drive = new(vault.FileSystem.RootDirectory); + long diskUsage = drive.TotalSize - drive.TotalFreeSpace; + CommandLine.WriteLine($"Total Usage: {Formatter.FromBytes(diskUsage)} ({Math.Round(diskUsage / (double)drive.TotalSize * 100, 1)}%)"); + CommandLine.WriteLine($"Disk Usage: {Formatter.FromBytes(diskUsage - remoteSize)} ({Math.Round((diskUsage - remoteSize) / (double)drive.TotalSize * 100, 1)}%)"); + CommandLine.WriteLine($"Disk Free: {Formatter.FromBytes(drive.TotalFreeSpace)} ({Math.Round(drive.TotalFreeSpace / (double)drive.TotalSize * 100, 1)}%)"); + CommandLine.WriteLine($"Disk Total: {Formatter.FromBytes(drive.TotalSize)}"); + } + } + } +} \ No newline at end of file From 6be8dd6af1e1957fd54780fd3babfde0f965b6fa Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 11 Nov 2025 07:17:54 -0600 Subject: [PATCH 27/33] Minor improvements --- Parallel.Cli/Commands/DiskCommand.cs | 6 +-- Parallel.Cli/Commands/PushCommand.cs | 12 ++--- Parallel.Cli/Commands/RestoreCommand.cs | 19 ++++++++ .../Database/Contexts/SqliteContext.cs | 45 ++++++++++++------- Parallel.Core/Database/IDatabase.cs | 4 ++ Parallel.Core/IO/Scanning/FileScanner.cs | 4 +- Parallel.Core/Settings/ParallelConfig.cs | 10 +++++ 7 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 Parallel.Cli/Commands/RestoreCommand.cs diff --git a/Parallel.Cli/Commands/DiskCommand.cs b/Parallel.Cli/Commands/DiskCommand.cs index 2444a13..57c8453 100644 --- a/Parallel.Cli/Commands/DiskCommand.cs +++ b/Parallel.Cli/Commands/DiskCommand.cs @@ -52,9 +52,9 @@ private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) CommandLine.WriteLine($"Using profile '{vault.Name}' ({vault.Id}):"); CommandLine.WriteLine($"Root Directory: {vault.FileSystem.RootDirectory}"); - CommandLine.WriteLine($"Managed Files: {(totalLocalFiles + totalDeletedFiles).ToString("N0")}"); - CommandLine.WriteLine($"Local Files: {totalLocalFiles.ToString("N0")}"); - CommandLine.WriteLine($"Deleted Files: {totalDeletedFiles.ToString("N0")}"); + CommandLine.WriteLine($"Managed Files: {(totalLocalFiles + totalDeletedFiles):N0}"); + CommandLine.WriteLine($"Local Files: {totalLocalFiles:N0}"); + CommandLine.WriteLine($"Deleted Files: {totalDeletedFiles:N0}"); CommandLine.WriteLine($"Local Size: {Formatter.FromBytes(localSize)}"); CommandLine.WriteLine($"Remote Size: {Formatter.FromBytes(remoteSize)}"); CommandLine.WriteLine($"Space Saved: {Math.Round((localSize - remoteSize) / (double)localSize * 100, 1)}%"); diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index 4662efc..b5d18e0 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -14,15 +14,15 @@ namespace Parallel.Cli.Commands { public class PushCommand : Command { - private Command addCmd = new("add", "Adds a new directory to the backup list."); - private Command listCmd = new("list", "Shows all directories in the backup list."); - private Command removeCmd = new("remove", "Removes a directory from the backup list."); + private Command addCmd = new("add", "Adds a new directory to the sync list."); + private Command listCmd = new("list", "Shows all directories in the sync list."); + private Command removeCmd = new("remove", "Removes a directory from the sync list."); - private readonly Option _sourceArg = new(["--path", "-p"], "The source path to backup."); + private readonly Option _sourceArg = new(["--path", "-p"], "The source path to sync."); private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); - public PushCommand() : base("push", "Pushes changed files to vaults.") + public PushCommand() : base("push", "Pushes changed files to one vault or multiple.") { this.AddOption(_sourceArg); this.AddOption(_configOpt); @@ -50,7 +50,7 @@ private async Task SyncPathAsync(string path) { await Program.Settings.ForEachVaultAsync(async vault => { - FileSyncManager syncManager = new FileSyncManager(vault); + ISyncManager syncManager = new FileSyncManager(vault); if (!await syncManager.ConnectAsync()) { CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); diff --git a/Parallel.Cli/Commands/RestoreCommand.cs b/Parallel.Cli/Commands/RestoreCommand.cs new file mode 100644 index 0000000..7b55542 --- /dev/null +++ b/Parallel.Cli/Commands/RestoreCommand.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; + +namespace Parallel.Cli.Commands +{ + public class RestoreCommand : Command + { + private Command createCmd = new("create", "Creates a new recovery point current of the system state."); + private Command listCmd = new("list", "Lists all available recovery points."); + private Command restoreCmd = new("restore", "Restores the system state from a previous recovery point."); + private Option credsOpt = new(["--credentials", "-c"], "The file system credentials to use."); + + public RestoreCommand() : base("restore", "Creates or loads a system restore point.") + { + + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index f4ca34d..923820d 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -56,31 +56,46 @@ public async Task InitializeAsync() /// public async Task AddFileAsync(SystemFile file) { - using (IDbConnection connection = CreateConnection()) - { - string sql = @"INSERT OR REPLACE INTO files (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) VALUES (@Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @CheckSum);"; - return await connection.ExecuteAsync(sql, new { file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.CheckSum }) > 0; - } + using IDbConnection connection = CreateConnection(); + string sql = @"INSERT OR REPLACE INTO files (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) VALUES (@Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @CheckSum);"; + return await connection.ExecuteAsync(sql, new { file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.CheckSum }) > 0; + } + + public async Task GetLocalSizeAsync() + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT SUM(localsize) FROM files;"; + return await connection.QuerySingleOrDefaultAsync(sql); + } + + public async Task GetRemoteSizeAsync() + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT SUM(remotesize) FROM files;"; + return await connection.QuerySingleOrDefaultAsync(sql); + } + + public async Task GetTotalFilesAsync(bool deleted) + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT COUNT(*) FROM files WHERE deleted = @deleted;"; + return await connection.QuerySingleOrDefaultAsync(sql, new { deleted }); } /// public async Task> GetFilesAsync(string path, bool deleted) { - using (IDbConnection connection = CreateConnection()) - { - string sql = $"SELECT * FROM files WHERE deleted = {deleted} ORDER BY lastupdate DESC"; - return await connection.QueryAsync(sql); - } + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT * FROM files WHERE deleted = {deleted} ORDER BY lastupdate DESC"; + return await connection.QueryAsync(sql); } /// public async Task GetFileAsync(string path) { - using (IDbConnection connection = CreateConnection()) - { - string sql = $"SELECT (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; - return await connection.QuerySingleOrDefaultAsync(sql); - } + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; + return await connection.QuerySingleOrDefaultAsync(sql); } #endregion diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index 698be33..3327075 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -74,6 +74,10 @@ public interface IDatabase : IDisposable /// True if successful, false otherwise Task AddFileAsync(SystemFile file); + Task GetLocalSizeAsync(); + Task GetRemoteSizeAsync(); + Task GetTotalFilesAsync(bool deleted); + #endregion #region History diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index dd8d36f..2fe63db 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -278,7 +278,7 @@ public static Dictionary GetDuplicateFiles(string path) { Dictionary> dict = new(); IEnumerable files = GetFiles(path, "*"); - foreach (string file in files) + System.Threading.Tasks.Parallel.ForEach(files, ParallelConfig.Options, file => { SystemFile entry = new(file); if (dict.TryGetValue(entry.Name, out List value)) @@ -293,7 +293,7 @@ public static Dictionary GetDuplicateFiles(string path) { dict.Add(entry.Name, new List { entry }); } - } + }); return dict.Where(kv => kv.Value.Count > 1).OrderByDescending(kv => kv.Value.Count).ToDictionary(k => k.Key, v => v.Value.OrderBy(l => l.LastWrite.TotalMilliseconds).ToArray()); } diff --git a/Parallel.Core/Settings/ParallelConfig.cs b/Parallel.Core/Settings/ParallelConfig.cs index 7d7268e..17d382a 100644 --- a/Parallel.Core/Settings/ParallelConfig.cs +++ b/Parallel.Core/Settings/ParallelConfig.cs @@ -123,5 +123,15 @@ await System.Threading.Tasks.Parallel.ForEachAsync(Vaults, options, async (vault await actionAsync(vault); }); } + + /// + /// Gets a by either its id or name. + /// + /// + /// + public static LocalVaultConfig? GetVault(string vault) + { + return Load().Vaults.FirstOrDefault(v => v.Id.Equals(vault, StringComparison.OrdinalIgnoreCase) || v.Name.Equals(vault, StringComparison.OrdinalIgnoreCase)); + } } } \ No newline at end of file From a28031274f59f0ea321fa769b018509091378de6 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:00:26 -0600 Subject: [PATCH 28/33] Changed up the display --- Parallel.Cli/Commands/DiskCommand.cs | 14 ++++++---- Parallel.Cli/Commands/VaultsCommand.cs | 38 ++++++++++++++++++++++++-- Parallel.Cli/Utils/CommandLine.cs | 20 ++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/Parallel.Cli/Commands/DiskCommand.cs b/Parallel.Cli/Commands/DiskCommand.cs index 57c8453..7835318 100644 --- a/Parallel.Cli/Commands/DiskCommand.cs +++ b/Parallel.Cli/Commands/DiskCommand.cs @@ -15,14 +15,14 @@ namespace Parallel.Cli.Commands { public class DiskCommand : Command { - private readonly Argument vaultArg = new("vault", "The vault config to use."); + private readonly Argument configArg = new("config", "The vault configuration to use."); public DiskCommand() : base("disk", "Shows the current disk usage.") { - this.AddArgument(vaultArg); + this.AddArgument(configArg); this.SetHandler(async (vault) => { - CommandLine.WriteLine($"Retrieving disk information...", ConsoleColor.DarkGray); + CommandLine.WriteLine($"Retrieving vault information...", ConsoleColor.DarkGray); LocalVaultConfig? config = ParallelConfig.GetVault(vault); if (config == null) { @@ -31,7 +31,7 @@ public DiskCommand() : base("disk", "Shows the current disk usage.") } await DisplayDiskInformationAsync(config); - }, vaultArg); + }, configArg); } private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) @@ -44,13 +44,13 @@ private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) } IDatabase db = syncManager.Database; - long localSize = await db.GetLocalSizeAsync(); long remoteSize = await db.GetRemoteSizeAsync(); long totalLocalFiles = await db.GetTotalFilesAsync(false); long totalDeletedFiles = await db.GetTotalFilesAsync(true); - CommandLine.WriteLine($"Using profile '{vault.Name}' ({vault.Id}):"); + CommandLine.WriteLine($"Using vault '{vault.Name}' ({vault.Id}):"); + CommandLine.WriteLine($"Service Type: {vault.FileSystem.Service}"); CommandLine.WriteLine($"Root Directory: {vault.FileSystem.RootDirectory}"); CommandLine.WriteLine($"Managed Files: {(totalLocalFiles + totalDeletedFiles):N0}"); CommandLine.WriteLine($"Local Files: {totalLocalFiles:N0}"); @@ -68,6 +68,8 @@ private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) CommandLine.WriteLine($"Disk Free: {Formatter.FromBytes(drive.TotalFreeSpace)} ({Math.Round(drive.TotalFreeSpace / (double)drive.TotalSize * 100, 1)}%)"); CommandLine.WriteLine($"Disk Total: {Formatter.FromBytes(drive.TotalSize)}"); } + + await syncManager.DisconnectAsync(); } } } \ No newline at end of file diff --git a/Parallel.Cli/Commands/VaultsCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs index b1c8fa0..b981eb6 100644 --- a/Parallel.Cli/Commands/VaultsCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -3,7 +3,9 @@ using System.CommandLine; using Parallel.Cli.Utils; using Parallel.Core.Database; +using Parallel.Core.IO.Backup; using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; using Parallel.Core.Security; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -12,7 +14,8 @@ namespace Parallel.Cli.Commands { public class VaultsCommand : Command { - private Option configOpt = new(["--config", "-c"], "The vault configuration to use."); + private readonly Argument configArg = new("config", "The vault configuration to use."); + private readonly Option configOpt = new(["--config", "-c"], "The vault configuration to use."); private Command addCmd = new("add", "Adds a new vault configuration."); private Command editCmd = new("edit", "Edits a vault configuration."); @@ -56,8 +59,8 @@ public VaultsCommand() : base("vaults", "View or edit the vaults.") fsc.Password = CommandLine.ReadPassword("Password"); } - fsc.Encrypt = CommandLine.ReadBool("Encrypt files? (y/n)", false); - fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); + //fsc.Encrypt = CommandLine.ReadBool("Encrypt files? (y/n)", false); + //fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); string? profileName = CommandLine.ReadString("Profile Name"); LocalVaultConfig localVault = new LocalVaultConfig(profileName, fsc); @@ -67,6 +70,35 @@ public VaultsCommand() : base("vaults", "View or edit the vaults.") CommandLine.WriteLine($"Saved new storage vault: '{localVault.Name}' ({localVault.Id})"); }); + this.AddCommand(viewCmd); + viewCmd.AddArgument(configArg); + viewCmd.SetHandler(async (vault) => + { + CommandLine.WriteLine($"Retrieving vault information...", ConsoleColor.DarkGray); + LocalVaultConfig? config = ParallelConfig.GetVault(vault); + if (config == null) + { + CommandLine.WriteLine($"Unable to find vault with name: '{vault}'", ConsoleColor.Yellow); + return; + } + + ISyncManager syncManager = new FileSyncManager(config); + if (!await syncManager.ConnectAsync()) + { + CommandLine.WriteLine(config, $"Failed to connect to vault '{config.Name}'!", ConsoleColor.Red); + return; + } + + RemoteVaultConfig remoteVault = syncManager.RemoteVault; + CommandLine.WriteLine($"'{remoteVault.Name}' ({remoteVault.Id}):"); + CommandLine.WriteArray($"Backup Directories", remoteVault.BackupDirectories); + CommandLine.WriteArray($"Ignore Directories", remoteVault.IgnoreDirectories); + CommandLine.WriteArray($"Prune Directories", remoteVault.PruneDirectories); + CommandLine.WriteLine($"Prune Period: {remoteVault.PrunePeriod} days"); + + await syncManager.DisconnectAsync(); + }, configArg); + this.AddCommand(setCmd); setCmd.SetHandler(() => { diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index 90e9dfc..3f16a0c 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -156,5 +156,25 @@ public static void ProgressBar(double part, double total, TimeSpan elapsed, Cons Console.Write($"\r{percentStr} [{progressBar.ToString()}] {remainingStr}"); } + + public static void WriteArray(string message, IEnumerable elements) + { + lock (_consoleLock) + { + string[] array = elements.Order().ToArray(); + if (array.Length > 0) + { + Console.WriteLine($"> {message}:"); + foreach (string item in array) + { + Console.WriteLine($"> - {item}"); + } + } + else + { + Console.WriteLine($"> {message}: 0"); + } + } + } } } \ No newline at end of file From 866b027e1741354f66e263870baf38328c0b6536 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 11 Nov 2025 21:42:32 -0600 Subject: [PATCH 29/33] Various changes --- .gitignore | 5 ++++- Build-Release.bat | 8 ++++---- Parallel.Cli/Commands/DiskCommand.cs | 2 +- Parallel.Cli/Parallel.Cli.csproj | 10 +++++----- Parallel.Core/Parallel.Core.csproj | 15 +++++++-------- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 707d565..56650c3 100644 --- a/.gitignore +++ b/.gitignore @@ -398,4 +398,7 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -.idea/ \ No newline at end of file +.idea/ + +# Batch Script Outputs +Builds/ \ No newline at end of file diff --git a/Build-Release.bat b/Build-Release.bat index 11f9a88..d53b3ef 100644 --- a/Build-Release.bat +++ b/Build-Release.bat @@ -7,7 +7,7 @@ echo Building to folder: %BUILDS_DIR% rd /s /q "%BUILDS_DIR%" echo Building Parallel.Cli... -CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r win-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/win-x64" -CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r osx-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/osx-x64" -CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/linux-x64" -CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-arm -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%/linux-arm" \ No newline at end of file +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r win-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\win-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r osx-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\osx-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\linux-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-arm -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\linux-arm" \ No newline at end of file diff --git a/Parallel.Cli/Commands/DiskCommand.cs b/Parallel.Cli/Commands/DiskCommand.cs index 7835318..63a0ad4 100644 --- a/Parallel.Cli/Commands/DiskCommand.cs +++ b/Parallel.Cli/Commands/DiskCommand.cs @@ -57,7 +57,7 @@ private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) CommandLine.WriteLine($"Deleted Files: {totalDeletedFiles:N0}"); CommandLine.WriteLine($"Local Size: {Formatter.FromBytes(localSize)}"); CommandLine.WriteLine($"Remote Size: {Formatter.FromBytes(remoteSize)}"); - CommandLine.WriteLine($"Space Saved: {Math.Round((localSize - remoteSize) / (double)localSize * 100, 1)}%"); + CommandLine.WriteLine($"Space Saved: {Math.Round((localSize - remoteSize) / (double)localSize * 100, 2)}%"); if (vault.FileSystem.Service.Equals(FileService.Local)) { diff --git a/Parallel.Cli/Parallel.Cli.csproj b/Parallel.Cli/Parallel.Cli.csproj index edd0157..fb20e4e 100644 --- a/Parallel.Cli/Parallel.Cli.csproj +++ b/Parallel.Cli/Parallel.Cli.csproj @@ -18,14 +18,14 @@ $(DefineConstants);TRACE - true - true + True + True $(DefineConstants);DEBUG;TRACE - false - true - full + False + True + Full False diff --git a/Parallel.Core/Parallel.Core.csproj b/Parallel.Core/Parallel.Core.csproj index 2844179..fdd0ec4 100644 --- a/Parallel.Core/Parallel.Core.csproj +++ b/Parallel.Core/Parallel.Core.csproj @@ -3,18 +3,17 @@ net9.0 enable + True + Parallel.Core + AnyCPU enable - 1.0.1.0 - Entex Interactive, LLC - Copyright Entex Interactive, LLC. All Rights Reserved. + Parallel.Core + 1.0.0.0 + Kyle Ebbinga + Copyright $(Company). All Rights Reserved. $(AssemblyVersion) $(VersionPrefix)$(AssemblyVersion) $(Company) - Parallel.Core - True - Parallel.Core - Debug;Release;Analyze - AnyCPU From cb780e82225712e60ce4f188d455747e49eb5c03 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:10:39 -0600 Subject: [PATCH 30/33] Fixed some explicit type issues --- Parallel.Cli/Commands/DuplicatesCommand.cs | 1 - Parallel.Cli/Commands/VaultsCommand.cs | 1 - Parallel.Core/IO/Scanning/FileScanner.cs | 3 +-- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Parallel.Cli/Commands/DuplicatesCommand.cs b/Parallel.Cli/Commands/DuplicatesCommand.cs index ac1956d..d5f1393 100644 --- a/Parallel.Cli/Commands/DuplicatesCommand.cs +++ b/Parallel.Cli/Commands/DuplicatesCommand.cs @@ -2,7 +2,6 @@ using System.CommandLine; using Parallel.Cli.Utils; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.Scanning; using Parallel.Core.Models; using Parallel.Core.Settings; diff --git a/Parallel.Cli/Commands/VaultsCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs index b981eb6..8a40bb7 100644 --- a/Parallel.Cli/Commands/VaultsCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -3,7 +3,6 @@ using System.CommandLine; using Parallel.Cli.Utils; using Parallel.Core.Database; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.FileSystem; using Parallel.Core.IO.Syncing; using Parallel.Core.Security; diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 2fe63db..69edf97 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -5,7 +5,6 @@ using System.Text; using Newtonsoft.Json.Linq; using Parallel.Core.Database; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.Syncing; using Parallel.Core.Models; using Parallel.Core.Settings; @@ -164,7 +163,7 @@ public static IEnumerable GetEmptyDirectories(string path, bool r foreach (DirectoryInfo di in directory.EnumerateDirectories("*", options)) { - var files = di.EnumerateFiles("*", options); + IEnumerable files = di.EnumerateFiles("*", options); Log.Debug($"{files.Count()} files: {di.FullName}"); if (!files.Any()) list.Add(di); } From db2e64c0ab2ccc2a795ca7b2fce8a171ef4e21d7 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:10:59 -0600 Subject: [PATCH 31/33] New debug setting --- Parallel.Cli/Program.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index b179be2..6aa1da6 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -16,8 +16,11 @@ public static async Task Main(string[] args) Settings = ParallelConfig.Load(); string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", "latest.txt"); if (File.Exists(logFile)) File.Delete(logFile); + #if DEBUG + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); + #else Log.Logger = new LoggerConfiguration().MinimumLevel.Warning().WriteTo.File(logFile).CreateLogger(); - //Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); + #endif AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); Log.Information($"{assembly.Name} [Version {assembly.Version}]"); From 99ead3d6d9d307e2adfe117b833564c5a20237ee Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:11:43 -0600 Subject: [PATCH 32/33] Fixed issue #21 --- Parallel.Cli/Commands/CleanCommand.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Parallel.Cli/Commands/CleanCommand.cs b/Parallel.Cli/Commands/CleanCommand.cs index 324a208..6b5cb35 100644 --- a/Parallel.Cli/Commands/CleanCommand.cs +++ b/Parallel.Cli/Commands/CleanCommand.cs @@ -2,7 +2,6 @@ using System.CommandLine; using Parallel.Cli.Utils; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.Scanning; using Parallel.Core.IO.Syncing; using Parallel.Core.Settings; @@ -13,6 +12,8 @@ namespace Parallel.Cli.Commands public class CleanCommand : Command { private long _freedBytes = 0; + private int _filesCount = 0; + private int _dirsCount = 0; private readonly Option _sourceOpt = new(["--path", "-p"], "The source path to clean."); private readonly Option _daysOpt = new(["--days", "-d"], "The amount of days to hang onto files."); @@ -39,6 +40,7 @@ public CleanCommand() : base("clean", "Cleans up the file system by removing old await CleanDirectoryAsync(config, path, days, recursive, verbose); } + CommandLine.WriteLine($"Successfully cleaned {_filesCount:N0} files and {_dirsCount:N0} directories, ({Formatter.FromBytes(_freedBytes)} removed)", ConsoleColor.Green); }, _sourceOpt, _daysOpt, _recursiveOpt, _verboseOpt); } @@ -62,7 +64,6 @@ private async Task CleanDirectoryAsync(ParallelConfig config, string path, int d UnixTime minTime = UnixTime.FromMilliseconds(UnixTime.Now.TotalMilliseconds - (days * UnixTime.Day)); IEnumerable cleanableFiles = FileScanner.GetCleanableFiles(path, minTime, recursive); if (!cleanableFiles.Any()) CommandLine.WriteLine($"No cleanable files were found in the provided path.", ConsoleColor.Green); - int filesCount = cleanableFiles.Count(); await System.Threading.Tasks.Parallel.ForEachAsync(cleanableFiles, ParallelConfig.Options, (fi, ct) => { @@ -71,6 +72,7 @@ await System.Threading.Tasks.Parallel.ForEachAsync(cleanableFiles, ParallelConfi try { _freedBytes += fi.Length; + _filesCount++; fi.Delete(); } catch (Exception ex) @@ -85,11 +87,10 @@ await System.Threading.Tasks.Parallel.ForEachAsync(cleanableFiles, ParallelConfi IEnumerable directories = FileScanner.GetEmptyDirectories(path, recursive); if (!directories.Any()) CommandLine.WriteLine($"No empty directories were found in the provided path.", ConsoleColor.Green); - int directoriesCount = directories.Count(); await System.Threading.Tasks.Parallel.ForEachAsync(directories, ParallelConfig.Options, (di, ct) => { - if (di.Exists && di.EnumerateFiles().Any()) + if (di.Exists && !di.EnumerateFiles().Any()) { try { @@ -98,7 +99,7 @@ await System.Threading.Tasks.Parallel.ForEachAsync(directories, ParallelConfig.O } catch (Exception ex) { - Log.Warning($"{ex.GetBaseException().Message}"); + Log.Error($"{ex.GetBaseException().Message}"); } } @@ -113,15 +114,13 @@ await System.Threading.Tasks.Parallel.ForEachAsync(directories, ParallelConfig.O { Log.Debug($"Removing empty directory: {currentDir.FullName}"); currentDir.Delete(true); - directoriesCount++; + _dirsCount++; } catch (Exception ex) { Log.Warning($"{ex.GetBaseException().Message}"); } } - - if(filesCount > 0 || directoriesCount > 0) CommandLine.WriteLine($"Successfully cleaned {filesCount:N0} files and {directoriesCount:N0} directories, ({Formatter.FromBytes(_freedBytes)} removed)", ConsoleColor.Green); } } } \ No newline at end of file From 43c806b46cb5b5ece24d481002a61d31bd696524 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Fri, 5 Dec 2025 07:02:18 -0600 Subject: [PATCH 33/33] This commit is a mess, ngl --- Parallel.Cli/Commands/DiskCommand.cs | 3 +- Parallel.Cli/Commands/PullCommand.cs | 88 ++++++++++++++++++- Parallel.Cli/Commands/PushCommand.cs | 5 +- Parallel.Cli/Commands/VaultsCommand.cs | 2 +- .../Database/Contexts/SqliteContext.cs | 8 ++ Parallel.Core/Database/IDatabase.cs | 1 + Parallel.Core/IO/Blobs/BlobStorage.cs | 81 +++++++++++++++++ Parallel.Core/IO/PathBuilder.cs | 6 ++ Parallel.Core/IO/Scanning/FileScanner.cs | 22 ++--- Parallel.Core/IO/Syncing/BaseSyncManager.cs | 18 +--- Parallel.Core/IO/Syncing/BlobSyncManager.cs | 43 +++++++++ Parallel.Core/IO/Syncing/DeltaSyncManager.cs | 2 +- Parallel.Core/IO/Syncing/FileSyncManager.cs | 13 +-- Parallel.Core/IO/Syncing/SyncManager.cs | 9 +- Parallel.Core/Security/HashGenerator.cs | 10 +++ 15 files changed, 254 insertions(+), 57 deletions(-) create mode 100644 Parallel.Core/IO/Blobs/BlobStorage.cs create mode 100644 Parallel.Core/IO/Syncing/BlobSyncManager.cs diff --git a/Parallel.Cli/Commands/DiskCommand.cs b/Parallel.Cli/Commands/DiskCommand.cs index 63a0ad4..df4e46f 100644 --- a/Parallel.Cli/Commands/DiskCommand.cs +++ b/Parallel.Cli/Commands/DiskCommand.cs @@ -5,7 +5,6 @@ using Newtonsoft.Json.Linq; using Parallel.Cli.Utils; using Parallel.Core.Database; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.FileSystem; using Parallel.Core.IO.Syncing; using Parallel.Core.Settings; @@ -36,7 +35,7 @@ public DiskCommand() : base("disk", "Shows the current disk usage.") private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) { - ISyncManager syncManager = new FileSyncManager(vault); + ISyncManager syncManager = SyncManager.CreateNew(vault); if (!await syncManager.ConnectAsync()) { CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); diff --git a/Parallel.Cli/Commands/PullCommand.cs b/Parallel.Cli/Commands/PullCommand.cs index 23d40ba..fe71931 100644 --- a/Parallel.Cli/Commands/PullCommand.cs +++ b/Parallel.Cli/Commands/PullCommand.cs @@ -1,9 +1,93 @@ // Copyright 2025 Kyle Ebbinga +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.Diagnostics; +using Parallel.Core.IO; +using Parallel.Core.IO.Scanning; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Models; +using Parallel.Core.Settings; + namespace Parallel.Cli.Commands { - public class PullCommand + public class PullCommand : Command { - + private readonly Option _sourceArg = new(["--path", "-p"], "The source path to sync."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); + private readonly Option _forceOpt = new(["--force", "-f"], "Forces the pull overwriting any files."); + + public PullCommand() : base("pull", "Pulls changes from a vault.") + { + this.AddOption(_sourceArg); + this.AddOption(_configOpt); + this.AddOption(_forceOpt); + this.SetHandler(async (path, config, force) => + { + LocalVaultConfig? vault = ParallelConfig.GetVault(config); + if (vault == null) + { + CommandLine.WriteLine($"Unable to find vault with name: '{vault}'", ConsoleColor.Yellow); + return; + } + + await PullPathAsync(vault, path, force); + }, _sourceArg, _configOpt, _forceOpt); + } + + private async Task PullPathAsync(LocalVaultConfig vault, string path, bool force) + { + ISyncManager syncManager = SyncManager.CreateNew(vault); + if (!await syncManager.ConnectAsync()) + { + CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); + return; + } + + string fullPath = Path.GetFullPath(path); + if (PathBuilder.IsFile(fullPath)) + { + await PullFileAsync(syncManager, fullPath, force); + return; + } + + IEnumerable files = await syncManager.Database.GetFilesAsync(fullPath); + if (!files.Any()) + { + CommandLine.WriteLine("The provided directory has not been pushed!", ConsoleColor.Yellow); + return; + } + + List pullFiles = new List(); + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + { + if (!File.Exists(file.LocalPath) || FileScanner.HasChanged(file, new SystemFile(file.LocalPath)) || force) pullFiles.Add(file); + }); + + Log.Debug($"Pulling {pullFiles.Count} files..."); + await syncManager.PullFilesAsync(pullFiles.ToArray(), new ProgressLogger()); + CommandLine.WriteLine(vault, $"Successfully pulled {pullFiles.Count:N0} files from '{vault.FileSystem.RootDirectory}'.", ConsoleColor.Green); + } + + private async Task PullFileAsync(ISyncManager syncManager, string fullPath, bool force) + { + SystemFile? remoteFile = await syncManager.Database.GetFileAsync(fullPath); + if (remoteFile == null) + { + CommandLine.WriteLine("The provided file has not been pushed!", ConsoleColor.Yellow); + return; + } + + SystemFile localFile = new SystemFile(fullPath); + if (!(FileScanner.HasChanged(localFile, remoteFile) || force)) + { + CommandLine.WriteLine("Cannot overwrite an existing file!", ConsoleColor.Yellow); + return; + } + + Log.Debug($"Pulling '{fullPath}'"); + await syncManager.PullFilesAsync([remoteFile], new ProgressLogger()); + CommandLine.WriteLine(syncManager.RemoteVault, $"Successfully pulled file from '{syncManager.RemoteVault.FileSystem.RootDirectory}'.", ConsoleColor.Green); + } } } \ No newline at end of file diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index b5d18e0..f5d388d 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -4,7 +4,6 @@ using Parallel.Cli.Utils; using Parallel.Core.Diagnostics; using Parallel.Core.IO; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.Scanning; using Parallel.Core.IO.Syncing; using Parallel.Core.Models; @@ -22,7 +21,7 @@ public class PushCommand : Command private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); - public PushCommand() : base("push", "Pushes changed files to one vault or multiple.") + public PushCommand() : base("push", "Pushes changed files to vaults.") { this.AddOption(_sourceArg); this.AddOption(_configOpt); @@ -50,7 +49,7 @@ private async Task SyncPathAsync(string path) { await Program.Settings.ForEachVaultAsync(async vault => { - ISyncManager syncManager = new FileSyncManager(vault); + ISyncManager syncManager = SyncManager.CreateNew(vault); if (!await syncManager.ConnectAsync()) { CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); diff --git a/Parallel.Cli/Commands/VaultsCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs index 8a40bb7..8ad97f2 100644 --- a/Parallel.Cli/Commands/VaultsCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -81,7 +81,7 @@ public VaultsCommand() : base("vaults", "View or edit the vaults.") return; } - ISyncManager syncManager = new FileSyncManager(config); + ISyncManager syncManager = SyncManager.CreateNew(config); if (!await syncManager.ConnectAsync()) { CommandLine.WriteLine(config, $"Failed to connect to vault '{config.Name}'!", ConsoleColor.Red); diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 923820d..dc74ff8 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -82,6 +82,14 @@ public async Task GetTotalFilesAsync(bool deleted) return await connection.QuerySingleOrDefaultAsync(sql, new { deleted }); } + /// + public async Task> GetFilesAsync(string path) + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT * FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; + return await connection.QueryAsync(sql); + } + /// public async Task> GetFilesAsync(string path, bool deleted) { diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index 3327075..4564eb8 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -96,6 +96,7 @@ public interface IDatabase : IDisposable #endregion + Task> GetFilesAsync(string path); Task> GetFilesAsync(string path, bool deleted); Task GetFileAsync(string path); } diff --git a/Parallel.Core/IO/Blobs/BlobStorage.cs b/Parallel.Core/IO/Blobs/BlobStorage.cs new file mode 100644 index 0000000..630a143 --- /dev/null +++ b/Parallel.Core/IO/Blobs/BlobStorage.cs @@ -0,0 +1,81 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Diagnostics; +using Parallel.Core.Security; + +namespace Parallel.Core.IO.Blobs +{ + /// + /// Represents the way to chunk files into blobs for syncing. + /// + public class BlobStorage + { + /// + /// The size, in bytes, to use for chunks of a file. + /// + public int ChunkSize { get; set; } + + /// + /// Gets the temp directory for storing blobs. + /// + public string TempDirectory { get; set; } + + public BlobStorage(string tempDir, int chunkSize = 4194304) + { + TempDirectory = tempDir; + ChunkSize = chunkSize; + } + + /// + /// Chunks a file into hashes for blob storage. + /// + /// The source path of the file. + /// The destination to send chunked objects to. + /// + /// + public async Task> ChunkFileAsync(string sourcePath, string destPath, IProgressReporter progress) + { + List chunkHashes = new List(); + await using FileStream fs = File.OpenRead(sourcePath); + byte[] buffer = new byte[ChunkSize]; + int bytesRead = 0; + + while ((bytesRead = await fs.ReadAsync(buffer)) > 0) + { + byte[] chunkData = new byte[bytesRead]; + Buffer.BlockCopy(buffer, 0, chunkData, 0, bytesRead); + + string hash = HashGenerator.CreateSHA256(chunkData); + string chunkPath = PathBuilder.GetObjectPath(destPath, hash); + + if(!File.Exists(chunkPath)) await File.WriteAllBytesAsync(chunkPath, chunkData); + chunkHashes.Add(hash); + } + + Log.Debug($"Wrote {chunkHashes.Count} hashes to {destPath}"); + return chunkHashes; + } + + /// + /// Assembles a file from the chunked hashes. + /// + /// + /// The path to the chunked objects' folder. + /// + public async Task AssembleFileAsync(IEnumerable chunkHashes, string sourcePath, string createFilePath) + { + Log.Debug($"Assembling '{createFilePath}' from {chunkHashes.Count()} hashes."); + await using FileStream createStream = File.Create(createFilePath); + foreach (string hash in chunkHashes) + { + string chunkPath = PathBuilder.GetObjectPath(sourcePath, hash); + if(!File.Exists(chunkPath)) throw new FileNotFoundException($"Missing chunk for hash: {hash}"); + + await using FileStream chunkStream = File.OpenRead(chunkPath); + await chunkStream.CopyToAsync(createStream); + } + + await createStream.FlushAsync(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/PathBuilder.cs b/Parallel.Core/IO/PathBuilder.cs index 4cf7b6d..b2b12fd 100644 --- a/Parallel.Core/IO/PathBuilder.cs +++ b/Parallel.Core/IO/PathBuilder.cs @@ -167,5 +167,11 @@ public static bool IsFile(string path) { return !Directory.Exists(path) && File.Exists(path); } + + public static string GetObjectPath(string basePath, string hash) + { + if (hash.Length < 8) throw new ArgumentException("Hash too short for sharding", nameof(hash)); + return Path.Combine(basePath, hash.Substring(0, 2), hash.Substring(2, 2), hash.Substring(4, 2), hash.Substring(6, 2), hash); + } } } \ No newline at end of file diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 69edf97..f4a210d 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -59,7 +59,7 @@ public async Task GetFileChangesAsync(string path, string[] ignore IEnumerable remoteFiles = await _db.GetFilesAsync(path, false); await System.Threading.Tasks.Parallel.ForEachAsync(remoteFiles, ParallelConfig.Options, async (remoteFile, ct) => { - if (File.Exists(remoteFile.LocalPath) && remoteFile.RemotePath != null) + if (File.Exists(remoteFile.LocalPath)) { SystemFile localFile = new SystemFile(remoteFile.LocalPath); if (IsIgnored(localFile.LocalPath, ignoreFolders)) @@ -86,17 +86,7 @@ await System.Threading.Tasks.Parallel.ForEachAsync(remoteFiles, ParallelConfig.O } }); - // foreach (SystemFile remoteFile in remoteFiles) - // { - // - // } - Log.Debug($"{localFiles.Count} files are untracked! Adding..."); - // foreach (var file in localFiles) - // { - // - // } - await System.Threading.Tasks.Parallel.ForEachAsync(localFiles, ParallelConfig.Options, async (file, ct) => { if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) @@ -114,12 +104,14 @@ await System.Threading.Tasks.Parallel.ForEachAsync(localFiles, ParallelConfig.Op /// /// Gets if a file has changed. /// - /// The base file to compare. - /// The remote file to compare to. + /// The source file to compare. + /// The target file to compare to. /// True is success, otherwise false. - public static bool HasChanged(SystemFile localFile, SystemFile? remoteFile) + public static bool HasChanged(SystemFile sourcePath, SystemFile? targetPath) { - return remoteFile == null || (localFile.LastWrite.TotalMilliseconds > remoteFile.LastWrite.TotalMilliseconds && !localFile.CheckSum.SequenceEqual(remoteFile.CheckSum)); + Console.WriteLine($"{sourcePath.Name}: {targetPath} == null || ({sourcePath.LastWrite.TotalMilliseconds} > {targetPath.LastWrite.TotalMilliseconds} && {!sourcePath.CheckSum.SequenceEqual(targetPath.CheckSum)}"); + + return targetPath == null || (sourcePath.LastWrite.TotalMilliseconds > targetPath.LastWrite.TotalMilliseconds && !sourcePath.CheckSum.SequenceEqual(targetPath.CheckSum)); } diff --git a/Parallel.Core/IO/Syncing/BaseSyncManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs index c9745c4..116060e 100644 --- a/Parallel.Core/IO/Syncing/BaseSyncManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -9,7 +9,7 @@ namespace Parallel.Core.IO.Syncing { /// - /// Represents the base way of backing up files to an associated file system. + /// Represents the base functionality for syncing files to an associated file system. /// public abstract class BaseSyncManager : ISyncManager { @@ -40,19 +40,6 @@ public BaseSyncManager(LocalVaultConfig localVault) } /// - public async Task InitializeAsync() - { - try - { - return true; - } - catch (Exception ex) - { - Log.Error(ex.GetBaseException().ToString()); - return false; - } - } - public async Task ConnectAsync() { string root = PathBuilder.GetRootDirectory(LocalVault); @@ -101,7 +88,8 @@ public async Task ConnectAsync() /// public async Task DisconnectAsync() { - await FileSystem.UploadFilesAsync([new SystemFile(TempConfigFile, PathBuilder.GetConfigurationFile(LocalVault)), new SystemFile(TempDbFile, PathBuilder.GetDatabaseFile(LocalVault))], new ProgressLogger()); + SystemFile[] tempFiles = [new SystemFile(TempConfigFile, PathBuilder.GetConfigurationFile(LocalVault)), new SystemFile(TempDbFile, PathBuilder.GetDatabaseFile(LocalVault))]; + await FileSystem.UploadFilesAsync(tempFiles, new ProgressLogger()); FileSystem.Dispose(); } diff --git a/Parallel.Core/IO/Syncing/BlobSyncManager.cs b/Parallel.Core/IO/Syncing/BlobSyncManager.cs new file mode 100644 index 0000000..7ae3b69 --- /dev/null +++ b/Parallel.Core/IO/Syncing/BlobSyncManager.cs @@ -0,0 +1,43 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Reflection.Metadata; +using Parallel.Core.Diagnostics; +using Parallel.Core.IO.Blobs; +using Parallel.Core.Models; +using Parallel.Core.Settings; + +namespace Parallel.Core.IO.Syncing +{ + /// + /// Represents the way to sync files with content assigned binary objects. + /// + public class BlobSyncManager : BaseSyncManager + { + private readonly BlobStorage _blobStorage; + private string _hashes; + + /// + /// Initializes a new instance of the class. + /// + /// + public BlobSyncManager(LocalVaultConfig localVault) : base(localVault) + { + _blobStorage = new BlobStorage(TempDirectory); + _hashes = Path.Combine(TempDirectory, "Hashes.json"); + } + + /// + public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) + { + IEnumerable hashes = await _blobStorage.ChunkFileAsync(files.First().LocalPath, Path.Combine(TempDirectory, "objects"), new ProgressLogger()); + File.WriteAllText(_hashes, JsonConvert.SerializeObject(hashes, Formatting.Indented)); + } + + /// + public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) + { + IEnumerable? hashes = JsonConvert.DeserializeObject>(await File.ReadAllTextAsync(_hashes)); + await _blobStorage.AssembleFileAsync(hashes, Path.Combine(TempDirectory, "objects"), files.First().LocalPath); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Syncing/DeltaSyncManager.cs b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs index a9155a8..be0b55c 100644 --- a/Parallel.Core/IO/Syncing/DeltaSyncManager.cs +++ b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs @@ -7,7 +7,7 @@ namespace Parallel.Core.IO.Syncing { /// - /// Represents the way to clone files to an associated file system using file deltas. + /// Represents the way to sync files to an associated file system using file deltas. /// public class DeltaSyncManager : BaseSyncManager { diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index c2a135a..5b0eabe 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -1,18 +1,14 @@ // Copyright 2025 Kyle Ebbinga -using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.Diagnostics; -using Parallel.Core.Events; -using Parallel.Core.IO.FileSystem; -using Parallel.Core.IO.Syncing; using Parallel.Core.Models; using Parallel.Core.Settings; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// - /// Represents the way to archive files to an associated file system. + /// Represents the way to sync whole files to an associated file system. /// public class FileSyncManager : BaseSyncManager { @@ -56,10 +52,7 @@ await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options /// public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) { - SystemFile[] restoreFiles = files.Where(f => f.Deleted).ToArray(); - - if (!restoreFiles.Any()) return; - await FileSystem.DownloadFilesAsync(restoreFiles, progress); + await FileSystem.DownloadFilesAsync(files, progress); } } } \ No newline at end of file diff --git a/Parallel.Core/IO/Syncing/SyncManager.cs b/Parallel.Core/IO/Syncing/SyncManager.cs index 49f4b8c..9151632 100644 --- a/Parallel.Core/IO/Syncing/SyncManager.cs +++ b/Parallel.Core/IO/Syncing/SyncManager.cs @@ -1,15 +1,8 @@ // Copyright 2025 Kyle Ebbinga -using Newtonsoft.Json.Linq; using Parallel.Core.Settings; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Parallel.Core.IO.Syncing; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// /// Represents the way manage s. diff --git a/Parallel.Core/Security/HashGenerator.cs b/Parallel.Core/Security/HashGenerator.cs index 71fa07f..6fda9c4 100644 --- a/Parallel.Core/Security/HashGenerator.cs +++ b/Parallel.Core/Security/HashGenerator.cs @@ -71,6 +71,16 @@ public static string CreateSHA1(string value) return Convert.ToHexString(SHA1.HashData(Encoding.ASCII.GetBytes(value))).ToLower(); } + /// + /// Computes a SHA256 hash from bytes. + /// + /// The string to hash. + /// A hash as a string. + public static string CreateSHA256(byte[] value) + { + return Convert.ToHexString(SHA256.HashData(value)).ToLower(); + } + /// /// Computes a SHA256 hash from a string. ///