RussianCalendar.psm1

#Const
$datasetUrl = 'https://data.gov.ru/api/json/dataset/7708660670-proizvcalendar'
$ApiKeyPath = "$Env:APPDATA\PowerShell\RussianCalendar\ApiKey"

$YearFirstWorkDays = &{
    $Path = "$Env:APPDATA\PowerShell\RussianCalendar\YearFirstWorkDays"
    if (!(Test-Path -Path $Path)) {New-Item -Path $Path -ItemType File -Force}
    $Content = Get-Content -Raw -Path $Path
    $NewHashTable = @{}
    if ($Content) {
        $HashTable = ConvertFrom-StringData -StringData $Content
        $HashTable.Keys | % {
            $NewHashTable[$_] = [DateTime]::FromFileTime($HashTable[$_])
        }
    }
    $NewHashTable
}

#Var
$Param = @{
    WorkSchedule = @(5,2)
    FirstWorkDay = $null
    YearFirstWorkDays = $YearFirstWorkDays
    CultureInfo = $(Get-Culture)
    FirstDayOfWeek = $((Get-Culture).DateTimeFormat.FirstDayOfWeek)
    ColorScheme = $(Invoke-Expression -Command (Get-Content -Raw -Path "$PSScriptRoot\ColorScheme"))
    Padding = @{
        Char = $([char]32)
        Length = 5
        Padding = 4
        Align = 'Right'
    }
    
}
$HoliDays = @{
    Array = @()
    Cache = @{}
    Hash = @{}
}
$LocalStorage = @{
    MeasureCalendarYearCounter = @{
        Weekday = 1
        Weekend = 1
        Switch = 0
    }
}

#-------------------------------------------------------------------------------------------------------
Function Set-CalendarApiKey {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)][string]$ApiKey
    )
    New-Item -Path $ApiKeyPath -ItemType File -Force | Out-Null
    Set-Content -Path $ApiKeyPath -Value $ApiKey -Force
}

Function Set-CalendarFirstWorkDays {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)][int]$Year = (Get-Date).Year,
        [Parameter(Mandatory=$true)][DateTime]$Date
        )
        $YearFirstWorkDays = $Param['YearFirstWorkDays']
    }
