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 1/4] 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 2/4] 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 3/4] 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 4/4] 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