518 lines
17 KiB
C#
518 lines
17 KiB
C#
|
|
// Copyright (c) 2010-2014 SharpDX - Alexandre Mutel
|
|||
|
|
//
|
|||
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|||
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|||
|
|
// in the Software without restriction, including without limitation the rights
|
|||
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|||
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|||
|
|
// furnished to do so, subject to the following conditions:
|
|||
|
|
//
|
|||
|
|
// The above copyright notice and this permission notice shall be included in
|
|||
|
|
// all copies or substantial portions of the Software.
|
|||
|
|
//
|
|||
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|||
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|||
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|||
|
|
// THE SOFTWARE.
|
|||
|
|
|
|||
|
|
using System;
|
|||
|
|
using System.Collections;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.ComponentModel;
|
|||
|
|
using System.Diagnostics;
|
|||
|
|
using System.IO;
|
|||
|
|
using System.Reflection;
|
|||
|
|
using System.Runtime.InteropServices;
|
|||
|
|
|
|||
|
|
namespace SharpDX
|
|||
|
|
{
|
|||
|
|
/// <summary>
|
|||
|
|
/// Reusable, reflection based helper for parsing command line options.
|
|||
|
|
/// Greetings to Shawn Hargreaves, original code http://blogs.msdn.com/b/shawnhar/archive/2012/04/20/a-reusable-reflection-based-command-line-parser.aspx
|
|||
|
|
/// This is a modified version of command line parser that adds:
|
|||
|
|
/// - .NET 2.0 compatible
|
|||
|
|
/// - Allow inheritance to simplify declaration
|
|||
|
|
/// - Print exe banner, using AssemblyTitle and AssemblyCopyright.
|
|||
|
|
/// - Better padding of options, add descriptor and value text overrides.
|
|||
|
|
/// - Add support for - and / starting options.
|
|||
|
|
/// - Remove usage of ":" to separate option from parsed option
|
|||
|
|
/// - Add "<options>" to the Usage when options are defined
|
|||
|
|
/// - Add Console Color handling
|
|||
|
|
/// </summary>
|
|||
|
|
/// <remarks>
|
|||
|
|
/// This single file is intended to be directly included in the project that needs to handle command line without requiring any SharpDX assembly dependencies.
|
|||
|
|
/// </remarks>
|
|||
|
|
class ConsoleProgram
|
|||
|
|
{
|
|||
|
|
private const int STD_OUTPUT_HANDLE = -11;
|
|||
|
|
private static int hConsoleHandle;
|
|||
|
|
private static COORD ConsoleOutputLocation;
|
|||
|
|
private static CONSOLE_SCREEN_BUFFER_INFO ConsoleInfo;
|
|||
|
|
private static int OriginalColors;
|
|||
|
|
private Dictionary<string, FieldInfo> optionalOptions = new Dictionary<string, FieldInfo>();
|
|||
|
|
private List<string> optionalUsageHelp = new List<string>();
|
|||
|
|
private object optionsObject;
|
|||
|
|
private string[] optionNames;
|
|||
|
|
|
|||
|
|
private Queue<FieldInfo> requiredOptions = new Queue<FieldInfo>();
|
|||
|
|
|
|||
|
|
private List<string> requiredUsageHelp = new List<string>();
|
|||
|
|
|
|||
|
|
static ConsoleProgram()
|
|||
|
|
{
|
|||
|
|
ConsoleInfo = new CONSOLE_SCREEN_BUFFER_INFO();
|
|||
|
|
ConsoleOutputLocation = new COORD();
|
|||
|
|
hConsoleHandle = GetStdHandle(STD_OUTPUT_HANDLE);
|
|||
|
|
GetConsoleScreenBufferInfo(hConsoleHandle, ref ConsoleInfo);
|
|||
|
|
OriginalColors = ConsoleInfo.wAttributes;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
protected ConsoleProgram(int padOptions = 16) : this(null, padOptions)
|
|||
|
|
{
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Constructor.
|
|||
|
|
private ConsoleProgram(object optionsObjectArg, int padOptions = 16)
|
|||
|
|
{
|
|||
|
|
this.optionsObject = optionsObjectArg ?? this;
|
|||
|
|
|
|||
|
|
// Reflect to find what command line options are available.
|
|||
|
|
foreach (FieldInfo field in optionsObject.GetType().GetFields())
|
|||
|
|
{
|
|||
|
|
var option = GetOptionName(field);
|
|||
|
|
|
|||
|
|
var optionName = option.Name;
|
|||
|
|
|
|||
|
|
if (option.Required)
|
|||
|
|
{
|
|||
|
|
// Record a required option.
|
|||
|
|
requiredOptions.Enqueue(field);
|
|||
|
|
|
|||
|
|
requiredUsageHelp.Add(string.Format("<{0}>", option.Name));
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
// Record an optional option.
|
|||
|
|
optionalOptions.Add(optionName, field);
|
|||
|
|
|
|||
|
|
if (field.FieldType == typeof (bool))
|
|||
|
|
{
|
|||
|
|
optionalUsageHelp.Add(string.Format("/{0,-" + padOptions + "}{1}", optionName, option.Description ?? string.Empty));
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
optionalUsageHelp.Add(string.Format("/{0,-" + padOptions + "}{1}", string.Format("{0}{1}", optionName, option.Value ?? "<value>"), option.Description ?? string.Empty));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
optionNames = new string[optionalOptions.Count];
|
|||
|
|
optionalOptions.Keys.CopyTo(optionNames, 0);
|
|||
|
|
Array.Sort(optionNames, (left, right) => -string.Compare(left, right, StringComparison.Ordinal));
|
|||
|
|
|
|||
|
|
if (optionalOptions.Count > 0)
|
|||
|
|
{
|
|||
|
|
requiredUsageHelp.Insert(0, "<options>");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static void PrintHeader()
|
|||
|
|
{
|
|||
|
|
Console.WriteLine("{0} - {1}", GetAssemblyTitle(), Assembly.GetEntryAssembly().GetName().Version);
|
|||
|
|
Console.WriteLine("{0}", GetAssemblyCopyright());
|
|||
|
|
Console.WriteLine();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static bool ParseCommandLine(object options, string[] args, int padOptions = 16)
|
|||
|
|
{
|
|||
|
|
PrintHeader();
|
|||
|
|
|
|||
|
|
var cmdParser = new ConsoleProgram(options, padOptions);
|
|||
|
|
return cmdParser.ParseCommandLine(args);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
protected bool ParseCommandLine(string[] args)
|
|||
|
|
{
|
|||
|
|
// Parse each argument in turn.
|
|||
|
|
foreach (string arg in args)
|
|||
|
|
{
|
|||
|
|
if (!ParseArgument(arg.Trim()))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Make sure we got all the required options.
|
|||
|
|
FieldInfo missingRequiredOption = null;
|
|||
|
|
|
|||
|
|
foreach (var field in requiredOptions)
|
|||
|
|
{
|
|||
|
|
if (!IsList(field) || GetList(field).Count == 0)
|
|||
|
|
{
|
|||
|
|
missingRequiredOption = field;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (missingRequiredOption != null)
|
|||
|
|
{
|
|||
|
|
ShowError("Missing argument '{0}'", GetOptionName(missingRequiredOption).Name);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool ParseArgument(string arg)
|
|||
|
|
{
|
|||
|
|
if (arg.StartsWith("/") || arg.StartsWith("-"))
|
|||
|
|
{
|
|||
|
|
string name = arg.Substring(1);
|
|||
|
|
|
|||
|
|
string value = null;
|
|||
|
|
|
|||
|
|
FieldInfo field = null;
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
foreach (var registerName in optionNames)
|
|||
|
|
{
|
|||
|
|
if (name.StartsWith(registerName, StringComparison.InvariantCultureIgnoreCase))
|
|||
|
|
{
|
|||
|
|
field = optionalOptions[registerName];
|
|||
|
|
value = name.Substring(registerName.Length);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (field == null)
|
|||
|
|
{
|
|||
|
|
ShowError("Unknown option '{0}'", name);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (string.IsNullOrEmpty(value))
|
|||
|
|
{
|
|||
|
|
value = "true";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return SetOption(field, value);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
// Parse a required argument.
|
|||
|
|
if (requiredOptions.Count == 0)
|
|||
|
|
{
|
|||
|
|
ShowError("Too many arguments");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
FieldInfo field = requiredOptions.Peek();
|
|||
|
|
|
|||
|
|
if (!IsList(field))
|
|||
|
|
{
|
|||
|
|
requiredOptions.Dequeue();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return SetOption(field, arg);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool SetOption(FieldInfo field, string value)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (IsList(field))
|
|||
|
|
{
|
|||
|
|
// Append this value to a list of options.
|
|||
|
|
GetList(field).Add(ChangeType(value, ListElementType(field)));
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
// Set the value of a single option.
|
|||
|
|
field.SetValue(optionsObject, ChangeType(value, field.FieldType));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
ShowError("Invalid value '{0}' for option '{1}'", value, GetOptionName(field).Name);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
private static object ChangeType(string value, Type type)
|
|||
|
|
{
|
|||
|
|
TypeConverter converter = TypeDescriptor.GetConverter(type);
|
|||
|
|
|
|||
|
|
return converter.ConvertFromInvariantString(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
private static bool IsList(FieldInfo field)
|
|||
|
|
{
|
|||
|
|
return typeof (IList).IsAssignableFrom(field.FieldType);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
private IList GetList(FieldInfo field)
|
|||
|
|
{
|
|||
|
|
return (IList) field.GetValue(optionsObject);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
private static Type ListElementType(FieldInfo field)
|
|||
|
|
{
|
|||
|
|
foreach (var fieldInterface in field.FieldType.GetInterfaces())
|
|||
|
|
{
|
|||
|
|
if (fieldInterface.IsGenericType && fieldInterface.GetGenericTypeDefinition() == typeof (IEnumerable<>))
|
|||
|
|
{
|
|||
|
|
return fieldInterface.GetGenericArguments()[0];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static OptionAttribute GetOptionName(FieldInfo field)
|
|||
|
|
{
|
|||
|
|
return GetAttribute<OptionAttribute>(field) ?? new OptionAttribute(field.Name);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void ShowError(string message, params object[] args)
|
|||
|
|
{
|
|||
|
|
string name = Path.GetFileNameWithoutExtension(Process.GetCurrentProcess().ProcessName);
|
|||
|
|
|
|||
|
|
ErrorColor();
|
|||
|
|
System.Console.Error.WriteLine(message, args);
|
|||
|
|
ResetColor();
|
|||
|
|
System.Console.Error.WriteLine();
|
|||
|
|
System.Console.Out.WriteLine("Usage: {0} {1}", name, string.Join(" ", requiredUsageHelp.ToArray()));
|
|||
|
|
|
|||
|
|
if (optionalUsageHelp.Count > 0)
|
|||
|
|
{
|
|||
|
|
System.Console.Out.WriteLine();
|
|||
|
|
System.Console.Out.WriteLine("Options:");
|
|||
|
|
|
|||
|
|
foreach (string optional in optionalUsageHelp)
|
|||
|
|
{
|
|||
|
|
System.Console.Out.WriteLine(" {0}", optional);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static T GetAttribute<T>(ICustomAttributeProvider provider) where T : Attribute
|
|||
|
|
{
|
|||
|
|
var attributes = provider.GetCustomAttributes(typeof (T), false);
|
|||
|
|
if (attributes.Length > 0)
|
|||
|
|
{
|
|||
|
|
return (T)attributes[0];
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Gets the assembly title.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <value>The assembly title.</value>
|
|||
|
|
private static string GetAssemblyTitle()
|
|||
|
|
{
|
|||
|
|
var attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(AssemblyTitleAttribute), false);
|
|||
|
|
if (attributes.Length > 0)
|
|||
|
|
{
|
|||
|
|
var titleAttribute = (AssemblyTitleAttribute)attributes[0];
|
|||
|
|
if (!string.IsNullOrEmpty(titleAttribute.Title))
|
|||
|
|
return titleAttribute.Title;
|
|||
|
|
}
|
|||
|
|
return Path.GetFileNameWithoutExtension(Assembly.GetExecutingAssembly().CodeBase);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Gets the assembly title.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <value>The assembly title.</value>
|
|||
|
|
private static string GetAssemblyCopyright()
|
|||
|
|
{
|
|||
|
|
var attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(AssemblyCopyrightAttribute), false);
|
|||
|
|
if (attributes.Length > 0)
|
|||
|
|
{
|
|||
|
|
var titleAttribute = (AssemblyCopyrightAttribute)attributes[0];
|
|||
|
|
if (!string.IsNullOrEmpty(titleAttribute.Copyright))
|
|||
|
|
return titleAttribute.Copyright;
|
|||
|
|
}
|
|||
|
|
return string.Empty;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Used on optionsObject fields to indicate which options are required.
|
|||
|
|
|
|||
|
|
[DllImport("kernel32.dll", EntryPoint = "GetStdHandle", SetLastError = true,
|
|||
|
|
CharSet = CharSet.Auto,
|
|||
|
|
CallingConvention = CallingConvention.StdCall)]
|
|||
|
|
private static extern int GetStdHandle(int nStdHandle);
|
|||
|
|
|
|||
|
|
[DllImport("kernel32.dll", EntryPoint = "GetConsoleScreenBufferInfo",
|
|||
|
|
SetLastError = true, CharSet = CharSet.Auto,
|
|||
|
|
CallingConvention = CallingConvention.StdCall)]
|
|||
|
|
private static extern int GetConsoleScreenBufferInfo(int hConsoleOutput,
|
|||
|
|
ref CONSOLE_SCREEN_BUFFER_INFO lpConsoleScreenBufferInfo);
|
|||
|
|
|
|||
|
|
[DllImport("kernel32.dll", EntryPoint = "SetConsoleTextAttribute",
|
|||
|
|
SetLastError = true, CharSet = CharSet.Auto,
|
|||
|
|
CallingConvention = CallingConvention.StdCall)]
|
|||
|
|
private static extern int SetConsoleTextAttribute(int hConsoleOutput,
|
|||
|
|
int wAttributes);
|
|||
|
|
|
|||
|
|
public static void ErrorColor()
|
|||
|
|
{
|
|||
|
|
Color(ConsoleColor.Red | ConsoleColor.Intensity);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static void Color(ConsoleColor color)
|
|||
|
|
{
|
|||
|
|
SetConsoleTextAttribute(hConsoleHandle, (int) color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static void ResetColor()
|
|||
|
|
{
|
|||
|
|
SetConsoleTextAttribute(hConsoleHandle, OriginalColors);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#region Nested type: CONSOLE_SCREEN_BUFFER_INFO
|
|||
|
|
|
|||
|
|
[StructLayout(LayoutKind.Sequential)]
|
|||
|
|
private struct CONSOLE_SCREEN_BUFFER_INFO
|
|||
|
|
{
|
|||
|
|
public COORD dwSize;
|
|||
|
|
public COORD dwCursorPosition;
|
|||
|
|
public int wAttributes;
|
|||
|
|
public SMALL_RECT srWindow;
|
|||
|
|
public COORD dwMaximumWindowSize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region Nested type: COORD
|
|||
|
|
|
|||
|
|
[StructLayout(LayoutKind.Sequential)]
|
|||
|
|
private struct COORD
|
|||
|
|
{
|
|||
|
|
private short X;
|
|||
|
|
private short Y;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region Nested type: OptionAttribute
|
|||
|
|
|
|||
|
|
[AttributeUsage(AttributeTargets.Field)]
|
|||
|
|
public sealed class OptionAttribute : Attribute
|
|||
|
|
{
|
|||
|
|
public OptionAttribute(string name)
|
|||
|
|
{
|
|||
|
|
this.Name = name;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public string Name { get; private set; }
|
|||
|
|
|
|||
|
|
public string Description { get; set; }
|
|||
|
|
|
|||
|
|
public string Value { get; set; }
|
|||
|
|
|
|||
|
|
public bool Required { get; set; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region Nested type: SMALL_RECT
|
|||
|
|
|
|||
|
|
[StructLayout(LayoutKind.Sequential)]
|
|||
|
|
private struct SMALL_RECT
|
|||
|
|
{
|
|||
|
|
private short Left;
|
|||
|
|
private short Top;
|
|||
|
|
private short Right;
|
|||
|
|
private short Bottom;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Colors used by <see cref="ConsoleProgram.Color"/>
|
|||
|
|
/// </summary>
|
|||
|
|
[Flags]
|
|||
|
|
enum ConsoleColor
|
|||
|
|
{
|
|||
|
|
/// <summary>
|
|||
|
|
/// Blue foreground color.
|
|||
|
|
/// </summary>
|
|||
|
|
Blue = 0x00000001,
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Green foreground color.
|
|||
|
|
/// </summary>
|
|||
|
|
Green = 0x00000002,
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Red foreground color.
|
|||
|
|
/// </summary>
|
|||
|
|
Red = 0x00000004,
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Intensity foreground color modifier.
|
|||
|
|
/// </summary>
|
|||
|
|
Intensity = 0x00000008,
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Blue background color.
|
|||
|
|
/// </summary>
|
|||
|
|
BlueBackground = 0x00000010,
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Green background color.
|
|||
|
|
/// </summary>
|
|||
|
|
GreenBackground = 0x00000020,
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Red background color.
|
|||
|
|
/// </summary>
|
|||
|
|
RedBackground = 0x00000040,
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Intensity background color modifier.
|
|||
|
|
/// </summary>
|
|||
|
|
IntensityBackground = 0x00000080,
|
|||
|
|
|
|||
|
|
|
|||
|
|
Black = 0,
|
|||
|
|
|
|||
|
|
Cyan = Green | Blue,
|
|||
|
|
|
|||
|
|
Magenta = Red | Blue,
|
|||
|
|
|
|||
|
|
Yellow = Red | Green,
|
|||
|
|
|
|||
|
|
DarkGrey = Red | Green | Blue,
|
|||
|
|
|
|||
|
|
LightGrey = Intensity,
|
|||
|
|
|
|||
|
|
LightRed = Intensity | Red,
|
|||
|
|
|
|||
|
|
LightGreen = Intensity | Green,
|
|||
|
|
|
|||
|
|
LightBlue = Intensity | Blue,
|
|||
|
|
|
|||
|
|
LightCyan = Intensity | Green | Blue,
|
|||
|
|
|
|||
|
|
LightMagenta = Intensity | Red | Blue,
|
|||
|
|
|
|||
|
|
LightYellow = Intensity | Red | Green,
|
|||
|
|
|
|||
|
|
White = Intensity | Red | Green | Blue,
|
|||
|
|
}
|
|||
|
|
}
|