Function Get-CalendarApiKey {
    [CmdletBinding()]
    $ApiKey = Get-Content -Path $ApiKeyPath -ErrorAction SilentlyContinue
    if (!$ApiKey) {
        Write-Verbose -Message "visit https://data.gov.ru/get-api-key and get the key for the api and run Set-CalendarApiKey" -Verbose
        Write-Verbose -Message "key not found in $ApiKeyPath"
        Set-CalendarApiKey
        $ApiKey = Get-Content -Path $ApiKeyPath -ErrorAction SilentlyContinue
    }
    if ($ApiKey) {
        Write-Verbose -Message "key was found in $ApiKeyPath"
        $ApiKey
    } else {
        Write-Verbose -Message "key not found in $ApiKeyPath"
    }
}
Function Find-CalendarApiVersion {
    [CmdletBinding()]
    param(
        [switch]$All
    )
    $ApiKey = Get-CalendarApiKey
    $Responce = Invoke-RestMethod -Uri "$datasetUrl/version/" -Body @{access_token = $ApiKey}
    $Versions = $Responce | Sort-Object -Property @{Expression = {[DateTime]::ParseExact($_.created,'yyyyMMddTHHmmss',$null)}} -Descending
    if ($Versions) {
        if ($All) {$Versions.created} else {$Versions[0].created}
    }
}
Function Update-CalendarFile {
    [CmdletBinding()]
    param(
        [string]$Version = $(Find-CalendarApiVersion)
    )
    $ScopeIsAllUsers = $Env:PSModulePath.Split(';') -match 'Program.Files|Windows.System32' | ? {$PSScriptRoot -like "$_*"}
    if ($ScopeIsAllUsers) {
        Write-Verbose -Message "Scope is AllUsers"
        $WindowsPrincipal = New-Object -TypeName System.Security.Principal.WindowsPrincipal -ArgumentList @([System.Security.Principal.WindowsIdentity]::GetCurrent())
        if (!($WindowsPrincipal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))) {
            throw {'Requires: RunAsAdministrator'}
        }
    }
    $ApiKey = Get-CalendarApiKey
    $FilePath = "$PSScriptRoot\CalendarFiles\$Version.json"
    try {
        $Responce = Invoke-RestMethod -Uri "$datasetUrl/version/$Version/content/" -Body @{access_token = $ApiKey}
        if ($?) {
            Write-Verbose -Message "Responced: $([bool]$Responce)"
            Write-Verbose -Message "Saving path: $FilePath"
            $Responce | ConvertTo-Json | Out-File -FilePath $FilePath -Force
            Import-CalendarFile | Out-Null
        }
    } catch {
        Write-Verbose -Message "Responce error"
    }
}
Function Import-CalendarFile {
    [CmdletBinding()]
    param()
    if ($HoliDays['Hash'].Count -and $HoliDays['Array'].Count) {
        Write-Verbose -Message "Using: Saved HoliDays Variable"
        $HoliDays
    } else {        
        $CalendarFile = Get-ChildItem -Path "$PSScriptRoot\CalendarFiles\*.json" | ? {$_.BaseName -match '^\d{8}T\d{6}$'} | Sort-Object -Property @{Expression = {[DateTime]::ParseExact($_.BaseName,'yyyyMMddTHHmmss',$null)}} -Descending | Select-Object -First 1
        Write-Verbose -Message "Using: $CalendarFile"
        (ConvertFrom-Json -InputObject (Get-Content -Path $CalendarFile -Raw)) | ConvertTo-Csv | ConvertFrom-Csv -Header Year,1,2,3,4,5,6,7,8,9,10,11,12 | Select-Object -Skip 1 -PipelineVariable Object | % {
            Write-Debug -Message $(('=')*80)
            [int]$Year = $Object.Year
            $HoliDays['Hash'][$Year] = @{}
            1..12 | Select-Object -PipelineVariable Month | % {
                $HoliDays['Hash'][$Year][$Month] = @{}
                $Days = $Object.psobject.Properties[$Month].Value -split ','
                Write-Debug -Message "Year: $Year, Month: $Month, $('Days: ' + ($Days -join ','))"
                $Days | Select-Object -PipelineVariable Day | % {
                    [int]$DayInt = $Day -replace '\D'
                    [string]$DayView = $Day
                    $HoliDays['Hash'][$Year][$Month][$DayInt] = $DayView
                    $HoliDays['Array'] += [DateTime]::New($Year,$Month,$DayInt)
                }
            }
        }
        $HoliDays
    }
}
#-------------------------------------------------------------------------------------------------------
Import-CalendarFile
#-------------------------------------------------------------------------------------------------------
Function Get-DateList {
    param(
        [Parameter(Mandatory=$true)][DateTime]$Start,
        [Parameter(Mandatory=$true)][DateTime]$End,
        [TimeSpan]$Step = [TimeSpan]::FromDays(1)
    )
    while ($Start -le $End) {$Start; $Start = $Start + $Step}
}
Function Get-WeekOfYear {
    param(
        [Parameter(ValueFromPipeline=$true)][System.DateTime]$DateTime = (Get-Date),
        [System.Globalization.CultureInfo]$CultureInfo = $Param['CultureInfo'],
        [System.DayOfWeek]$FirstDayOfWeek = $Param['FirstDayOfWeek']
    )
    $PSBoundParameters.Keys.Where({$Param.ContainsKey($_)}).ForEach({$Param[$_] = $PSBoundParameters[$_]})
    $CalendarWeekRule = $CultureInfo.DateTimeFormat.CalendarWeekRule
    $CultureInfo.Calendar.GetWeekOfYear($DateTime, $CalendarWeekRule, $FirstDayOfWeek)
}
Function Get-DaysOfWeek {
    param(
        [System.DayOfWeek]$FirstDayOfWeek = $Param['FirstDayOfWeek']
    )
    $PSBoundParameters.Keys.Where({$Param.ContainsKey($_)}).ForEach({$Param[$_] = $PSBoundParameters[$_]})
    [System.Collections.ArrayList]$DaysOfWeekDefault = [System.DayOfWeek[]](0..6)
    [System.Collections.ArrayList]$DaysOfWeek = @()
    $DaysOfWeek.AddRange($DaysOfWeekDefault.GetRange($DaysOfWeekDefault.IndexOf($FirstDayOfWeek),(7-$DaysOfWeekDefault.IndexOf($FirstDayOfWeek))))
    $DaysOfWeek.AddRange($DaysOfWeekDefault.GetRange(0,$DaysOfWeekDefault.IndexOf($FirstDayOfWeek)))
    $LocalStorage['DaysOfWeek'] = $DaysOfWeek
    $DaysOfWeek
}
Function Format-StringPadding {
    param(
        [Parameter(Mandatory=$true,ValueFromPipeline=$true)][String[]]$String,
        [char]$Char = $([char]32),
        [int]$Length,
        [int]$Padding,
        [ValidateSet('Left','Right','Center')][String]$Align = 'Right'
    )
    Begin {}
    Process {
        [int]$HalfStringLength = [System.Math]::Floor($String.Length / 2)
        [int]$HalfLength = [System.Math]::Floor($Length / 2)
        if (!$Padding) {$Padding = [int]$HalfLength - $HalfStringLength}
        if ($Align -eq 'Right') {$String.PadLeft($Padding,$Char).PadRight($Length,$Char)}
        if ($Align -eq 'Left') {$String.PadRight($Padding,$Char).PadLeft($Length,$Char)}
        if ($Align -eq 'Center') {
            $Padding = [System.Math]::Floor(($Length - $String.Length) / 2) + ($String.Length * 2)
            $String.PadRight($Padding,$Char).PadLeft($Length,$Char)
        }
    }
    End {}
}
Function ConvertTo-StringData {
    param([HashTable]$HashTable)
    $HashTable.Keys.ForEach({"$_ = $($HashTable[$_])"})
}
#-------------------------------------------------------------------------------------------------------
Function Measure-CalendarYear {
    [CmdLetBinding()]
    param(
        [int]$Year = (Get-Date).Year,
        [System.DayOfWeek]$FirstDayOfWeek = $Param['FirstDayOfWeek'],
        [ValidateCount(2,2)][int[]]$WorkSchedule = $Param['WorkSchedule'],
        [DateTime]$FirstWorkDay
    )
    $PSBoundParameters.Keys.Where({$Param.ContainsKey($_)}).ForEach({$Param[$_] = $PSBoundParameters[$_]})
    if ($WorkSchedule -and !$FirstWorkDay) {
        if ($WorkSchedule[0] -eq 5 -and $WorkSchedule[1] -eq 2) {
            $FirstWorkDay = Get-DateList -Start ([DateTime]::new($Year,1,1)) -End ([DateTime]::new($Year,1,7)) | ? {$_.DayOfWeek -eq $FirstDayOfWeek}
        } else {
            Write-Verbose -Verbose -Message "Parameter FirstWorkDay is not set and WorkSchedule is not 5/2. Specify FirstWorkDay or read more about `$PSDefaultParameterValues"
            if (!$FirstWorkDay) {
                $FirstWorkDay = [DateTime]::new($Year,1,1)
            }
        }
    }
    $DateNow = Get-Date
    $Counter = $LocalStorage['MeasureCalendarYearCounter'].Clone()
    Get-DateList -Start $([DateTime]::new($Year,1,1)) -End $([DateTime]::new($Year,12,31)) | Select-Object -Property @(
        ,@{Name = 'DateTime'; Expression = {$_}}
        ,'Year'
        ,'Month'
        ,'Day'
        ,@{Name = 'HoliDay'; Expression = {$HoliDays['Array'].Contains($_)}}
        ,@{Name = 'ToDay'; Expression = {$_.Date -eq $DateNow.Date}}
        ,'DayOfWeek'
        ,'DayOfYear'
        ,@{Name = 'WeekOfYear'; Expression = {Get-WeekOfYear -DateTime $_.Date -FirstDayOfWeek $FirstDayOfWeek}}
        ,@{Name = 'ScheduledWeekend'; Expression = {
            if ($_.Date -ge $FirstWorkDay.Date) {
                if ($Counter['Switch'] -eq 0) {
                    if ($Counter['Weekday'] -le $WorkSchedule[0]) {$false; $Counter['Weekday'] += 1}
                    if ($Counter['Weekday'] -gt $WorkSchedule[0]) {$Counter['Weekday'] = 1; $Counter['Switch'] = 1}
                } elseif ($Counter['Switch'] -eq 1) {
                    if ($Counter['Weekend'] -le $WorkSchedule[1]) {$true; $Counter['Weekend'] += 1}
                    if ($Counter['Weekend'] -gt $WorkSchedule[1]) {$Counter['Weekend'] = 1; $Counter['Switch'] = 0}
                } else {$null}
            }
        }}
    ) | Select-Object -Property @(
        ,'*'
        ,@{Name = 'DayView'; Expression = {
            if ($_.HoliDay) {
                $HoliDays['Hash'][$_.Year][$_.Month][$_.Day].ToString()
            } else {
                $_.Day.ToString()
            }
        }}
    ) | Select-Object -Property @(
        ,'*'
        ,@{Name = 'Color'; Expression = {
            $ColorParam = $Param['ColorScheme']['Default'].Clone()
            if ($_.ScheduledWeekend) {
                if ($Param['ColorScheme']['ScheduledWeekend'].ContainsKey('Foreground')) {$ColorParam['Foreground'] = $Param['ColorScheme']['ScheduledWeekend']['Foreground']}
                if ($Param['ColorScheme']['ScheduledWeekend'].ContainsKey('Background')) {$ColorParam['Background'] = $Param['ColorScheme']['ScheduledWeekend']['Background']}
                if (!$_.HoliDay) {
                    if ($Param['ColorScheme']['ScheduledWeekendIsNotHoliDay'].ContainsKey('Foreground')) {$ColorParam['Foreground'] = $Param['ColorScheme']['ScheduledWeekendIsNotHoliDay']['Foreground']}
                    if ($Param['ColorScheme']['ScheduledWeekendIsNotHoliDay'].ContainsKey('Background')) {$ColorParam['Background'] = $Param['ColorScheme']['ScheduledWeekendIsNotHoliDay']['Background']}
                }
            }
            if ($_.HoliDay) {
                if ($Param['ColorScheme']['HoliDay'].ContainsKey('Foreground')) {$ColorParam['Foreground'] = $Param['ColorScheme']['HoliDay']['Foreground']}
                if ($Param['ColorScheme']['HoliDay'].ContainsKey('Background')) {$ColorParam['Background'] = $Param['ColorScheme']['HoliDay']['Background']}
            }
            if ($_.ToDay) {
                if ($Param['ColorScheme']['ToDay'].ContainsKey('Foreground')) {$ColorParam['Foreground'] = $Param['ColorScheme']['ToDay']['Foreground']}
                if ($Param['ColorScheme']['ToDay'].ContainsKey('Background')) {$ColorParam['Background'] = $Param['ColorScheme']['ToDay']['Background']}
            }
            $ColorParam
        }}
    ) | Tee-Object -Variable Cache
    $HoliDays['Cache'][$Year] = $Cache
}

