feat: add Cake build to produce powershell module (#2)

- Add Cake build project
- Change to CPM
- Package bumps for security bumps
- Reorganise solution structure to group harness projects
This commit is contained in:
Scott 2026-07-31 02:15:16 +00:00
commit ecb0a20cbb
27 changed files with 267 additions and 58 deletions

View file

@ -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;
/// <summary>
/// Holds configured day abbreviations, based on <see cref="CultureInfo"/>.
/// <para>
/// 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 <see cref="DayOfWeek"/> enum starting with Sunday.
/// </para>
/// </summary>
private readonly List<string> _daysOfWeekLookup;
/// <summary>
/// Used to work out how to pad columns for each day, based on the longest abbreviated day
/// </summary>
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<string>(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<string>();
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<string> Calendar { get; set; } = [];
public int WeeksInMonth { get; set; }
}

View file

@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>0.0.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,20 @@
using System.Text;
using ModuleCore.Calendar;
namespace ModuleHarness;
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);
}
}

View file

@ -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<string, PSObject> Prompt(string caption, string message, System.Collections.ObjectModel.Collection<FieldDescription> 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<ChoiceDescription> 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; } = "";
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.PowerShell.SDK" />
<PackageReference Include="System.Security.Cryptography.Xml" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowershellModule\PowershellModule.csproj" />
</ItemGroup>
</Project>

View file

@ -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<DayOfWeek>())
{
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);
}
/// <summary>
/// Initialises a PowerShell session and returns an open Runspace
/// </summary>
/// <param name="host"></param>
/// <returns></returns>
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<CommandParameter>? 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);
}
}
}

View file

@ -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;
}
}
/// <summary>
/// Returns a <see cref="DayOfWeek"/> from a string.
/// <para>
/// Defaults to the calendars default start of week on null or invalid values.
/// </para>
/// </summary>
/// <param name="dayOfWeek"></param>
/// <returns></returns>
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;
// }
// }
}

View file

@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>0.0.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>PowershellModule</AssemblyName>
<LangVersion>latestmajor</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PowerShellStandard.Library" >
<PrivateAssets>All</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Management.Automation" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,5 @@
{
"_TODO":[
"add manifest details here maybe idk"
]
}