From dcfb93b26d9b26850810fbf8e2b9482ef6252a34 Mon Sep 17 00:00:00 2001 From: "Leilei Zhang (from Dev Box)" Date: Mon, 15 Dec 2025 09:53:08 +0800 Subject: [PATCH] Add standard CLI support for Image Resizer --- .pipelines/ESRPSigning_core.json | 2 + PowerToys.slnx | 4 + doc/devdocs/cli-conventions.md | 182 +++++++++++++ .../ImageResizerCLI/ImageResizerCLI.csproj | 28 ++ .../imageresizer/ImageResizerCLI/Program.cs | 34 +++ .../Cli/Commands/ImageResizerRootCommand.cs | 86 ++++++ .../ui/Cli/ImageResizerCliExecutor.cs | 188 +++++++++++++ .../ui/Cli/Options/DestinationOption.cs | 18 ++ .../ui/Cli/Options/FileNameOption.cs | 18 ++ .../ui/Cli/Options/FilesArgument.cs | 17 ++ .../imageresizer/ui/Cli/Options/FitOption.cs | 18 ++ .../ui/Cli/Options/HeightOption.cs | 18 ++ .../imageresizer/ui/Cli/Options/HelpOption.cs | 18 ++ .../ui/Cli/Options/IgnoreOrientationOption.cs | 18 ++ .../ui/Cli/Options/KeepDateModifiedOption.cs | 18 ++ .../ui/Cli/Options/QualityOption.cs | 26 ++ .../ui/Cli/Options/RemoveMetadataOption.cs | 18 ++ .../ui/Cli/Options/ReplaceOption.cs | 18 ++ .../ui/Cli/Options/ShowConfigOption.cs | 18 ++ .../ui/Cli/Options/ShrinkOnlyOption.cs | 18 ++ .../imageresizer/ui/Cli/Options/SizeOption.cs | 26 ++ .../imageresizer/ui/Cli/Options/UnitOption.cs | 18 ++ .../ui/Cli/Options/WidthOption.cs | 18 ++ .../imageresizer/ui/ImageResizerUI.csproj | 1 + .../imageresizer/ui/Models/CliOptions.cs | 256 ++++++++++++++++++ .../imageresizer/ui/Models/ResizeBatch.cs | 62 +++-- .../imageresizer/ui/Properties/Settings.cs | 2 + 27 files changed, 1122 insertions(+), 26 deletions(-) create mode 100644 doc/devdocs/cli-conventions.md create mode 100644 src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj create mode 100644 src/modules/imageresizer/ImageResizerCLI/Program.cs create mode 100644 src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs create mode 100644 src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/FitOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/HeightOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/HelpOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/QualityOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/SizeOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/UnitOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/WidthOption.cs create mode 100644 src/modules/imageresizer/ui/Models/CliOptions.cs diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index e3ebffc20c..050fe426dc 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -131,6 +131,8 @@ "PowerToys.ImageResizer.exe", "PowerToys.ImageResizer.dll", + "WinUI3Apps\\PowerToys.ImageResizerCLI.exe", + "WinUI3Apps\\PowerToys.ImageResizerCLI.dll", "PowerToys.ImageResizerExt.dll", "PowerToys.ImageResizerContextMenu.dll", "ImageResizerContextMenuPackage.msix", diff --git a/PowerToys.slnx b/PowerToys.slnx index 1884b2d58b..2aa1ec2b9f 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -442,6 +442,10 @@ + + + + diff --git a/doc/devdocs/cli-conventions.md b/doc/devdocs/cli-conventions.md new file mode 100644 index 0000000000..969f2a32bf --- /dev/null +++ b/doc/devdocs/cli-conventions.md @@ -0,0 +1,182 @@ +# CLI Conventions + +This document describes the conventions for implementing command-line interfaces (CLI) in PowerToys modules. + +## Library + +Use the **System.CommandLine** library for CLI argument parsing. This is already defined in `Directory.Packages.props`: + +```xml + +``` + +Add the reference to your project: + +```xml + +``` + +## Option Naming + +### Aliases Pattern + +Define option aliases as static readonly arrays following this pattern: + +```csharp +private static readonly string[] AliasesSilent = ["--silent", "-s"]; +private static readonly string[] AliasesWidth = ["--width", "-w"]; +private static readonly string[] AliasesHelp = ["--help"]; +``` + +When creating dedicated option types (for example, `sealed class FooOption : Option`), +avoid naming a member `Aliases` (it hides `Option.Aliases`). Prefer `_aliases`. + +### Naming Rules + +1. **Long form**: Use `--kebab-case` (e.g., `--shrink-only`, `--keep-date-modified`) +2. **Short form**: Use single `-x` character (e.g., `-s`, `-w`, `-h`) +3. **No short form** for less common options (e.g., `--shrink-only`, `--ignore-orientation`) + +## Option Definition + +Create options using `Option` with descriptive help text: + +```csharp +var silentOption = new Option(AliasesSilent, "Run in silent mode without UI"); +var widthOption = new Option(AliasesWidth, "Set width in pixels"); +var unitOption = new Option(AliasesUnit, "Set unit (Pixel, Percent, Inch, Centimeter)"); +``` + +## Validation + +Add validators for options that require range or format checking: + +```csharp +qualityOption.AddValidator(result => +{ + var value = result.GetValueOrDefault(); + if (value.HasValue && (value.Value < 1 || value.Value > 100)) + { + result.ErrorMessage = "JPEG quality must be between 1 and 100."; + } +}); +``` + +## RootCommand Setup + +Create a `RootCommand` with a description and add all options: + +```csharp +public static RootCommand CreateRootCommand() +{ + var rootCommand = new RootCommand("PowerToys Module Name - Brief description") + { + silentOption, + widthOption, + heightOption, + // ... other options + filesArgument, + }; + + return rootCommand; +} +``` + +## Parsing + +Parse arguments and extract values: + +```csharp +public static CliOptions Parse(string[] args) +{ + var options = new CliOptions(); + var rootCommand = CreateRootCommand(); + // Note: with the pinned System.CommandLine version in this repo, + // RootCommand.Parse(args) may not be available. Use Parser instead. + var parseResult = new Parser(rootCommand).Parse(args); + + // Extract values + options.Silent = parseResult.GetValueForOption(silentOption); + options.Width = parseResult.GetValueForOption(widthOption); + + return options; +} +``` + +### Parse/Validation Errors + +If parsing or validation fails, return a non-zero exit code (and typically print +the errors plus usage): + +```csharp +if (parseResult.Errors.Count > 0) +{ + foreach (var error in parseResult.Errors) + { + Console.Error.WriteLine(error.Message); + } + + PrintUsage(); + return 1; +} +``` + +## Examples + +### Awake Module + +Reference implementation: `src/modules/Awake/Awake/Program.cs` + +```csharp +private static readonly string[] _aliasesConfigOption = ["--use-pt-config", "-c"]; +private static readonly string[] _aliasesDisplayOption = ["--display-on", "-d"]; +private static readonly string[] _aliasesTimeOption = ["--time-limit", "-t"]; +private static readonly string[] _aliasesPidOption = ["--pid", "-p"]; +private static readonly string[] _aliasesExpireAtOption = ["--expire-at", "-e"]; +``` + +### ImageResizer Module + +Reference implementation: + +- `src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs` +- `src/modules/imageresizer/ui/Models/CliOptions.cs` +- `src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs` + +```csharp +public sealed class DestinationOption : Option +{ + private static readonly string[] _aliases = ["--destination", "-d"]; + + public DestinationOption() + : base(_aliases, "Set destination directory") + { + } +} +``` + +## Help Output + +Provide a `PrintUsage()` method for custom help formatting if needed: + +```csharp +public static void PrintUsage() +{ + Console.WriteLine("ModuleName - PowerToys Module CLI"); + Console.WriteLine(); + Console.WriteLine("Usage: PowerToys.ModuleName.exe [options] [arguments...]"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" --option, -o Description of the option"); + // ... +} +``` + +## Best Practices + +1. **Consistency**: Follow existing patterns in the codebase (Awake, ImageResizer) +2. **Documentation**: Always provide help text for each option +3. **Validation**: Validate input values and provide clear error messages +4. **Nullable types**: Use `Option` for optional parameters +5. **Boolean flags**: Use `Option` for flags that don't require values +6. **Enum support**: System.CommandLine automatically handles enum parsing diff --git a/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj b/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj new file mode 100644 index 0000000000..41de902d6e --- /dev/null +++ b/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj @@ -0,0 +1,28 @@ + + + + + + + + PowerToys.ImageResizerCLI + PowerToys Image Resizer Command Line Interface + PowerToys Image Resizer CLI + Exe + x64;ARM64 + false + false + ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\ + PowerToys.ImageResizerCLI + $(NoWarn);SA1500;SA1402;CA1852 + + + + + + + + + + + diff --git a/src/modules/imageresizer/ImageResizerCLI/Program.cs b/src/modules/imageresizer/ImageResizerCLI/Program.cs new file mode 100644 index 0000000000..0754f68bef --- /dev/null +++ b/src/modules/imageresizer/ImageResizerCLI/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Text; + +using ImageResizer.Cli; +using ManagedCommon; + +namespace ImageResizerCLI; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + string appLanguage = LanguageHelper.LoadLanguage(); + if (!string.IsNullOrEmpty(appLanguage)) + { + System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(appLanguage); + } + } + catch (CultureNotFoundException) + { + // Ignore invalid culture and fall back to default. + } + + Console.InputEncoding = Encoding.Unicode; + return ImageResizerCliExecutor.RunStandalone(args); + } +} diff --git a/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs b/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs new file mode 100644 index 0000000000..5a463394df --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +using ImageResizer.Cli.Options; + +namespace ImageResizer.Cli.Commands +{ + /// + /// Root command for the ImageResizer CLI. + /// + public sealed class ImageResizerRootCommand : RootCommand + { + public ImageResizerRootCommand() + : base("PowerToys Image Resizer - Resize images from command line") + { + HelpOption = new HelpOption(); + ShowConfigOption = new ShowConfigOption(); + DestinationOption = new DestinationOption(); + WidthOption = new WidthOption(); + HeightOption = new HeightOption(); + UnitOption = new UnitOption(); + FitOption = new FitOption(); + SizeOption = new SizeOption(); + ShrinkOnlyOption = new ShrinkOnlyOption(); + ReplaceOption = new ReplaceOption(); + IgnoreOrientationOption = new IgnoreOrientationOption(); + RemoveMetadataOption = new RemoveMetadataOption(); + QualityOption = new QualityOption(); + KeepDateModifiedOption = new KeepDateModifiedOption(); + FileNameOption = new FileNameOption(); + FilesArgument = new FilesArgument(); + + AddOption(HelpOption); + AddOption(ShowConfigOption); + AddOption(DestinationOption); + AddOption(WidthOption); + AddOption(HeightOption); + AddOption(UnitOption); + AddOption(FitOption); + AddOption(SizeOption); + AddOption(ShrinkOnlyOption); + AddOption(ReplaceOption); + AddOption(IgnoreOrientationOption); + AddOption(RemoveMetadataOption); + AddOption(QualityOption); + AddOption(KeepDateModifiedOption); + AddOption(FileNameOption); + AddArgument(FilesArgument); + } + + public HelpOption HelpOption { get; } + + public ShowConfigOption ShowConfigOption { get; } + + public DestinationOption DestinationOption { get; } + + public WidthOption WidthOption { get; } + + public HeightOption HeightOption { get; } + + public UnitOption UnitOption { get; } + + public FitOption FitOption { get; } + + public SizeOption SizeOption { get; } + + public ShrinkOnlyOption ShrinkOnlyOption { get; } + + public ReplaceOption ReplaceOption { get; } + + public IgnoreOrientationOption IgnoreOrientationOption { get; } + + public RemoveMetadataOption RemoveMetadataOption { get; } + + public QualityOption QualityOption { get; } + + public KeepDateModifiedOption KeepDateModifiedOption { get; } + + public FileNameOption FileNameOption { get; } + + public FilesArgument FilesArgument { get; } + } +} diff --git a/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs new file mode 100644 index 0000000000..999c831a27 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Threading; + +using ImageResizer.Models; +using ImageResizer.Properties; + +namespace ImageResizer.Cli +{ + /// + /// Centralizes the Image Resizer CLI execution logic for the dedicated CLI host. + /// + public static class ImageResizerCliExecutor + { + /// + /// Entry point used by the dedicated CLI host. + /// + /// Command-line arguments. + /// Exit code. + public static int RunStandalone(string[] args) + { + var cliOptions = CliOptions.Parse(args); + + if (cliOptions.ParseErrors.Count > 0) + { + foreach (var error in cliOptions.ParseErrors) + { + Console.Error.WriteLine(error); + } + + CliOptions.PrintUsage(); + return 1; + } + + if (cliOptions.ShowHelp) + { + CliOptions.PrintUsage(); + return 0; + } + + if (cliOptions.ShowConfig) + { + CliOptions.PrintConfig(Settings.Default); + return 0; + } + + if (cliOptions.Files.Count == 0 && string.IsNullOrEmpty(cliOptions.PipeName)) + { + CliOptions.PrintUsage(); + return 1; + } + + return RunSilentMode(cliOptions); + } + + private static int RunSilentMode(CliOptions cliOptions) + { + var batch = ResizeBatch.FromCliOptions(Console.In, cliOptions); + var settings = Settings.Default; + ApplyCliOptionsToSettings(cliOptions, settings); + + Console.WriteLine($"Processing {batch.Files.Count} file(s)..."); + + var errors = batch.Process( + (completed, total) => + { + var progress = (int)((completed / total) * 100); + Console.Write($"\rProgress: {progress}% ({completed}/{(int)total})"); + }, + settings, + CancellationToken.None); + + Console.WriteLine(); + + var errorList = errors.ToList(); + if (errorList.Count > 0) + { + Console.Error.WriteLine($"Completed with {errorList.Count} error(s):"); + foreach (var error in errorList) + { + Console.Error.WriteLine($" {error.File}: {error.Error}"); + } + + return 1; + } + + Console.WriteLine("All files processed successfully."); + return 0; + } + + /// + /// Applies CLI options to the settings, overriding default values. + /// + /// The CLI options to apply. + /// The settings to modify. + private static void ApplyCliOptionsToSettings(CliOptions cliOptions, Settings settings) + { + // If custom width/height specified, use custom size + if (cliOptions.Width.HasValue || cliOptions.Height.HasValue) + { + if (cliOptions.Width.HasValue) + { + settings.CustomSize.Width = cliOptions.Width.Value; + } + else + { + // If only height specified, set width to 0 for auto-calculation in Fit mode + settings.CustomSize.Width = 0; + } + + if (cliOptions.Height.HasValue) + { + settings.CustomSize.Height = cliOptions.Height.Value; + } + else + { + // If only width specified, set height to 0 for auto-calculation in Fit mode + settings.CustomSize.Height = 0; + } + + if (cliOptions.Unit.HasValue) + { + settings.CustomSize.Unit = cliOptions.Unit.Value; + } + + if (cliOptions.Fit.HasValue) + { + settings.CustomSize.Fit = cliOptions.Fit.Value; + } + + // Select custom size (index = Sizes.Count) + settings.SelectedSizeIndex = settings.Sizes.Count; + } + else if (cliOptions.SizeIndex.HasValue) + { + // Use preset size by index + if (cliOptions.SizeIndex.Value >= 0 && cliOptions.SizeIndex.Value < settings.Sizes.Count) + { + settings.SelectedSizeIndex = cliOptions.SizeIndex.Value; + } + else + { + Console.Error.WriteLine($"Warning: Invalid size index {cliOptions.SizeIndex.Value}. Using default."); + } + } + + // Apply other options + if (cliOptions.ShrinkOnly.HasValue) + { + settings.ShrinkOnly = cliOptions.ShrinkOnly.Value; + } + + if (cliOptions.Replace.HasValue) + { + settings.Replace = cliOptions.Replace.Value; + } + + if (cliOptions.IgnoreOrientation.HasValue) + { + settings.IgnoreOrientation = cliOptions.IgnoreOrientation.Value; + } + + if (cliOptions.RemoveMetadata.HasValue) + { + settings.RemoveMetadata = cliOptions.RemoveMetadata.Value; + } + + if (cliOptions.JpegQualityLevel.HasValue) + { + settings.JpegQualityLevel = cliOptions.JpegQualityLevel.Value; + } + + if (cliOptions.KeepDateModified.HasValue) + { + settings.KeepDateModified = cliOptions.KeepDateModified.Value; + } + + if (!string.IsNullOrEmpty(cliOptions.FileName)) + { + settings.FileName = cliOptions.FileName; + } + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs b/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs new file mode 100644 index 0000000000..85b122b894 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class DestinationOption : Option + { + private static readonly string[] _aliases = ["--destination", "-d"]; + + public DestinationOption() + : base(_aliases, "Set destination directory") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs b/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs new file mode 100644 index 0000000000..ee3e7db8f3 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class FileNameOption : Option + { + private static readonly string[] _aliases = ["--filename", "-n"]; + + public FileNameOption() + : base(_aliases, "Set output filename format (%1=original name, %2=size name)") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs b/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs new file mode 100644 index 0000000000..f8b4ca5c66 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class FilesArgument : Argument + { + public FilesArgument() + : base("files", "Image files to resize") + { + Arity = ArgumentArity.ZeroOrMore; + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/FitOption.cs b/src/modules/imageresizer/ui/Cli/Options/FitOption.cs new file mode 100644 index 0000000000..c0fe11fd47 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/FitOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class FitOption : Option + { + private static readonly string[] _aliases = ["--fit", "-f"]; + + public FitOption() + : base(_aliases, "Set fit mode (Fill, Fit, Stretch)") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs b/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs new file mode 100644 index 0000000000..0e95129e25 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class HeightOption : Option + { + private static readonly string[] _aliases = ["--height", "-h"]; + + public HeightOption() + : base(_aliases, "Set height") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs b/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs new file mode 100644 index 0000000000..fcb472c652 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class HelpOption : Option + { + private static readonly string[] _aliases = ["--help", "-?", "/?"]; + + public HelpOption() + : base(_aliases, "Show help information") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs b/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs new file mode 100644 index 0000000000..d470714b73 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class IgnoreOrientationOption : Option + { + private static readonly string[] _aliases = ["--ignore-orientation"]; + + public IgnoreOrientationOption() + : base(_aliases, "Ignore image orientation") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs b/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs new file mode 100644 index 0000000000..9967d347ef --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class KeepDateModifiedOption : Option + { + private static readonly string[] _aliases = ["--keep-date-modified"]; + + public KeepDateModifiedOption() + : base(_aliases, "Keep original date modified") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs b/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs new file mode 100644 index 0000000000..babc597038 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class QualityOption : Option + { + private static readonly string[] _aliases = ["--quality", "-q"]; + + public QualityOption() + : base(_aliases, "Set JPEG quality level (1-100)") + { + AddValidator(result => + { + var value = result.GetValueOrDefault(); + if (value.HasValue && (value.Value < 1 || value.Value > 100)) + { + result.ErrorMessage = "JPEG quality must be between 1 and 100."; + } + }); + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs b/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs new file mode 100644 index 0000000000..22c8ad918b --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class RemoveMetadataOption : Option + { + private static readonly string[] _aliases = ["--remove-metadata"]; + + public RemoveMetadataOption() + : base(_aliases, "Remove metadata from resized images") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs b/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs new file mode 100644 index 0000000000..9b5ed6333b --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class ReplaceOption : Option + { + private static readonly string[] _aliases = ["--replace", "-r"]; + + public ReplaceOption() + : base(_aliases, "Replace original files") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs b/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs new file mode 100644 index 0000000000..ac82519d34 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class ShowConfigOption : Option + { + private static readonly string[] _aliases = ["--show-config", "--config"]; + + public ShowConfigOption() + : base(_aliases, "Show current configuration") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs b/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs new file mode 100644 index 0000000000..d61b5ec10b --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class ShrinkOnlyOption : Option + { + private static readonly string[] _aliases = ["--shrink-only"]; + + public ShrinkOnlyOption() + : base(_aliases, "Only shrink images, don't enlarge") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs b/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs new file mode 100644 index 0000000000..7d1ed83bf3 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class SizeOption : Option + { + private static readonly string[] _aliases = ["--size"]; + + public SizeOption() + : base(_aliases, "Use preset size by index (0-based)") + { + AddValidator(result => + { + var value = result.GetValueOrDefault(); + if (value.HasValue && value.Value < 0) + { + result.ErrorMessage = "Size index must be a non-negative integer."; + } + }); + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs b/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs new file mode 100644 index 0000000000..9158c618f8 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class UnitOption : Option + { + private static readonly string[] _aliases = ["--unit", "-u"]; + + public UnitOption() + : base(_aliases, "Set unit (Pixel, Percent, Inch, Centimeter)") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs b/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs new file mode 100644 index 0000000000..072f25c079 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class WidthOption : Option + { + private static readonly string[] _aliases = ["--width", "-w"]; + + public WidthOption() + : base(_aliases, "Set width") + { + } + } +} diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj index 3ce98d8386..d927e9f2cb 100644 --- a/src/modules/imageresizer/ui/ImageResizerUI.csproj +++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj @@ -47,6 +47,7 @@ + diff --git a/src/modules/imageresizer/ui/Models/CliOptions.cs b/src/modules/imageresizer/ui/Models/CliOptions.cs new file mode 100644 index 0000000000..b066bf7ede --- /dev/null +++ b/src/modules/imageresizer/ui/Models/CliOptions.cs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +using System.CommandLine.Parsing; + +using ImageResizer.Cli.Commands; + +#pragma warning disable SA1649 // File name should match first type name +#pragma warning disable SA1402 // File may only contain a single type + +namespace ImageResizer.Models +{ + /// + /// Represents the command-line options for ImageResizer CLI mode. + /// + public class CliOptions + { + /// + /// Gets or sets a value indicating whether to show help information. + /// + public bool ShowHelp { get; set; } + + /// + /// Gets or sets a value indicating whether to show current configuration. + /// + public bool ShowConfig { get; set; } + + /// + /// Gets or sets the destination directory for resized images. + /// + public string DestinationDirectory { get; set; } + + /// + /// Gets or sets the width of the resized image. + /// + public double? Width { get; set; } + + /// + /// Gets or sets the height of the resized image. + /// + public double? Height { get; set; } + + /// + /// Gets or sets the resize unit (Pixel, Percent, Inch, Centimeter). + /// + public ResizeUnit? Unit { get; set; } + + /// + /// Gets or sets the resize fit mode (Fill, Fit, Stretch). + /// + public ResizeFit? Fit { get; set; } + + /// + /// Gets or sets the index of the preset size to use. + /// + public int? SizeIndex { get; set; } + + /// + /// Gets or sets a value indicating whether to only shrink images (not enlarge). + /// + public bool? ShrinkOnly { get; set; } + + /// + /// Gets or sets a value indicating whether to replace the original file. + /// + public bool? Replace { get; set; } + + /// + /// Gets or sets a value indicating whether to ignore orientation when resizing. + /// + public bool? IgnoreOrientation { get; set; } + + /// + /// Gets or sets a value indicating whether to remove metadata from the resized image. + /// + public bool? RemoveMetadata { get; set; } + + /// + /// Gets or sets the JPEG quality level (1-100). + /// + public int? JpegQualityLevel { get; set; } + + /// + /// Gets or sets a value indicating whether to keep the date modified. + /// + public bool? KeepDateModified { get; set; } + + /// + /// Gets or sets the output filename format. + /// + public string FileName { get; set; } + + /// + /// Gets the list of files to process. + /// + public ICollection Files { get; } = new List(); + + /// + /// Gets or sets the pipe name for receiving file list. + /// + public string PipeName { get; set; } + + /// + /// Gets parse/validation errors produced by System.CommandLine. + /// + public IReadOnlyList ParseErrors { get; private set; } = Array.Empty(); + + /// + /// Converts a boolean value to nullable bool (true -> true, false -> null). + /// + private static bool? ToBoolOrNull(bool value) => value ? true : null; + + /// + /// Parses command-line arguments into CliOptions using System.CommandLine. + /// + /// The command-line arguments. + /// A CliOptions instance with parsed values. + public static CliOptions Parse(string[] args) + { + var options = new CliOptions(); + var cmd = new ImageResizerRootCommand(); + + // Parse using System.CommandLine + var parseResult = new Parser(cmd).Parse(args); + + if (parseResult.Errors.Count > 0) + { + var errors = new List(parseResult.Errors.Count); + foreach (var error in parseResult.Errors) + { + errors.Add(error.Message); + } + + options.ParseErrors = new ReadOnlyCollection(errors); + } + + // Extract values from parse result using strongly typed options + options.ShowHelp = parseResult.GetValueForOption(cmd.HelpOption); + options.ShowConfig = parseResult.GetValueForOption(cmd.ShowConfigOption); + options.DestinationDirectory = parseResult.GetValueForOption(cmd.DestinationOption); + options.Width = parseResult.GetValueForOption(cmd.WidthOption); + options.Height = parseResult.GetValueForOption(cmd.HeightOption); + options.Unit = parseResult.GetValueForOption(cmd.UnitOption); + options.Fit = parseResult.GetValueForOption(cmd.FitOption); + options.SizeIndex = parseResult.GetValueForOption(cmd.SizeOption); + + // Convert bool to nullable bool (true -> true, false -> null) + options.ShrinkOnly = ToBoolOrNull(parseResult.GetValueForOption(cmd.ShrinkOnlyOption)); + options.Replace = ToBoolOrNull(parseResult.GetValueForOption(cmd.ReplaceOption)); + options.IgnoreOrientation = ToBoolOrNull(parseResult.GetValueForOption(cmd.IgnoreOrientationOption)); + options.RemoveMetadata = ToBoolOrNull(parseResult.GetValueForOption(cmd.RemoveMetadataOption)); + options.KeepDateModified = ToBoolOrNull(parseResult.GetValueForOption(cmd.KeepDateModifiedOption)); + + options.JpegQualityLevel = parseResult.GetValueForOption(cmd.QualityOption); + + options.FileName = parseResult.GetValueForOption(cmd.FileNameOption); + + // Get files from arguments + var files = parseResult.GetValueForArgument(cmd.FilesArgument); + if (files != null) + { + const string pipeNamePrefix = "\\\\.\\pipe\\"; + foreach (var file in files) + { + // Check for pipe name (must be at the start of the path) + if (file.StartsWith(pipeNamePrefix, StringComparison.OrdinalIgnoreCase)) + { + options.PipeName = file.Substring(pipeNamePrefix.Length); + } + else + { + options.Files.Add(file); + } + } + } + + return options; + } + + /// + /// Prints current configuration to the console. + /// + /// The settings to display. + public static void PrintConfig(ImageResizer.Properties.Settings settings) + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine("ImageResizer - Current Configuration"); + Console.WriteLine(); + Console.WriteLine("General Settings:"); + Console.WriteLine($" Shrink Only: {settings.ShrinkOnly}"); + Console.WriteLine($" Replace Original: {settings.Replace}"); + Console.WriteLine($" Ignore Orientation: {settings.IgnoreOrientation}"); + Console.WriteLine($" Remove Metadata: {settings.RemoveMetadata}"); + Console.WriteLine($" Keep Date Modified: {settings.KeepDateModified}"); + Console.WriteLine($" JPEG Quality: {settings.JpegQualityLevel}"); + Console.WriteLine($" PNG Interlace: {settings.PngInterlaceOption}"); + Console.WriteLine($" TIFF Compress: {settings.TiffCompressOption}"); + Console.WriteLine($" Filename Format: {settings.FileName}"); + Console.WriteLine(); + Console.WriteLine("Custom Size:"); + Console.WriteLine($" Width: {settings.CustomSize.Width} {settings.CustomSize.Unit}"); + Console.WriteLine($" Height: {settings.CustomSize.Height} {settings.CustomSize.Unit}"); + Console.WriteLine($" Fit Mode: {settings.CustomSize.Fit}"); + Console.WriteLine(); + Console.WriteLine("Preset Sizes:"); + for (int i = 0; i < settings.Sizes.Count; i++) + { + var size = settings.Sizes[i]; + var selected = i == settings.SelectedSizeIndex ? "*" : " "; + Console.WriteLine($" [{i}]{selected} {size.Name}: {size.Width}x{size.Height} {size.Unit} ({size.Fit})"); + } + + if (settings.SelectedSizeIndex >= settings.Sizes.Count) + { + Console.WriteLine($" [Custom]* {settings.CustomSize.Width}x{settings.CustomSize.Height} {settings.CustomSize.Unit} ({settings.CustomSize.Fit})"); + } + } + + /// + /// Prints usage information to the console. + /// + public static void PrintUsage() + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine("ImageResizer - PowerToys Image Resizer CLI"); + Console.WriteLine(); + + var cmd = new ImageResizerRootCommand(); + + // Print usage line + Console.WriteLine("Usage: PowerToys.ImageResizerCLI.exe [options] [files...]"); + Console.WriteLine(); + + // Print options from the command definition + Console.WriteLine("Options:"); + foreach (var option in cmd.Options) + { + var aliases = string.Join(", ", option.Aliases); + var description = option.Description ?? string.Empty; + Console.WriteLine($" {aliases,-30} {description}"); + } + + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" PowerToys.ImageResizerCLI.exe --help"); + Console.WriteLine(" PowerToys.ImageResizerCLI.exe --width 800 --height 600 image.jpg"); + Console.WriteLine(" PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent *.jpg"); + Console.WriteLine(" PowerToys.ImageResizerCLI.exe --size 0 -d \"C:\\Output\" photo.png"); + } + } +} diff --git a/src/modules/imageresizer/ui/Models/ResizeBatch.cs b/src/modules/imageresizer/ui/Models/ResizeBatch.cs index 87e0b84e7b..87fcd3a242 100644 --- a/src/modules/imageresizer/ui/Models/ResizeBatch.cs +++ b/src/modules/imageresizer/ui/Models/ResizeBatch.cs @@ -26,44 +26,45 @@ namespace ImageResizer.Models public ICollection Files { get; } = new List(); - public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args) + /// + /// Creates a ResizeBatch from CliOptions. + /// + /// Standard input stream for reading additional file paths. + /// The parsed CLI options. + /// A ResizeBatch instance. + public static ResizeBatch FromCliOptions(TextReader standardInput, CliOptions options) { - var batch = new ResizeBatch(); - const string pipeNamePrefix = "\\\\.\\pipe\\"; - string pipeName = null; - - for (var i = 0; i < args?.Length; i++) + var batch = new ResizeBatch { - if (args[i] == "/d") - { - batch.DestinationDirectory = args[++i]; - continue; - } - else if (args[i].Contains(pipeNamePrefix)) - { - pipeName = args[i].Substring(pipeNamePrefix.Length); - continue; - } + DestinationDirectory = options.DestinationDirectory, + }; - batch.Files.Add(args[i]); + foreach (var file in options.Files) + { + // Convert relative paths to absolute paths + var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file); + batch.Files.Add(absolutePath); } - if (string.IsNullOrEmpty(pipeName)) + if (string.IsNullOrEmpty(options.PipeName)) { // NB: We read these from stdin since there are limits on the number of args you can have + // Only read from stdin if it's redirected (piped input), not from interactive terminal string file; - if (standardInput != null) + if (standardInput != null && Console.IsInputRedirected) { while ((file = standardInput.ReadLine()) != null) { - batch.Files.Add(file); + // Convert relative paths to absolute paths + var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file); + batch.Files.Add(absolutePath); } } } else { using (NamedPipeClientStream pipeClient = - new NamedPipeClientStream(".", pipeName, PipeDirection.In)) + new NamedPipeClientStream(".", options.PipeName, PipeDirection.In)) { // Connect to the pipe or wait until the pipe is available. pipeClient.Connect(); @@ -84,17 +85,26 @@ namespace ImageResizer.Models return batch; } + public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args) + { + var options = CliOptions.Parse(args); + return FromCliOptions(standardInput, options); + } + public IEnumerable Process(Action reportProgress, CancellationToken cancellationToken) + { + // NOTE: Settings.Default is captured once before parallel processing. + // Any changes to settings on disk during this batch will NOT be reflected until the next batch. + // This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch. + return Process(reportProgress, Settings.Default, cancellationToken); + } + + public IEnumerable Process(Action reportProgress, Settings settings, CancellationToken cancellationToken) { double total = Files.Count; int completed = 0; var errors = new ConcurrentBag(); - // NOTE: Settings.Default is captured once before parallel processing. - // Any changes to settings on disk during this batch will NOT be reflected until the next batch. - // This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch. - var settings = Settings.Default; - // TODO: If we ever switch to Windows.Graphics.Imaging, we can get a lot more throughput by using the async // APIs and a custom SynchronizationContext Parallel.ForEach( diff --git a/src/modules/imageresizer/ui/Properties/Settings.cs b/src/modules/imageresizer/ui/Properties/Settings.cs index 0f8690dcbb..6b54a3fad1 100644 --- a/src/modules/imageresizer/ui/Properties/Settings.cs +++ b/src/modules/imageresizer/ui/Properties/Settings.cs @@ -15,6 +15,7 @@ using System.IO.Abstractions; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Windows.Media.Imaging; @@ -30,6 +31,7 @@ namespace ImageResizer.Properties { NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, WriteIndented = true, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), }; private static readonly CompositeFormat ValueMustBeBetween = System.Text.CompositeFormat.Parse(Properties.Resources.ValueMustBeBetween);