mirror of
https://github.com/aaru-dps/Aaru.git
synced 2025-12-16 19:24:25 +00:00
547 lines
23 KiB
C#
547 lines
23 KiB
C#
// /***************************************************************************
|
|
// Aaru Data Preservation Suite
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// Filename : Compare.cs
|
|
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
|
//
|
|
// Component : Commands.
|
|
//
|
|
// --[ Description ] ----------------------------------------------------------
|
|
//
|
|
// Implements the 'compare' command.
|
|
//
|
|
// --[ License ] --------------------------------------------------------------
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
//
|
|
// ----------------------------------------------------------------------------
|
|
// Copyright © 2011-2025 Natalia Portillo
|
|
// ****************************************************************************/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using Aaru.CommonTypes;
|
|
using Aaru.CommonTypes.Enums;
|
|
using Aaru.CommonTypes.Interfaces;
|
|
using Aaru.Core;
|
|
using Aaru.Helpers;
|
|
using Aaru.Localization;
|
|
using Aaru.Logging;
|
|
using Sentry;
|
|
using Spectre.Console;
|
|
using Spectre.Console.Cli;
|
|
using ImageInfo = Aaru.CommonTypes.Structs.ImageInfo;
|
|
|
|
namespace Aaru.Commands.Image;
|
|
|
|
sealed class CompareCommand : Command<CompareCommand.Settings>
|
|
{
|
|
const string MODULE_NAME = "Compare command";
|
|
|
|
public override int Execute(CommandContext context, Settings settings)
|
|
{
|
|
MainClass.PrintCopyright();
|
|
|
|
Statistics.AddCommand("compare");
|
|
|
|
AaruLogging.Debug(MODULE_NAME, "--debug={0}", settings.Debug);
|
|
AaruLogging.Debug(MODULE_NAME, "--input1={0}", Markup.Escape(settings.ImagePath1 ?? ""));
|
|
AaruLogging.Debug(MODULE_NAME, "--input2={0}", Markup.Escape(settings.ImagePath2 ?? ""));
|
|
AaruLogging.Debug(MODULE_NAME, "--verbose={0}", settings.Verbose);
|
|
|
|
IFilter inputFilter1 = null;
|
|
IFilter inputFilter2 = null;
|
|
|
|
Core.Spectre.ProgressSingleSpinner(ctx =>
|
|
{
|
|
ctx.AddTask(UI.Identifying_first_file_filter).IsIndeterminate();
|
|
inputFilter1 = PluginRegister.Singleton.GetFilter(settings.ImagePath1);
|
|
});
|
|
|
|
Core.Spectre.ProgressSingleSpinner(ctx =>
|
|
{
|
|
ctx.AddTask(UI.Identifying_second_file_filter).IsIndeterminate();
|
|
inputFilter2 = PluginRegister.Singleton.GetFilter(settings.ImagePath2);
|
|
});
|
|
|
|
if(inputFilter1 == null)
|
|
{
|
|
AaruLogging.Error(UI.Cannot_open_first_input_file);
|
|
|
|
return (int)ErrorNumber.CannotOpenFile;
|
|
}
|
|
|
|
if(inputFilter2 == null)
|
|
{
|
|
AaruLogging.Error(UI.Cannot_open_second_input_file);
|
|
|
|
return (int)ErrorNumber.CannotOpenFile;
|
|
}
|
|
|
|
IBaseImage input1Format = null;
|
|
IBaseImage input2Format = null;
|
|
|
|
Core.Spectre.ProgressSingleSpinner(ctx =>
|
|
{
|
|
ctx.AddTask(UI.Identifying_first_image_format).IsIndeterminate();
|
|
input1Format = ImageFormat.Detect(inputFilter1);
|
|
});
|
|
|
|
Core.Spectre.ProgressSingleSpinner(ctx =>
|
|
{
|
|
ctx.AddTask(UI.Identifying_second_image_format).IsIndeterminate();
|
|
input2Format = ImageFormat.Detect(inputFilter2);
|
|
});
|
|
|
|
if(input1Format == null)
|
|
{
|
|
AaruLogging.Error(UI.First_input_file_format_not_identified);
|
|
|
|
return (int)ErrorNumber.UnrecognizedFormat;
|
|
}
|
|
|
|
if(settings.Verbose)
|
|
AaruLogging.Verbose(UI.First_input_file_format_identified_by_0_1, input1Format.Name, input1Format.Id);
|
|
else
|
|
AaruLogging.WriteLine(UI.First_input_file_format_identified_by_0, input1Format.Name);
|
|
|
|
if(input2Format == null)
|
|
{
|
|
AaruLogging.Error(UI.Second_input_file_format_not_identified);
|
|
|
|
return (int)ErrorNumber.UnrecognizedFormat;
|
|
}
|
|
|
|
if(settings.Verbose)
|
|
AaruLogging.Verbose(UI.Second_input_file_format_identified_by_0_1, input2Format.Name, input2Format.Id);
|
|
else
|
|
AaruLogging.WriteLine(UI.Second_input_file_format_identified_by_0, input2Format.Name);
|
|
|
|
ErrorNumber opened1 = ErrorNumber.NoData;
|
|
ErrorNumber opened2 = ErrorNumber.NoData;
|
|
|
|
Core.Spectre.ProgressSingleSpinner(ctx =>
|
|
{
|
|
ctx.AddTask(UI.Opening_first_image_file).IsIndeterminate();
|
|
opened1 = input1Format.Open(inputFilter1);
|
|
});
|
|
|
|
if(opened1 != ErrorNumber.NoError)
|
|
{
|
|
AaruLogging.WriteLine(UI.Unable_to_open_first_image_format);
|
|
AaruLogging.WriteLine(Localization.Core.Error_0, opened1);
|
|
|
|
return (int)opened1;
|
|
}
|
|
|
|
Core.Spectre.ProgressSingleSpinner(ctx =>
|
|
{
|
|
ctx.AddTask(UI.Opening_second_image_file).IsIndeterminate();
|
|
opened2 = input2Format.Open(inputFilter2);
|
|
});
|
|
|
|
if(opened2 != ErrorNumber.NoError)
|
|
{
|
|
AaruLogging.WriteLine(UI.Unable_to_open_second_image_format);
|
|
AaruLogging.WriteLine(Localization.Core.Error_0, opened2);
|
|
|
|
return (int)opened2;
|
|
}
|
|
|
|
Statistics.AddMediaFormat(input1Format.Format);
|
|
Statistics.AddMediaFormat(input2Format.Format);
|
|
Statistics.AddMedia(input1Format.Info.MediaType, false);
|
|
Statistics.AddMedia(input2Format.Info.MediaType, false);
|
|
Statistics.AddFilter(inputFilter1.Name);
|
|
Statistics.AddFilter(inputFilter2.Name);
|
|
|
|
var sb = new StringBuilder();
|
|
Table table = new();
|
|
table.AddColumn("");
|
|
table.AddColumn(new TableColumn(new Markup(UI.Title_First_Media_image).Centered()));
|
|
table.AddColumn(new TableColumn(new Markup(UI.Title_Second_Media_image).Centered()));
|
|
table.Columns[0].RightAligned();
|
|
table.Border(TableBorder.Rounded);
|
|
table.BorderColor(Color.Yellow);
|
|
|
|
if(settings.Verbose)
|
|
{
|
|
table.AddRow(UI.Title_File,
|
|
$"[navy]{Markup.Escape(settings.ImagePath1)}[/]",
|
|
$"[navy]{Markup.Escape(settings.ImagePath2)}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_image_format,
|
|
$"[fuchsia]{input1Format.Name}[/]",
|
|
$"[fuchsia]{input2Format.Name}[/]");
|
|
}
|
|
else
|
|
{
|
|
sb
|
|
.AppendFormat($"[bold][green]{UI.Title_First_Media_image}:[/] [italic]{Markup.Escape(settings.ImagePath1)}[/][/]")
|
|
.AppendLine();
|
|
|
|
sb
|
|
.AppendFormat($"[bold][red]{UI.Title_Second_Media_image}:[/] [italic]{Markup.Escape(settings.ImagePath2)}[/][/]")
|
|
.AppendLine();
|
|
}
|
|
|
|
bool imagesDiffer = false;
|
|
ErrorNumber errno;
|
|
|
|
ImageInfo image1Info = input1Format.Info;
|
|
ImageInfo image2Info = input2Format.Info;
|
|
Dictionary<MediaTagType, byte[]> image1DiskTags = [];
|
|
Dictionary<MediaTagType, byte[]> image2DiskTags = [];
|
|
var input1MediaImage = input1Format as IMediaImage;
|
|
var input2MediaImage = input2Format as IMediaImage;
|
|
|
|
if(input1MediaImage != null)
|
|
{
|
|
foreach(MediaTagType diskTag in Enum.GetValues(typeof(MediaTagType)))
|
|
{
|
|
errno = input1MediaImage.ReadMediaTag(diskTag, out byte[] tempArray);
|
|
|
|
if(errno == ErrorNumber.NoError) image1DiskTags.Add(diskTag, tempArray);
|
|
}
|
|
}
|
|
|
|
if(input2MediaImage != null)
|
|
{
|
|
foreach(MediaTagType diskTag in Enum.GetValues(typeof(MediaTagType)))
|
|
{
|
|
errno = input2MediaImage.ReadMediaTag(diskTag, out byte[] tempArray);
|
|
|
|
if(errno == ErrorNumber.NoError) image2DiskTags.Add(diskTag, tempArray);
|
|
}
|
|
}
|
|
|
|
if(settings.Verbose)
|
|
{
|
|
table.AddRow(UI.Has_partitions_Question,
|
|
image1Info.HasPartitions
|
|
? $"[green]{image1Info.HasPartitions}[/]"
|
|
: $"[red]{image1Info.HasPartitions}[/]",
|
|
image2Info.HasPartitions
|
|
? $"[green]{image2Info.HasPartitions}[/]"
|
|
: $"[red]{image2Info.HasPartitions}[/]");
|
|
|
|
table.AddRow(UI.Has_sessions_Question,
|
|
image1Info.HasSessions
|
|
? $"[green]{image1Info.HasSessions}[/]"
|
|
: $"[red]{image1Info.HasSessions}[/]",
|
|
image2Info.HasSessions
|
|
? $"[green]{image2Info.HasSessions}[/]"
|
|
: $"[red]{image2Info.HasSessions}[/]");
|
|
|
|
table.AddRow(UI.Title_Image_size, $"[teal]{image1Info.ImageSize}[/]", $"[teal]{image2Info.ImageSize}[/]");
|
|
|
|
table.AddRow(UI.Title_Sectors, $"[lime]{image1Info.Sectors}[/]", $"[lime]{image2Info.Sectors}[/]");
|
|
|
|
table.AddRow(UI.Title_Sector_size,
|
|
$"[aqua]{image1Info.SectorSize}[/]",
|
|
$"[aqua]{image2Info.SectorSize}[/]");
|
|
|
|
table.AddRow(UI.Title_Creation_time,
|
|
$"[yellow3]{image1Info.CreationTime.ToString(CultureInfo.CurrentCulture)}[/]",
|
|
$"[yellow3]{image2Info.CreationTime.ToString(CultureInfo.CurrentCulture)}[/]");
|
|
|
|
table.AddRow(UI.Title_Last_modification_time,
|
|
$"[yellow3]{image1Info.LastModificationTime.ToString(CultureInfo.CurrentCulture)}[/]",
|
|
$"[yellow3]{image2Info.LastModificationTime.ToString(CultureInfo.CurrentCulture)}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_type,
|
|
$"[orange3]{image1Info.MediaType}[/]",
|
|
$"[orange3]{image2Info.MediaType}[/]");
|
|
|
|
table.AddRow(UI.Title_Image_version,
|
|
$"[rosybrown]{image1Info.Version ?? ""}[/]",
|
|
$"[rosybrown]{image2Info.Version ?? ""}[/]");
|
|
|
|
table.AddRow(UI.Title_Image_application,
|
|
$"[fuchsia]{image1Info.Application ?? ""}[/]",
|
|
$"[fuchsia]{image2Info.Application ?? ""}[/]");
|
|
|
|
table.AddRow(UI.Title_Image_application_version,
|
|
$"[rosybrown]{image1Info.ApplicationVersion ?? ""}[/]",
|
|
$"[rosybrown]{image2Info.ApplicationVersion ?? ""}[/]");
|
|
|
|
table.AddRow(UI.Title_Image_creator,
|
|
$"[blue]{Markup.Escape(image1Info.Creator ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.Creator ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Image_name,
|
|
$"[blue]{Markup.Escape(image1Info.MediaTitle ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.MediaTitle ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Image_comments,
|
|
$"[blue]{Markup.Escape(image1Info.Comments ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.Comments ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_manufacturer,
|
|
$"[blue]{Markup.Escape(image1Info.MediaManufacturer ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.MediaManufacturer ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_model,
|
|
$"[blue]{Markup.Escape(image1Info.MediaModel ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.MediaModel ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_serial_number,
|
|
$"[blue]{Markup.Escape(image1Info.MediaSerialNumber ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.MediaSerialNumber ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_barcode,
|
|
$"[blue]{Markup.Escape(image1Info.MediaBarcode ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.MediaBarcode ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_part_number,
|
|
$"[blue]{Markup.Escape(image1Info.MediaPartNumber ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.MediaPartNumber ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Media_sequence,
|
|
$"[teal]{image1Info.MediaSequence}[/]",
|
|
$"[teal]{image2Info.MediaSequence}[/]");
|
|
|
|
table.AddRow(UI.Title_Last_media_on_sequence,
|
|
$"[teal]{image1Info.LastMediaSequence}[/]",
|
|
$"[teal]{image2Info.LastMediaSequence}[/]");
|
|
|
|
table.AddRow(UI.Title_Drive_manufacturer,
|
|
$"[blue]{Markup.Escape(image1Info.DriveManufacturer ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.DriveManufacturer ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Drive_firmware_revision,
|
|
$"[blue]{Markup.Escape(image1Info.DriveFirmwareRevision ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.DriveFirmwareRevision ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Drive_model,
|
|
$"[blue]{Markup.Escape(image1Info.DriveModel ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.DriveModel ?? "")}[/]");
|
|
|
|
table.AddRow(UI.Title_Drive_serial_number,
|
|
$"[blue]{Markup.Escape(image1Info.DriveSerialNumber ?? "")}[/]",
|
|
$"[blue]{Markup.Escape(image2Info.DriveSerialNumber ?? "")}[/]");
|
|
|
|
foreach(MediaTagType diskTag in
|
|
(Enum.GetValues(typeof(MediaTagType)) as MediaTagType[]).OrderBy(e => e.ToString()))
|
|
{
|
|
table.AddRow(string.Format(UI.Has_tag_0_Question, diskTag),
|
|
image1DiskTags.ContainsKey(diskTag)
|
|
? $"[green]{image1DiskTags.ContainsKey(diskTag)}[/]"
|
|
: $"[red]{image1DiskTags.ContainsKey(diskTag)}[/]",
|
|
image2DiskTags.ContainsKey(diskTag)
|
|
? $"[green]{image2DiskTags.ContainsKey(diskTag)}[/]"
|
|
: $"[red]{image2DiskTags.ContainsKey(diskTag)}[/]");
|
|
}
|
|
}
|
|
|
|
ulong leastSectors = 0;
|
|
|
|
Core.Spectre.ProgressSingleSpinner(ctx =>
|
|
{
|
|
ctx.AddTask(UI.Comparing_media_image_characteristics).IsIndeterminate();
|
|
|
|
if(image1Info.HasPartitions != image2Info.HasPartitions)
|
|
{
|
|
imagesDiffer = true;
|
|
|
|
if(!settings.Verbose) sb.AppendLine(UI.Image_partitioned_status_differ);
|
|
}
|
|
|
|
if(image1Info.HasSessions != image2Info.HasSessions)
|
|
{
|
|
imagesDiffer = true;
|
|
|
|
if(!settings.Verbose) sb.AppendLine(UI.Image_session_status_differ);
|
|
}
|
|
|
|
if(image1Info.Sectors != image2Info.Sectors)
|
|
{
|
|
imagesDiffer = true;
|
|
|
|
if(!settings.Verbose) sb.AppendLine(UI.Image_sectors_differ);
|
|
}
|
|
|
|
if(image1Info.SectorSize != image2Info.SectorSize)
|
|
{
|
|
imagesDiffer = true;
|
|
|
|
if(!settings.Verbose) sb.AppendLine(UI.Image_sector_size_differ);
|
|
}
|
|
|
|
if(image1Info.MediaType != image2Info.MediaType)
|
|
{
|
|
imagesDiffer = true;
|
|
|
|
if(!settings.Verbose) sb.AppendLine(UI.Media_type_differs);
|
|
}
|
|
|
|
if(image1Info.Sectors < image2Info.Sectors)
|
|
{
|
|
imagesDiffer = true;
|
|
leastSectors = image1Info.Sectors;
|
|
|
|
if(!settings.Verbose) sb.AppendLine(UI.Second_image_has_more_sectors);
|
|
}
|
|
else if(image1Info.Sectors > image2Info.Sectors)
|
|
{
|
|
imagesDiffer = true;
|
|
leastSectors = image2Info.Sectors;
|
|
|
|
if(!settings.Verbose) sb.AppendLine(UI.First_image_has_more_sectors);
|
|
}
|
|
else
|
|
leastSectors = image1Info.Sectors;
|
|
});
|
|
|
|
var input1ByteAddressable = input1Format as IByteAddressableImage;
|
|
var input2ByteAddressable = input2Format as IByteAddressableImage;
|
|
|
|
if(input1ByteAddressable is null && input2ByteAddressable is not null) imagesDiffer = true;
|
|
|
|
if(input1ByteAddressable is not null && input2ByteAddressable is null) imagesDiffer = true;
|
|
|
|
if(input1MediaImage is null && input2MediaImage is not null) imagesDiffer = true;
|
|
|
|
if(input1MediaImage is not null && input2MediaImage is null) imagesDiffer = true;
|
|
|
|
if(input1MediaImage is not null && input2MediaImage is not null)
|
|
{
|
|
AnsiConsole.Progress()
|
|
.AutoClear(true)
|
|
.HideCompleted(true)
|
|
.Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn())
|
|
.Start(ctx =>
|
|
{
|
|
ProgressTask task = ctx.AddTask(UI.Comparing_sectors);
|
|
task.MaxValue = leastSectors;
|
|
|
|
for(ulong sector = 0; sector < leastSectors; sector++)
|
|
{
|
|
task.Value = sector;
|
|
task.Description = string.Format(UI.Comparing_sector_0_of_1, sector + 1, leastSectors);
|
|
|
|
try
|
|
{
|
|
errno = input1MediaImage.ReadSector(sector, out byte[] image1Sector);
|
|
|
|
if(errno != ErrorNumber.NoError)
|
|
{
|
|
AaruLogging.Error(string.Format(UI.Error_0_reading_sector_1_from_first_image,
|
|
errno,
|
|
sector));
|
|
}
|
|
|
|
errno = input2MediaImage.ReadSector(sector, out byte[] image2Sector);
|
|
|
|
if(errno != ErrorNumber.NoError)
|
|
{
|
|
AaruLogging.Error(string.Format(UI.Error_0_reading_sector_1_from_second_image,
|
|
errno,
|
|
sector));
|
|
}
|
|
|
|
ArrayHelpers.CompareBytes(out bool different,
|
|
out bool sameSize,
|
|
image1Sector,
|
|
image2Sector);
|
|
|
|
if(different)
|
|
imagesDiffer = true;
|
|
|
|
// sb.AppendFormat("Sector {0} is different", sector).AppendLine();
|
|
else if(!sameSize) imagesDiffer = true;
|
|
/* sb.
|
|
AppendFormat("Sector {0} has different sizes ({1} bytes in image 1, {2} in image 2) but are otherwise identical",
|
|
sector, image1Sector.LongLength, image2Sector.LongLength).AppendLine();*/
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
SentrySdk.CaptureException(ex);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if(input1ByteAddressable is not null && input2ByteAddressable is not null)
|
|
{
|
|
AnsiConsole.Progress()
|
|
.AutoClear(true)
|
|
.HideCompleted(true)
|
|
.Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn())
|
|
.Start(ctx =>
|
|
{
|
|
ProgressTask task = ctx.AddTask(UI.Comparing_images);
|
|
task.IsIndeterminate = true;
|
|
|
|
byte[] data1 = new byte[input1ByteAddressable.Info.Sectors];
|
|
byte[] data2 = new byte[input2ByteAddressable.Info.Sectors];
|
|
byte[] tmp;
|
|
|
|
input1ByteAddressable.ReadBytes(data1, 0, data1.Length, out int bytesRead);
|
|
|
|
if(bytesRead != data1.Length)
|
|
{
|
|
tmp = new byte[bytesRead];
|
|
Array.Copy(data1, 0, tmp, 0, bytesRead);
|
|
data1 = tmp;
|
|
}
|
|
|
|
input2ByteAddressable.ReadBytes(data2, 0, data2.Length, out bytesRead);
|
|
|
|
if(bytesRead != data2.Length)
|
|
{
|
|
tmp = new byte[bytesRead];
|
|
Array.Copy(data2, 0, tmp, 0, bytesRead);
|
|
data2 = tmp;
|
|
}
|
|
|
|
ArrayHelpers.CompareBytes(out bool different, out bool sameSize, data1, data2);
|
|
|
|
if(different)
|
|
imagesDiffer = true;
|
|
else if(!sameSize) imagesDiffer = true;
|
|
});
|
|
}
|
|
|
|
AaruLogging.WriteLine();
|
|
|
|
sb.AppendLine(imagesDiffer ? UI.Images_differ : UI.Images_do_not_differ);
|
|
|
|
if(settings.Verbose)
|
|
AnsiConsole.Write(table);
|
|
else
|
|
AaruLogging.WriteLine(sb.ToString());
|
|
|
|
return (int)ErrorNumber.NoError;
|
|
}
|
|
|
|
#region Nested type: Settings
|
|
|
|
public class Settings : ImageFamily
|
|
{
|
|
[Description("First media image path")]
|
|
[CommandArgument(0, "<image-path1>")]
|
|
public string ImagePath1 { get; init; }
|
|
[Description("Second media image path")]
|
|
[CommandArgument(1, "<image-path1>")]
|
|
public string ImagePath2 { get; init; }
|
|
}
|
|
|
|
#endregion
|
|
} |