diff --git a/.gitignore b/.gitignore index f34ab01..8ffca3f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ riderModule.iml /_ReSharper.Caches/ # Don't care about IDE specific things /.idea -*.DotSettings.user \ No newline at end of file +*.DotSettings.user +# Don't care about files created from the build script +PowershellModule/build/PowershellModule diff --git a/Harness/Harness.csproj b/Harness/Harness.csproj new file mode 100644 index 0000000..9fe2c2b --- /dev/null +++ b/Harness/Harness.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + latestmajor + + + + + + + diff --git a/Harness/Program.cs b/Harness/Program.cs new file mode 100644 index 0000000..59e7805 --- /dev/null +++ b/Harness/Program.cs @@ -0,0 +1,20 @@ +using System.Text; +using ModuleCore.Calendar; + +namespace Harness; + +class Program +{ + static void Main(string[] args) + { + Console.OutputEncoding = Encoding.Unicode; + var a = new Calendar(); + var lastMonth = a.RenderCalendar(DateTime.Now.AddMonths(-1)); + var now = a.RenderCalendar(DateTime.Now); + var august = a.RenderCalendar(DateTime.Now.AddMonths(1)); + + Console.Write(lastMonth); + Console.Write(now); + Console.Write(august); + } +} \ No newline at end of file diff --git a/ModuleCore/Calendar/Calendar.cs b/ModuleCore/Calendar/Calendar.cs new file mode 100644 index 0000000..138f394 --- /dev/null +++ b/ModuleCore/Calendar/Calendar.cs @@ -0,0 +1,259 @@ +using System.Globalization; +using System.IO.MemoryMappedFiles; +using System.Text; + +namespace ModuleCore.Calendar; + +public sealed class Calendar +{ + public const string DefaultMarkedOffSymbol = "x"; + public const DayOfWeek DefaultStartOfWeek = DayOfWeek.Monday; + + private readonly string _markedOffSymbol; + private readonly DayOfWeek _startOfWeek; + + /// + /// Holds configured day abbreviations, based on . + /// + /// Using english as the culture: Sunday = Sun, Monday = Mon and so on. This lookup also starts with + /// Sunday as the first day to match the enum starting with Sunday. + /// + /// + private readonly List _daysOfWeekLookup; + + /// + /// Used to work out how to pad columns for each day, based on the longest abbreviated day + /// + private readonly int _dayWidth; + + private readonly CultureInfo _cultureInfo; + + private readonly string _rolloverDaySymbol = ""; + + public Calendar(string? markedDaySymbol = null, DayOfWeek? startOfWeek = null) + { + _markedOffSymbol = markedDaySymbol ?? DefaultMarkedOffSymbol; + _startOfWeek = startOfWeek ?? DefaultStartOfWeek; + + // Generate localised day of week lookup table + _daysOfWeekLookup = new List(7); + _dayWidth = _markedOffSymbol.Length; + // TODO: make this passed in from configuration, and if its invalid default it to + // CultureInfo.CurrentCulture + // TODO: test if its possible to determine if a culture string is invalid and what happens + _cultureInfo = new CultureInfo("en-AU"); + for (var i = 0; i < 7; i++) + { + var localisedDayAbbreviation = _cultureInfo.DateTimeFormat.GetAbbreviatedDayName((DayOfWeek)i); + + // For unicode languages (such as Japanese and Chinese), the length of a day will be reported as 1, + // but will display in a way that makes them look short or offset. + // eg: + // ┌───────────────────────────┐ + // │ 7月 │ + // ├───┬───┬───┬───┬───┬───┬───┤ + // │ 月 │ 火 │ 水 │ 木 │ 金 │ 土 │ 日 │ + // ├───┴───┴───┴───┴───┴───┴───┤ + // There's not really an easy fix I know of (yet) to resolve this (and I'm not really too bothered by it yet) + // so unfortunately it has to stay as a known bug as is currently + if (localisedDayAbbreviation.Length > _dayWidth) + { + _dayWidth = localisedDayAbbreviation.Length; + } + + _daysOfWeekLookup.Add(localisedDayAbbreviation); + } + + // Increase the width by 2 to include a space on either side + _dayWidth += 2; + } + + public string RenderCalendar(DateTime month) + { + var sb = new StringBuilder(); + // Generate the header for the month/days of week + GenerateHeader(sb, month); + + var calendarForMonth = GenerateCalendarForMonth(month, _markedOffSymbol, _startOfWeek, _rolloverDaySymbol); + + for (var i = 0; i < calendarForMonth.WeeksInMonth; i++) + { + sb.Append("│"); + for (var j = 0; j < 7; j++) + { + var index = j + i * 7; + // We align days to the right of their box, 1 off from the border. + // Sure I could center them, but honestly this is better to read. + sb.Append(calendarForMonth.Calendar[index].PadLeft(_dayWidth - 1)); + sb.Append(" │"); + } + + sb.AppendLine(); + } + + // Cap the calendar off + GenerateSpacer(sb, "└", "─", "┴", "┘", true); + + return sb.ToString(); + } + + private void GenerateHeader(StringBuilder sb, DateTime month) + { + // Top border for the calendar, with padder and seperator the same as there's no columns immediately + // under it + GenerateSpacer(sb, "┌", "─", "─", "┐"); + + var monthString = month.ToString("MMMM yyyy", _cultureInfo); + // total width of the calendar is width of the localised day calculated in the constructor, plus 7 extra + // to account for separators for each day. + // It shouldn't need to be said that the 7 here stands for days in a week + var calendarWidth = _dayWidth * 7 + 7; + // Calculate the offsets for + var offsets = ( + left: Math.Floor(calendarWidth / 2.0 + monthString.Length / 2.0), + right: Math.Ceiling(calendarWidth / 2.0 - monthString.Length / 2.0) + ); + + sb.Append("│") + .Append(monthString.PadLeft((int)offsets.left)) + .AppendLine("│".PadLeft((int)offsets.right)); + + // Top border for the week + GenerateSpacer(sb, "├", "─", "┬", "┤"); + + // Work out the week display based on start of week + var d = 0; + do + { + // Index into the day of week, wrapping if needed from the configured start of the week. + // The DayOfWeek enum starts with Sunday, so by default the start of the week is 0, + // But most calendars visually start with Monday as the start of the week which is our default start + // of week. + // We also pad the string to the width of the day (minus 2 for spaces either side of the day), if + // the marked day symbol is a long string. + // Why did I decide to support arbitrary length marked days? Why not? + sb.Append($"│ {_daysOfWeekLookup[(d + (int)_startOfWeek) % 7].PadLeft(_dayWidth - 2)} "); + d++; + } while (d < 7); + + // End the week block + sb.AppendLine("│"); + // bottom border + GenerateSpacer(sb, "├", "─", "┼", "┤"); + } + + private void GenerateSpacer(StringBuilder sb, string leftCap, string padder, string seperator, string rightCap, bool noNewLine = false) + { + sb.Append(leftCap); + for (var i = 0; i < 7; i++) + { + for (var j = 0; j < _dayWidth; j++) + { + sb.Append(padder); + } + + if (i < 6) + { + sb.Append(seperator); + } + } + + if (noNewLine) + { + sb.Append(rightCap); + } + else + { + sb.AppendLine(rightCap); + } + } + + private MonthCalendar GenerateCalendarForMonth(DateTime month, string markedOffSymbol, DayOfWeek calendarStartOfWeek, string rolloverDaySymbol) + { + // TODO: use startOfWeek to offset the start of the calendar later + + // Reset the month to the start + var startOfMonth = new DateTime(month.Year, month.Month, 1); + // If the visual start of the month is before the actual start day of the month, we need to visually shift the + // calendar "back" a week, and pad appropriately. + // Eg, if the start of the month is Sunday, but our visual start of the week is a Monday, then we need to + // make a new week to display Monday as the start, and then pad blank days to Sunday. + // This could probably be calculated better to avoid annoying to read logic but I'm drunk at this moment so the + // brain ain't there. + // For what its worth, I wasn't drunk for the rest of the code in this file, as hard as that is to believe! + var daysToPadToStartMonth = startOfMonth.DayOfWeek >= calendarStartOfWeek + ? startOfMonth.DayOfWeek - calendarStartOfWeek + : 7 - (calendarStartOfWeek - startOfMonth.DayOfWeek); + + var daysInMonth = DateTime.DaysInMonth(month.Year, month.Month); + // TODO: a lot of this could probably be simplified by simply taking the start day of the week, and then + // based on where it would be in the first week, work out if the month is 4, 5 or 6 weeks from that. + // all this needs is a pre-calculated table and that's it. + // Weeks have 7 days so there's only a fixed permutation of weeks based on the day of the week a month + // starts on, and months have anywhere from 28 to 31 days + // Increase the days by the previous months + var paddedDaysInMonth = daysInMonth + daysToPadToStartMonth; + // How many days to add to the end to make the absolute number of days (including any + // days that would visually roll over on a calendar) + // eg, if there are 2 "roll over" days from the previous month and this month has 31 days, + // Work out how many days we have left from 7, then get the _final_ number of days to make it a multiple + // _of_ 7. + // So this results in (33%7) == 5 - 7 == 2 + 33 == 35 % 7 == 0 which means we have a "complete" month + // including next month roll over. + // Visually we won't be showing those roll over days but it makes things easier if I decide to later. + // If the days in the month plus padded days is already a multiple of 7, we don't need to add any more days. + var daysToPadToEndMonth = paddedDaysInMonth % 7 != 0 + ? 7 - paddedDaysInMonth % 7 + : 0; + paddedDaysInMonth += daysToPadToEndMonth; + // Round to the nearest week after padding for the previous months days. + // Naively allowing a loss of precision via int division because the paddedDaysInMonth should always + // be a multiple of 7. + // "should always" is going to be found not as true later + var weeksInMonth = paddedDaysInMonth / 7; + + var cal = new List(); + for (var i = 0; i < daysToPadToStartMonth; i++) + { + cal.Add(rolloverDaySymbol); + } + + var today = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + // If we're in the same year and month, any days already elapsed should be marked off, + // otherwise we display all days. + // If the DateTime we're rendering is in the past we render all days as marked off + // TODO: maybe don't do that and display the calendar differently + var hideElapsedDays = today.Year == month.Year && today.Month == month.Month; + + for (var i = 1; i < daysInMonth + 1; i++) + { + // if we're marking off elapsed days and we're not today or ahead, 'mark' it off + if (hideElapsedDays && i < today.Day) + { + cal.Add(markedOffSymbol); + } + else + { + cal.Add($"{i}"); + } + } + + // pad the rollover days at the end of the calendar + for (var i = 0; i < daysToPadToEndMonth; i++) + { + cal.Add(rolloverDaySymbol); + } + + return new MonthCalendar + { + Calendar = cal, + WeeksInMonth = weeksInMonth + }; + } +} + +public class MonthCalendar +{ + public List Calendar { get; set; } + public int WeeksInMonth { get; set; } +} \ No newline at end of file diff --git a/ModuleCore/ModuleCore.csproj b/ModuleCore/ModuleCore.csproj new file mode 100644 index 0000000..4ef1a45 --- /dev/null +++ b/ModuleCore/ModuleCore.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + latestmajor + + + diff --git a/PowershellHarness/CustomHost.cs b/PowershellHarness/CustomHost.cs new file mode 100644 index 0000000..96ef22b --- /dev/null +++ b/PowershellHarness/CustomHost.cs @@ -0,0 +1,216 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; +using System.Text; + +namespace PowershellHarness; + +// A whole bunch of empty implementations just so cmdlets have access to anything within Host.Ui (and probably any cmdlets +// that prompt for information later) +// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSHost.cs#L13 +public class CustomHost : PSHost +{ + public override string Name => "Custom Host"; + public override Version Version { get; } = new Version(0, 0, 0, 0); + public override Guid InstanceId { get; } = Guid.NewGuid(); + + private CustomUiHost _ui = new CustomUiHost(); + public override CustomUiHost UI => _ui; + + public override CultureInfo CurrentCulture { get; } = CultureInfo.CurrentCulture; + public override CultureInfo CurrentUICulture { get; } = CultureInfo.CurrentUICulture; + + public CustomHost(int width, int height = 100) + { + _ui.RawUI.WindowSize = new() { Width = width, Height = height }; + } + + public override void EnterNestedPrompt() + { + throw new NotImplementedException("EnterNestedPrompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override void ExitNestedPrompt() + { + throw new NotImplementedException("ExitNestedPrompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override void NotifyBeginApplication() + { + } + + public override void NotifyEndApplication() + { + } + + public override void SetShouldExit(int exitCode) + { + } +} + +// https://github.com/leechristensen/OffensivePowerShellTasking/blob/d1b498d874948f41e6dc053204c511fa6fc11c9c/OffensivePowerShellTasking/CustomPSHostUserInterface.cs#L8 +public class CustomUiHost : PSHostUserInterface +{ + // The only stuff that really matters to expose a host for any cmdlets + private readonly CustomRawUiHost _rawUI = new CustomRawUiHost(); + public override CustomRawUiHost RawUI => _rawUI; + + // Replace StringBuilder with whatever your preferred output method is (e.g. a socket or a named pipe) + public StringBuilder output { get; set; } + + public CustomUiHost() + { + output = new StringBuilder(); + } + + public CustomUiHost(ref StringBuilder sb) + { + output = sb; + } + + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + //output.Append("!").Append(value); + } + + public override void WriteLine() + { + //output.Append("!").Append("\n"); + } + + public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + //output.Append("!").Append(value + "\n"); + } + + public override void Write(string value) + { + //output.Append("!").Append(value); + } + + public override void WriteDebugLine(string message) + { + //output.Append("!").AppendLine("DEBUG: " + message); + } + + public override void WriteErrorLine(string value) + { + //output.Append("!").AppendLine("ERROR: " + value); + } + + public override void WriteLine(string value) + { + //output.Append("!").AppendLine(value); + } + + public override void WriteVerboseLine(string message) + { + //output.Append("!").AppendLine("VERBOSE: " + message); + } + + public override void WriteWarningLine(string message) + { + //output.Append("!").AppendLine("WARNING: " + message); + } + + public override void WriteProgress(long sourceId, ProgressRecord record) + { + } + + public string Output => output.ToString(); + + public override Dictionary Prompt(string caption, string message, System.Collections.ObjectModel.Collection descriptions) + { + throw new NotImplementedException("Prompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection choices, int defaultChoice) + { + throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) + { + throw new NotImplementedException("PromptForCredential1 is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) + { + throw new NotImplementedException("PromptForCredential2 is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override string ReadLine() + { + throw new NotImplementedException("ReadLine is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override SecureString ReadLineAsSecureString() + { + throw new NotImplementedException("ReadLineAsSecureString is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } +} + +// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSRHostRawUserInterface.cs#L8 +public class CustomRawUiHost : PSHostRawUserInterface +{ + public override ConsoleColor BackgroundColor { get; set; } = ConsoleColor.Black; + + public override Size BufferSize { get; set; } = new Size { Width = 100, Height = 1000 }; + + public override Coordinates CursorPosition { get; set; } = new Coordinates { X = 0, Y = 0 }; + + public override int CursorSize { get; set; } = 1; + + public override void FlushInputBuffer() + { + throw new NotImplementedException("FlushInputBuffer is not implemented."); + } + + public override ConsoleColor ForegroundColor { get; set; } = ConsoleColor.White; + + public override BufferCell[,] GetBufferContents(Rectangle rectangle) + { + throw new NotImplementedException("GetBufferContents is not implemented."); + } + + public override bool KeyAvailable + { + get { throw new NotImplementedException("KeyAvailable is not implemented."); } + } + + public override Size MaxPhysicalWindowSize { get; } = new Size + { + Width = int.MaxValue, + Height = int.MaxValue + }; + + public override Size MaxWindowSize { get; } = new Size { Width = 100, Height = 100 }; + + public override KeyInfo ReadKey(ReadKeyOptions options) + { + throw new NotImplementedException("ReadKey is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) + { + throw new NotImplementedException("ScrollBufferContents is not implemented"); + } + + public override void SetBufferContents(Rectangle rectangle, BufferCell fill) + { + throw new NotImplementedException("SetBufferContents is not implemented."); + } + + public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) + { + throw new NotImplementedException("SetBufferContents is not implemented"); + } + + public override Coordinates WindowPosition { get; set; } = new Coordinates { X = 0, Y = 0 }; + + public override Size WindowSize { get; set; } = new Size { Width = 120, Height = 100 }; + + public override string WindowTitle { get; set; } = ""; +} \ No newline at end of file diff --git a/PowershellHarness/PowershellHarness.csproj b/PowershellHarness/PowershellHarness.csproj new file mode 100644 index 0000000..250d874 --- /dev/null +++ b/PowershellHarness/PowershellHarness.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/PowershellHarness/Program.cs b/PowershellHarness/Program.cs new file mode 100644 index 0000000..077a9b7 --- /dev/null +++ b/PowershellHarness/Program.cs @@ -0,0 +1,145 @@ +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Text; +using PowershellModule.Calendar; + +namespace PowershellHarness; + +// https://github.com/FuseCP/FuseCP/blob/278a19dc06949600f25a1b4ed74d0419a8fa3fc2/FuseCP/Sources/FuseCP.Providers.HostedSolution.SfB2015/SfBBase.cs#L224 +// some useful code in here tbh +class Program +{ + static void Main(string[] args) + { + // I've gotta work out how to get the rider terminal to act closer to powershell because .PadLeft in powershell + // will correctly output lines when output with WriteOutput, but the Jetbrains debug worker (or whatever + // it is that rider spawns), has none of that and operates in its own world. + // Which is fine if you never want new lines. + // Ideally you'd just launch pwsh.exe and attach to that, but _that_ has its own issues as well. + // The new line issue might be related to how I've set up the host seeing as I just did a bunch of copy paste shit + // from something I found on github to get it across the line so I could debug it. + // I'll eventually revisit those hosts and rewrite them properly once I start adding more commands, and if I care. + // Threads & variables has all the visuals I need after all. +#if DEBUG + if (!System.Diagnostics.Debugger.IsAttached) + { + Console.BackgroundColor = ConsoleColor.Yellow; + Console.WriteLine(CenterText("Running without a debugger attached may result in lines not being output correctly")); + Console.ResetColor(); + } +#endif + Console.WriteLine(CenterText(" PowerShell Debug Harness ")); + Console.WriteLine($"Reported console window size: {Console.WindowWidth}x{Console.WindowHeight}"); + Console.WriteLine($"Reported console buffer size: {Console.BufferWidth}x{Console.BufferHeight}"); + Console.WriteLine(CenterText(" WIDTH ", '-')); + + var host = new CustomHost(Console.WindowWidth); + var runspace = InitialisePowershellHost(host); + + // InvokeCommand(runspace, GetCalendarCommand.FullName); + + foreach (var day in Enum.GetValues()) + { + InvokeCommand(runspace, GetCalendarCommand.FullName, [ + CreateCommand(nameof(GetCalendarCommand.MarkedDaySymbol), "faker"), + CreateCommand(nameof(GetCalendarCommand.StartOfWeek), day.ToString()), + CreateCommand(nameof(GetCalendarCommand.Date), "22/6/26"), + // CreateCommand(nameof(GetCalendarCommand.AlignRight)) + ]); + } + + // InvokeCommand(runspace, GetCalendarCommand.FullName, [ + // new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"), + // new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday) + // ]); + // InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]); + } + + private static CommandParameter CreateCommand(string name, string? argument = null) + { + if (argument is not null) + { + return new CommandParameter(name, argument); + } + + return new CommandParameter(name); + } + + static string CenterText(string text, char paddingChar = ' ') + { + return text.PadLeft((Console.WindowWidth + text.Length) / 2, paddingChar).PadRight(Console.WindowWidth, paddingChar); + } + + /// + /// Initialises a PowerShell session and returns an open Runspace + /// + /// + /// + static Runspace InitialisePowershellHost(CustomHost host) + { + // Create the initial session state for the host. Yes, that 2 on the end does indicate that the underlying + // implementation for this is C++ + // Welcome to the lands of fuck all documentation and figuring shit out from an increasingly shit internet + // where finding non-slop answers gets harder by the day as people close sources of information to prevent + // scraping. Fuck every single person involved modern AI. + // It wasn't much better finding documentation about the System.Management.* namespace before that, but it's + // definitely a lot worse because of it + var initialSessionState = InitialSessionState.CreateDefault2(); + + + // No idea what the helpFileName should be. As is common with _a lot_ of the System.Management.* namespace, + // fuck all is actually documented with comments, or documented at all! + // From https://learn.microsoft.com/en-us/powershell/scripting/developer/hosting/creating-a-constrained-runspace?view=powershell-7.6 + // (which yes, this section _is_ under a legacy category!), it's perfectly fine to leave it as null. + // We also don't really care as we're just doing this so we can test commands without having to deal with Rider + // and it's quirks around runnning a powershell terminal and attaching to it. Yeah it technically works, but + // it's way too common to end up with Rider refusing to build correctly or ensure the right dll is used for the module + var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null); + initialSessionState.Commands.Add(getCalendarCommand); + + // Create a runspace from the state, open and return it + var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState); + + // A runspace is technically disposable, but we're not reimplementing a full host and we won't be doing anything + // that would require a fresh runspace multiple times over (yet), so we can just treat the lifetime of the + // disposable as application lifetime + runspace.Open(); + + return runspace; + } + + static void InvokeCommand(Runspace runspace, string command, IEnumerable? parameters = null) + { + // Not too sure on the difference of runspace vs PowerShell here. Doesn't seem to make a difference either way + // and this is just a debug harness so it doesn't really matter for now + using var pipeline = runspace.CreatePipeline(); + // using var powershell = PowerShell.Create(runspace); + + var cmd = new Command(command); + if (parameters is not null) + { + foreach (var commandParameter in parameters) + { + cmd.Parameters.Add(commandParameter); + } + } + + pipeline.Commands.Add(cmd); + // powershell.Commands.AddCommand(cmd); + + try + { + var results = pipeline.Invoke(); + // var results = powershell.Invoke(); + foreach (var result in results) + { + Console.Write(result); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unable to execute command {command}"); + Console.Error.WriteLine(ex.GetBaseException().Message); + } + } +} \ No newline at end of file diff --git a/PowershellModule.slnx b/PowershellModule.slnx new file mode 100644 index 0000000..4331297 --- /dev/null +++ b/PowershellModule.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/PowershellModule/Calendar/GetCalendarCommand.cs b/PowershellModule/Calendar/GetCalendarCommand.cs new file mode 100644 index 0000000..64a6db7 --- /dev/null +++ b/PowershellModule/Calendar/GetCalendarCommand.cs @@ -0,0 +1,173 @@ +using System; +using System.Globalization; +using System.Management.Automation; + +namespace PowershellModule.Calendar +{ + [Cmdlet(VerbsCommon.Get, Noun)] + public class GetCalendarCommand : PSCmdlet + { +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + [Parameter( + Mandatory = false, + Position = 0)] + public string Date { get; set; } + + [Parameter( + Mandatory = false, + Position = 1)] + public string MarkedDaySymbol { get; set; } + + [Parameter( + Mandatory = false, + Position = 2)] + [ValidateSet("Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday")] + // pass a type in to get dynamic types + // NOTE: the generator gets called for _each_ character input, so ensure the values are idempotent for every + // keydown (and fast), or cached + // [ValidateSet(typeof(StartDayOfWeekGenerator))] + public string StartOfWeek { get; set; } + + [Parameter( + Mandatory = false, + Position = 3)] + public SwitchParameter AlignRight { get; set; } + +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + // Guaranteed to be initialised in BeginProcessing. If it's not, something is cooked in some other way + private ModuleCore.Calendar.Calendar _calendar = null!; + private const string Noun = "Calendar"; + public const string FullName = $"{VerbsCommon.Get}-{Noun}"; + + private DateTime _dateToRender = DateTime.Now; + private int _leftPadding; + + protected override void BeginProcessing() + { + // This is the first point in script execution where we'll have parameters populated + _calendar = new( + MarkedDaySymbol, + DayOfWeekFromString(StartOfWeek) + ); + + if (!string.IsNullOrWhiteSpace(Date)) + { + _dateToRender = DateTime.ParseExact(Date, CultureInfo.CurrentCulture.DateTimeFormat.GetAllDateTimePatterns(), null); + } + + if (AlignRight) + { + _leftPadding = Host.UI.RawUI.WindowSize.Width; + } + } + + /// + /// Returns a from a string. + /// + /// Defaults to the calendars default start of week on null or invalid values. + /// + /// + /// + /// + private static DayOfWeek DayOfWeekFromString(string? dayOfWeek) + { + if (dayOfWeek is null) + { + return ModuleCore.Calendar.Calendar.DefaultStartOfWeek; + } + + return dayOfWeek.ToLowerInvariant() switch + { + "sunday" => DayOfWeek.Sunday, + "monday" => DayOfWeek.Monday, + "tuesday" => DayOfWeek.Tuesday, + "wednesday" => DayOfWeek.Wednesday, + "thursday" => DayOfWeek.Thursday, + "friday" => DayOfWeek.Friday, + "saturday" => DayOfWeek.Saturday, + _ => ModuleCore.Calendar.Calendar.DefaultStartOfWeek + }; + } + + protected override void ProcessRecord() + { + var calendar = _calendar.RenderCalendar(_dateToRender); + + // From _very_ basic testing, string split allocates the same as making a span and iterating over it. + // I'm assuming this is because a lot of the span code is what string.Split() does internally and the rest + // is trivially optimised out. + // Not too fussed currently but it's most likely because Pad methods also allocate a string, so they end up + // equivalent at the end + // OutputCalendarStringSplit(calendar); + OutputCalendar(calendar); + + base.ProcessRecord(); + } + + private void OutputCalendarStringSplit(string calendar) + { + var splitCalendarLines = calendar.Split(Environment.NewLine); + foreach (var splitLines in splitCalendarLines) + { + // Have to output a newline character here + WriteObject(splitLines.PadLeft(_leftPadding)); + } + } + + private void OutputCalendar(string calendar) + { + // Work on a span - we avoid using string.Split() as this allocates on certain runtimes + var calendarSpan = calendar.AsSpan(); + // Get the end of line for the calendar. We use Environment.NewLine as the calendar is built up using + // StringBuilder which uses Environment.NewLine for new lines so this should be consistent across operating systems + var firstNewlineIndex = calendarSpan.IndexOf(Environment.NewLine); + // Calculate how many lines the calendar is from how many new lines we have. + // Sure we could count the number of \r\n are present, but the last line might not end with + // a new line. + // Conveniently enough, int division works out in our favor here + var lines = calendarSpan.Length / firstNewlineIndex; + + // We need to account for the size of the newline so we have an accurate length when outputting each line + // of the calendar below + var newLineLength = firstNewlineIndex + Environment.NewLine.Length; + + for (int i = 0; i < lines; i++) + { + // The start of each calendar line offset starts at a position including the length of the newline, + // so in effect the start of the line is the length of the previous line output, plus however many + // characters the newline was that weren't output as we're taking control there + var startOfLine = i * newLineLength; + // End of the line is exclusive of any newline characters + var endOfLine = startOfLine + firstNewlineIndex; + var calendarLine = calendarSpan[startOfLine .. endOfLine]; + + WriteObject(calendarLine.ToString().PadLeft(_leftPadding)); + } + } + } + + // Was here to test dynamic day names but most people using the console will be using english command names + // so there's at least some assumption that they understand days of the week in english. + // It sucks but it remains at least consistent with the argument being in english as well. + // public class StartDayOfWeekGenerator : IValidateSetValuesGenerator + // { + // private string[] _cached { get; set; } = new string[7]; + // + // public string[] GetValidValues() + // { + // if (_cached is null) + // { + // // _cached = Enumerable.Range(0, r.Next(3, 63)).Select(x => x.ToString()).ToArray(); + // } + // + // return _cached; + // } + // } +} \ No newline at end of file diff --git a/PowershellModule/PowershellModule.csproj b/PowershellModule/PowershellModule.csproj new file mode 100644 index 0000000..d127d89 --- /dev/null +++ b/PowershellModule/PowershellModule.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + PowershellModule + latestmajor + enable + + + + + All + + + + + + + + diff --git a/PowershellModule/build/build.ps1 b/PowershellModule/build/build.ps1 new file mode 100644 index 0000000..1097330 --- /dev/null +++ b/PowershellModule/build/build.ps1 @@ -0,0 +1,48 @@ +param ( + [switch] $release +) + +$wdir = $PSScriptRoot +$module = "PowershellModule" +$output = "$wdir/$module" + +if (Test-Path $output) +{ + Remove-Item $output -Recurse +} + +New-Item $output -ItemType Directory + +# copy the built dlls to this directory +if($release){ + # For release we'll get a bunch of dependencies that we honestly do not need. + # This module is built to run assuming the dotnet 10 runtime exists, and powershell is 7.6 or higher, + # because of this we really don't need the system.* dlls that come with the publish, or the random ass + # newtonsoft.json.dll that comes from ???? publish fucking sucks for libraries even with framework-dependent + # selected as the publish option + Copy-Item -Path "$( $wdir )\..\bin\Release\net10.0\publish\$module.dll" -Destination $output + Copy-Item -Path "$( $wdir )\..\bin\Release\net10.0\publish\ModuleCore.dll" -Destination $output +} else +{ + Copy-Item -Path "$( $wdir )\..\bin\Debug\net10.0\*.dll" -Destination $output +} +#Copy-Item -Path "$( $wdir )\..\bin\Debug\netstandard2.1\*.dll" -Destination $output + +$manifestSplat = @{ + Path = "$output/$module.psd1" + Author = 'Me' + # technically we only need the base module here and it'll locate any dlls it needs + NestedModules = @("$module.dll")#(Get-ChildItem "$output/*.dll" | Select-Object { $_.Name } -ExpandProperty Name) + RootModule = "$module.psm1" +# FunctionsToExport = @('Get-Calendar') + CmdletsToExport = @('Get-Calendar') + # These should all be empty arrays - otherwise they default to wildcard which the generated psd1 will helpfully + # tell you not to do for performance reasons, as the fallback if these are left out is to use wildcard... + FunctionsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} + +New-ModuleManifest @manifestSplat +# We don't actually need content here, it's just so PowerShell has an entry point +New-Item "$output/$module.psm1" -ItemType File diff --git a/PowershellModule/manifest.json b/PowershellModule/manifest.json new file mode 100644 index 0000000..3039b76 --- /dev/null +++ b/PowershellModule/manifest.json @@ -0,0 +1,5 @@ +{ + "_TODO":[ + "add manifest details here maybe idk" + ] +} \ No newline at end of file diff --git a/PowershellModule/meta/PowershellModule.psd1 b/PowershellModule/meta/PowershellModule.psd1 new file mode 100644 index 0000000..e69de29 diff --git a/PowershellModule/meta/PowershellModule.psm1 b/PowershellModule/meta/PowershellModule.psm1 new file mode 100644 index 0000000..e69de29