Function Show-Calendar {
    [CmdLetBinding()]
    param(
        [int]$Year = (Get-Date).Year,
        [int]$Month,
        [System.DayOfWeek]$FirstDayOfWeek = $Param['FirstDayOfWeek'],
        [ValidateCount(2,2)][int[]]$WorkSchedule = $Param['WorkSchedule'],
        [DateTime]$FirstWorkDay,
        [Switch]$ShowWeeks
        
    )
    $PSBoundParameters.Keys.Where({$Param.ContainsKey($_)}).ForEach({$Param[$_] = $PSBoundParameters[$_]})
    $CalendarYearParam = @{Year = $Year}
    $PSBoundParameters.Keys.Where({@('FirstDayOfWeek','WorkSchedule','FirstWorkDay').Contains($_)}).ForEach({$CalendarYearParam[$_] = $PSBoundParameters[$_]})
    if ($CalendarYearParam.Count -eq 1) {
        if ($HoliDays['Cache'].ContainsKey($Year)) {
            Write-Verbose -Message "Using Cache"
            $CalendarYear = $HoliDays['Cache'][$Year]
        } else {
            Write-Verbose -Message "Cache is empty. Need measuring"
            $CalendarYear = Measure-CalendarYear @CalendarYearParam
        }
    } else {
        Write-Verbose -Message "PSBoundParameters is declared. Need measuring"
        $CalendarYear = Measure-CalendarYear @CalendarYearParam
    }
    Write-Verbose -Message "PSBoundParameters: $($PSBoundParameters | ConvertTo-Json)"
    Write-Verbose -Message "CalendarYearParam: $($CalendarYearParam | ConvertTo-Json)"

    $TitleColor = $Param['ColorScheme']['Title']
    $HeaderColor = $Param['ColorScheme']['Header']
    $SpecialCharsColor = $Param['ColorScheme']['SpecialChars']
    
    Function Optimize-Month ($CalendarYear,$Month) {
        $MonthGroups = $CalendarYear | Group-Object -Property Month -AsHashTable
        $MonthGroup = $MonthGroups[$Month]
        $WeekGroups = $MonthGroup | Group-Object -Property WeekOfYear -AsHashTable
        $WeekGroupKeys = $WeekGroups.Keys | Sort-Object
        Write-Verbose -Message "Weeks: $($WeekGroupKeys -join ',')"
        $FirstWeekNumber = $WeekGroupKeys[0]
        $LastWeekNumber = $WeekGroupKeys[-1]

        if (($WeekGroups[$FirstWeekNumber]).Count -ne 7) {
            $WeekGroup = @([pscustomobject][ordered]@{DayView = [string][char]183*2; Color = $SpecialCharsColor}) * (7 - $WeekGroups[$FirstWeekNumber].Count)
            $WeekGroups[$FirstWeekNumber] | % {$WeekGroup += $_}
            $WeekGroups[$FirstWeekNumber] = $WeekGroup
        }

        if (($WeekGroups[$LastWeekNumber]).Count -ne 7) {
            $WeekGroup = $WeekGroups[$LastWeekNumber]
            @([pscustomobject][ordered]@{DayView = [string][char]183*2; Color = $SpecialCharsColor}) * (7 - $WeekGroups[$LastWeekNumber].Count) | % {$WeekGroup += $_}
            $WeekGroups[$LastWeekNumber] = $WeekGroup
        }

        $WeekGroups
    }

    Write-Host ' ' -BackgroundColor Black -ForegroundColor Black
    
    $Padding = $Param['Padding']
    $TitlePadding = $Padding.Clone()
    if (!$ShowWeeks) {$ColNum = 7} else {$ColNum = 8}
    
    if ($Month) {
        $TitlePadding['Length'] *= $ColNum
        $OptimizedMonth = Optimize-Month -CalendarYear $CalendarYear -Month $Month
        Write-Host @TitleColor (Format-StringPadding @TitlePadding -String ([DateTime]::New($Year,$Month,1)).ToString('MMMM yyyy'))
        Write-Host @HeaderColor (-join ((Get-DaysOfWeek) -replace @('(.{3}).*','$1') | Format-StringPadding -Length 5 -Padding 4))
        Write-Host @SpecialCharsColor ((' --- ') * $ColNum)
        $OptimizedMonth.Keys | Sort-Object | % {
            $OptimizedMonth[$_] | % {
                $Color = $_.Color
                Write-Host @Color (Format-StringPadding @Padding -String $_.DayView) -NoNewline
            }
            if ($ShowWeeks) {Write-Host (Format-StringPadding @Padding -String $_) -NoNewline -ForegroundColor DarkGray}
            Write-Host '' -BackgroundColor Black -ForegroundColor Black
        }
    } else {
        $TitlePadding['Length'] *= 7
        $EmptyObject = @([pscustomobject][ordered]@{DayView = [string][char]32; Color = $SpecialCharsColor})
        @(@(1..3),@(4..6),@(7..9),@(10..12)) | % {
            $Quarter = $_ | % {Optimize-Month -CalendarYear $CalendarYear -Month $_}
            $MaxWeekCount = ($Quarter | Measure-Object -Property Count -Maximum).Maximum
            $QuarterWeeks = @{
                0 = $Quarter[0].Keys | Sort-Object
                1 = $Quarter[1].Keys | Sort-Object
                2 = $Quarter[2].Keys | Sort-Object
            }
            Write-Host @TitleColor (-join (0..2 | % {$Quarter[$_][$QuarterWeeks[$_]][0][6].DateTime.ToString('MMMM')} | Format-StringPadding @TitlePadding))
            Write-Host @HeaderColor (-join (((Get-DaysOfWeek) -replace @('(.{3}).*','$1') | Format-StringPadding -Length 5 -Padding 4))*3)
            Write-Host @SpecialCharsColor ((' --- ') * 21)
            0..($MaxWeekCount) | % {
                $Row = @()
                $Row += try {$Quarter[0][$QuarterWeeks[0][$_]]} catch {$EmptyObject * 7}
                $Row += try {$Quarter[1][$QuarterWeeks[1][$_]]} catch {$EmptyObject * 7}
                $Row += try {$Quarter[2][$QuarterWeeks[2][$_]]} catch {$EmptyObject * 7}
                $Row | % {
                    $_ | % {
                        $Color = $_.Color
                        Write-Host @Color (Format-StringPadding @Padding -String $_.DayView) -NoNewline
                    }
                    Write-Host @SpecialCharsColor '' -NoNewline
                }
                Write-Host ''
            }
        }
    }

    Write-Host ' ' -BackgroundColor Black -ForegroundColor Black
}

Set-Alias -Name cal -Value Show-Calendar -Option ReadOnly -Scope Global