Utils/date.ps1

Register-GnuUtil @{
    Name        = 'date'
    Category    = 'User and system info'
    Synopsis    = 'Print or set the system date and time.'
    Usage       = @('date [OPTION]... [+FORMAT]', 'date [-u|--utc|--universal] [MMDDhhmm[[CC]YY][.ss]]')
    Description = @('Display date and time in the given FORMAT.')
    Options     = @(
        @{ Short = 'd'; Long = 'date'; Arg = 'STRING'; Help = 'display time described by STRING, not ''now''' }
        @{ Short = 'f'; Long = 'file'; Arg = 'DATEFILE'; Help = 'like --date; once for each line of DATEFILE' }
        @{ Short = 'I'; Long = 'iso-8601'; Arg = 'FMT'; OptionalArg = $true; Help = 'output date/time in ISO 8601 format. FMT=''date'' for date only (the default), ''hours'', ''minutes'', ''seconds'', or ''ns'' for date and time to the indicated precision. Example: 2006-08-14T02:34:56-06:00' }
        @{ Short = 'R'; Long = 'rfc-email'; Help = 'output date and time in RFC 5322 format. Example: Mon, 14 Aug 2006 02:34:56 -0600' }
        @{ Long = 'rfc-3339'; Arg = 'FMT'; Help = 'output date/time in RFC 3339 format. FMT=''date'', ''seconds'', or ''ns'' for date and time to the indicated precision. Example: 2006-08-14 02:34:56-06:00' }
        @{ Short = 'r'; Long = 'reference'; Arg = 'FILE'; Help = 'display the last modification time of FILE' }
        @{ Short = 's'; Long = 'set'; Arg = 'STRING'; Help = 'set time described by STRING (not supported)' }
        @{ Short = 'u'; Long = 'utc'; Key = 'utc'; Help = 'print or set Coordinated Universal Time (UTC)' }
        @{ Long = 'universal'; Key = 'utc'; Help = 'print or set Coordinated Universal Time (UTC)' }
    )
    Notes       = @(
        'FORMAT controls the output. Interpreted sequences are:',
        '',
        ' %% a literal %',
        ' %a abbreviated weekday name (e.g., Sun)',
        ' %A full weekday name (e.g., Sunday)',
        ' %b abbreviated month name (e.g., Jan)',
        ' %B full month name (e.g., January)',
        ' %c date and time (e.g., Thu Mar 3 23:05:25 2005)',
        ' %C century; like %Y, except omit last two digits (e.g., 20)',
        ' %d day of month (e.g., 01)',
        ' %D date; same as %m/%d/%y',
        ' %e day of month, space padded; same as %_d',
        ' %F full date; like %+4Y-%m-%d',
        ' %g last two digits of year of ISO week number (see %G)',
        ' %G year of ISO week number (see %V); normally useful only with %V',
        ' %h same as %b',
        ' %H hour (00..23)',
        ' %I hour (01..12)',
        ' %j day of year (001..366)',
        ' %k hour, space padded ( 0..23); same as %_H',
        ' %l hour, space padded ( 1..12); same as %_I',
        ' %m month (01..12)',
        ' %M minute (00..59)',
        ' %n a newline',
        ' %N nanoseconds (000000000..999999999)',
        ' %p AM or PM',
        ' %P like %p, but lower case',
        ' %q quarter of year (1..4)',
        ' %r 12-hour clock time (e.g., 11:11:04 PM)',
        ' %R 24-hour hour and minute; same as %H:%M',
        ' %s seconds since the Epoch (1970-01-01 00:00 UTC)',
        ' %S second (00..60)',
        ' %t a tab',
        ' %T time; same as %H:%M:%S',
        ' %u day of week (1..7); 1 is Monday',
        ' %U week number of year, with Sunday as first day of week (00..53)',
        ' %V ISO week number, with Monday as first day of week (01..53)',
        ' %w day of week (0..6); 0 is Sunday',
        ' %W week number of year, with Monday as first day of week (00..53)',
        ' %x date representation (e.g., 12/31/99)',
        ' %X time representation (e.g., 23:13:48)',
        ' %y last two digits of year (00..99)',
        ' %Y year',
        ' %z +hhmm numeric time zone (e.g., -0400)',
        ' %:z +hh:mm numeric time zone (e.g., -04:00)',
        ' %::z +hh:mm:ss numeric time zone (e.g., -04:00:00)',
        ' %:::z numeric time zone with : to necessary precision (e.g., -04, +05:30)',
        ' %Z alphabetic time zone abbreviation (e.g., EDT)',
        '',
        'By default, date pads numeric fields with zeroes.',
        'The following optional flags may follow ''%'':',
        '',
        ' - (hyphen) do not pad the field',
        ' _ (underscore) pad with spaces',
        ' 0 (zero) pad with zeros',
        ' ^ use upper case if possible',
        ' # use opposite case if possible',
        '',
        'After any flags comes an optional field width, as a decimal number.',
        '',
        'PowerShell note: names are those of the C locale; setting the date (-s, MMDDhhmm)',
        'is not supported; the TZ variable is ignored (use -u for UTC); -d understands',
        'ISO 8601 and other invariant-culture dates, @SECONDS, now/today/yesterday/tomorrow',
        'and relative items such as "2 days ago" or "+3 weeks", not the full GNU grammar.',
        '%Z falls back to a numeric zone (+0530) when Windows has no abbreviation.',
        'Times have a resolution of 100 ns (%N ends in 00).'
    )
}

$script:GnuDayNames = @('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday')
$script:GnuMonthNames = @('January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December')



function script:Invoke-GnuDatePrint {
    <#
    .SYNOPSIS
    Implementation of date.
    #>

    param([Parameter(Mandatory)][hashtable]$Context)

    $utc = Test-GnuOption -Context $Context -Key 'utc'
    if (Test-GnuOption -Context $Context -Key 'set') { Write-GnuError -Context $Context -Message 'setting the date is not supported'; return }

    $format = $null
    $operands = @($Context.Operands)
    if ($operands.Count -gt 0) {
        if (-not $operands[0].StartsWith('+', [System.StringComparison]::Ordinal)) {
            if ($operands[0] -match '^\d{8}(\d{2}|\d{4})?(\.\d{2})?$') { Write-GnuError -Context $Context -Message 'setting the date is not supported' }
            else { Write-GnuUsageError -Context $Context -Message "invalid date '$($operands[0])'" }
            return
        }
        if ($operands.Count -gt 1) { Write-GnuUsageError -Context $Context -Message "extra operand '$($operands[1])'"; return }
        $format = $operands[0].Substring(1)
    }

    $styles = @()
    if (Test-GnuOption -Context $Context -Key 'iso-8601') {
        $iso = [string](Get-GnuOptionValue -Context $Context -Key 'iso-8601')
        if ([string]::IsNullOrEmpty($iso)) { $iso = 'date' }
        $choice = Select-GnuDateArgument -Context $Context -Value $iso -Option 'iso-8601' -Valid @('date', 'hours', 'minutes', 'seconds', 'ns')
        if ($null -eq $choice) { return }
        $styles += @{ date = '%Y-%m-%d'; hours = '%Y-%m-%dT%H%:z'; minutes = '%Y-%m-%dT%H:%M%:z'; seconds = '%Y-%m-%dT%H:%M:%S%:z'; ns = '%Y-%m-%dT%H:%M:%S,%N%:z' }[$choice]
    }
    if (Test-GnuOption -Context $Context -Key 'rfc-email') { $styles += '%a, %d %b %Y %H:%M:%S %z' }
    if (Test-GnuOption -Context $Context -Key 'rfc-3339') {
        $choice = Select-GnuDateArgument -Context $Context -Value ([string](Get-GnuOptionValue -Context $Context -Key 'rfc-3339')) -Option 'rfc-3339' -Valid @('date', 'seconds', 'ns')
        if ($null -eq $choice) { return }
        $styles += @{ date = '%Y-%m-%d'; seconds = '%Y-%m-%d %H:%M:%S%:z'; ns = '%Y-%m-%d %H:%M:%S.%N%:z' }[$choice]
    }
    if ($null -ne $format) { $styles += $format }
    if ($styles.Count -gt 1) { Write-GnuUsageError -Context $Context -Message 'multiple output formats specified'; return }
    if ($styles.Count -eq 1) { $format = $styles[0] } else { $format = '%a %b %e %H:%M:%S %Z %Y' }

    $sources = @('date', 'file', 'reference') | Where-Object { Test-GnuOption -Context $Context -Key $_ }
    if (@($sources).Count -gt 1) { Write-GnuUsageError -Context $Context -Message 'the options to specify dates for printing are mutually exclusive'; return }

    $times = New-Object 'System.Collections.Generic.List[object]'
    if (Test-GnuOption -Context $Context -Key 'date') {
        $time = ConvertTo-GnuDateTime -Context $Context -Text ([string](Get-GnuOptionValue -Context $Context -Key 'date')) -Utc:$utc
        if ($null -eq $time) { return }
        $times.Add($time)
    }
    elseif (Test-GnuOption -Context $Context -Key 'file') {
        $lines = Get-GnuInputLine -Context $Context -Operand ([string](Get-GnuOptionValue -Context $Context -Key 'file'))
        foreach ($line in @($lines)) {
            $time = ConvertTo-GnuDateTime -Context $Context -Text $line -Utc:$utc
            if ($null -ne $time) { $times.Add($time) }
        }
    }
    elseif (Test-GnuOption -Context $Context -Key 'reference') {
        $reference = [string](Get-GnuOptionValue -Context $Context -Key 'reference')
        $full = Resolve-GnuPath -Path $reference
        if ([System.IO.File]::Exists($full)) { $stamp = [System.IO.File]::GetLastWriteTimeUtc($full) }
        elseif ([System.IO.Directory]::Exists($full)) { $stamp = [System.IO.Directory]::GetLastWriteTimeUtc($full) }
        else { Write-GnuError -Context $Context -Message "$($reference): No such file or directory"; return }
        $times.Add([System.DateTimeOffset]::new([datetime]::SpecifyKind($stamp, [System.DateTimeKind]::Utc)))
    }
    else { $times.Add([System.DateTimeOffset]::Now) }

    foreach ($time in $times) {
        if ($utc) { $time = $time.ToUniversalTime() } else { $time = $time.ToLocalTime() }
        ConvertTo-GnuOutputLine -Text (Format-GnuDate -Time $time -Format $format -Utc:$utc)
    }
}

function script:Select-GnuDateArgument {
    <#
    .SYNOPSIS
    Resolves an argument of --iso-8601 / --rfc-3339 (unambiguous prefixes allowed), or reports it.
    #>

    param([Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][AllowEmptyString()][string]$Value, [Parameter(Mandatory)][string]$Option, [Parameter(Mandatory)][string[]]$Valid)
    $found = @($Valid | Where-Object { $_.StartsWith($Value, [System.StringComparison]::Ordinal) -and $Value.Length -gt 0 })
    if ($found.Count -eq 1) { return $found[0] }
    # No two values share a prefix, so a non-matching value is always invalid (never ambiguous).
    Write-GnuUsageError -Context $Context -Message ("invalid argument '$Value' for '--$Option'`nValid arguments are:" + (($Valid | ForEach-Object { "`n - '$_'" }) -join ''))
    $null
}

function script:ConvertTo-GnuDateTime {
    <#
    .SYNOPSIS
    Parses a date string to a DateTimeOffset, or reports "invalid date".
    #>

    param([Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [switch]$Utc)
    $parsed = ConvertFrom-GnuDateString -Text $Text -Utc:$Utc
    if ($null -eq $parsed) {
        Write-GnuError -Context $Context -Message "invalid date '$Text'"
        return $null
    }
    if ($Utc) { return [System.DateTimeOffset]::new([datetime]::SpecifyKind($parsed, [System.DateTimeKind]::Utc)) }
    [System.DateTimeOffset]::new([datetime]::SpecifyKind($parsed, [System.DateTimeKind]::Local))
}

function script:Get-GnuTimeZoneAbbreviation {
    <#
    .SYNOPSIS
    The time zone abbreviation for %Z (CET, EDT, ...), or a numeric zone like +0530 if none is known.
    #>

    param([Parameter(Mandatory)][System.DateTimeOffset]$Time, [System.TimeZoneInfo]$TimeZone = [System.TimeZoneInfo]::Local)

    $daylight = $TimeZone.IsDaylightSavingTime($Time)
    $name = $TimeZone.StandardName
    if ($daylight) { $name = $TimeZone.DaylightName }
    if ($name -cmatch '^[A-Z]{2,6}$') { return $name }
    $known = @(
        @('^(W\. Europe|Central Europe|Central European|Romance)', 'CET', 'CEST'),
        @('^(E\. Europe|FLE|GTB|Eastern European)', 'EET', 'EEST'),
        @('^(GMT Standard|British|Greenwich Mean)', 'GMT', 'BST'),
        @('^(Eastern|US Eastern)', 'EST', 'EDT'),
        @('^Central (Standard|Daylight)', 'CST', 'CDT'),
        @('^(Mountain|US Mountain)', 'MST', 'MDT'),
        @('^Pacific (Standard|Daylight)', 'PST', 'PDT'),
        @('^Alaskan', 'AKST', 'AKDT'),
        @('^Hawaiian', 'HST', 'HDT'),
        @('^Tokyo', 'JST', 'JDT'),
        @('^India', 'IST', 'IST'),
        @('^AUS Eastern', 'AEST', 'AEDT'),
        @('^(Russian|Moscow)', 'MSK', 'MSD'),
        @('^(UTC|Coordinated Universal)', 'UTC', 'UTC')
    )
    foreach ($entry in $known) {
        if ($TimeZone.StandardName -match $entry[0] -or $TimeZone.Id -match $entry[0]) {
            if ($daylight) { return $entry[2] }
            return $entry[1]
        }
    }
    $offset = Format-GnuDateZone -Offset $Time.Offset -Colons 0
    if ($offset.EndsWith('00', [System.StringComparison]::Ordinal)) { return $offset.Substring(0, 3) }
    $offset
}

function script:Format-GnuDateZone {
    <#
    .SYNOPSIS
    A numeric zone: +hhmm (0 colons), +hh:mm (1), +hh:mm:ss (2), or minimal +hh[:mm] (3).
    #>

    param([Parameter(Mandatory)][timespan]$Offset, [Parameter(Mandatory)][int]$Colons)
    $sign = '+'
    if ($Offset -lt [timespan]::Zero) { $sign = '-'; $Offset = $Offset.Negate() }
    $hours = ([int][Math]::Truncate($Offset.TotalHours)).ToString('00')
    $minutes = $Offset.Minutes.ToString('00')
    switch ($Colons) {
        0 { return "$sign$hours$minutes" }
        1 { return "$sign$($hours):$minutes" }
        2 { return "$sign$($hours):$($minutes):$($Offset.Seconds.ToString('00'))" }
    }
    if ($Offset.Minutes -eq 0) { return "$sign$hours" }
    "$sign$($hours):$minutes"
}

function script:Get-GnuIsoWeek {
    <#
    .SYNOPSIS
    @{ Year; Week } of the ISO 8601 week date.
    #>

    param([Parameter(Mandatory)][datetime]$Date)
    $weekday = (([int]$Date.DayOfWeek + 6) % 7) + 1
    $thursday = $Date.Date.AddDays(4 - $weekday)
    @{ Year = $thursday.Year; Week = [int][Math]::Floor(($thursday.DayOfYear - 1) / 7) + 1 }
}

function script:Format-GnuDate {
    <#
    .SYNOPSIS
    Formats a time with GNU date / strftime sequences, flags (- _ 0 ^ #) and widths.
    #>

    param(
        [Parameter(Mandatory)][System.DateTimeOffset]$Time,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Format,
        [switch]$Utc
    )

    $date = $Time.DateTime
    $builder = New-Object System.Text.StringBuilder
    $i = 0
    while ($i -lt $Format.Length) {
        $match = [regex]::Match($Format.Substring($i), '^%([-_0^#]*)(\d*)(:{0,3})([EO]?)(.)?')
        if ($Format[$i] -ne '%' -or -not $match.Groups[5].Success) {
            [void]$builder.Append($Format[$i])
            $i++
            continue
        }
        $i += $match.Length
        $flags = $match.Groups[1].Value
        $width = -1
        if ($match.Groups[2].Value.Length -gt 0) { $width = [int]$match.Groups[2].Value }
        $colons = $match.Groups[3].Value.Length
        $conversion = $match.Groups[5].Value
        if ($colons -gt 0 -and $conversion -cne 'z') {
            [void]$builder.Append($match.Value)
            continue
        }

        # Number (with default padding and width) or text.
        $number = $null
        $pad = '0'
        $size = 2
        $text = $null
        $swapLower = $false
        switch -CaseSensitive ($conversion) {
            'a' { $text = $script:GnuDayNames[[int]$date.DayOfWeek].Substring(0, 3) }
            'A' { $text = $script:GnuDayNames[[int]$date.DayOfWeek] }
            { $_ -eq 'b' -or $_ -eq 'h' } { $text = $script:GnuMonthNames[$date.Month - 1].Substring(0, 3) }
            'B' { $text = $script:GnuMonthNames[$date.Month - 1] }
            'c' { $text = Format-GnuDate -Time $Time -Format '%a %b %e %H:%M:%S %Y' -Utc:$Utc }
            'C' { $number = [Math]::Floor($date.Year / 100) }
            'd' { $number = $date.Day }
            'D' { $text = Format-GnuDate -Time $Time -Format '%m/%d/%y' -Utc:$Utc }
            'e' { $number = $date.Day; $pad = ' ' }
            'F' { $text = Format-GnuDate -Time $Time -Format '%Y-%m-%d' -Utc:$Utc }
            'g' { $number = (Get-GnuIsoWeek -Date $date).Year % 100 }
            'G' { $number = (Get-GnuIsoWeek -Date $date).Year; $size = 4 }
            'H' { $number = $date.Hour }
            'I' { $number = (($date.Hour + 11) % 12) + 1 }
            'j' { $number = $date.DayOfYear; $size = 3 }
            'k' { $number = $date.Hour; $pad = ' ' }
            'l' { $number = (($date.Hour + 11) % 12) + 1; $pad = ' ' }
            'm' { $number = $date.Month }
            'M' { $number = $date.Minute }
            'n' { $text = "`n" }
            'N' {
                $digits = (($date.Ticks % 10000000) * 100).ToString('000000000')
                if ($width -gt 0 -and $width -lt 9) { $digits = $digits.Substring(0, $width) }
                $text = $digits
                $width = -1
            }
            'p' { $text = 'AM'; if ($date.Hour -ge 12) { $text = 'PM' }; $swapLower = $true }
            'P' { $text = 'am'; if ($date.Hour -ge 12) { $text = 'pm' } }
            'q' { $number = [Math]::Floor(($date.Month - 1) / 3) + 1; $size = 1 }
            'r' { $text = Format-GnuDate -Time $Time -Format '%I:%M:%S %p' -Utc:$Utc }
            'R' { $text = Format-GnuDate -Time $Time -Format '%H:%M' -Utc:$Utc }
            's' { $number = $Time.ToUnixTimeSeconds(); $size = 1 }
            'S' { $number = $date.Second }
            't' { $text = "`t" }
            'T' { $text = Format-GnuDate -Time $Time -Format '%H:%M:%S' -Utc:$Utc }
            'u' { $number = (([int]$date.DayOfWeek + 6) % 7) + 1; $size = 1 }
            'U' { $number = [Math]::Floor(($date.DayOfYear - 1 + 7 - [int]$date.DayOfWeek) / 7) }
            'V' { $number = (Get-GnuIsoWeek -Date $date).Week }
            'w' { $number = [int]$date.DayOfWeek; $size = 1 }
            'W' { $number = [Math]::Floor(($date.DayOfYear - 1 + 7 - (([int]$date.DayOfWeek + 6) % 7)) / 7) }
            'x' { $text = Format-GnuDate -Time $Time -Format '%m/%d/%y' -Utc:$Utc }
            'X' { $text = Format-GnuDate -Time $Time -Format '%H:%M:%S' -Utc:$Utc }
            'y' { $number = $date.Year % 100 }
            'Y' { $number = $date.Year; $size = 1 }
            'z' { $text = Format-GnuDateZone -Offset $Time.Offset -Colons $colons }
            'Z' {
                if ($Utc) { $text = 'UTC' } else { $text = Get-GnuTimeZoneAbbreviation -Time $Time }
                $swapLower = $true
            }
            '%' { $text = '%' }
            default { $text = $match.Value }
        }

        if ($null -ne $number) {
            if ($flags.Contains('-')) { $pad = '' }
            elseif ($flags.Contains('_')) { $pad = ' ' }
            elseif ($flags.Contains('0')) { $pad = '0' }
            if ($width -lt 0) { $width = $size }
            $text = [string][long]$number
            if ($pad.Length -gt 0 -and $text.Length -lt $width) { $text = $text.PadLeft($width, [char]$pad) }
        }
        else {
            if ($flags.Contains('^')) { $text = $text.ToUpperInvariant() }
            elseif ($flags.Contains('#')) {
                if ($swapLower) { $text = $text.ToLowerInvariant() } else { $text = $text.ToUpperInvariant() }
            }
            if ($width -gt $text.Length -and -not $flags.Contains('-')) {
                $padChar = ' '
                if ($flags.Contains('0')) { $padChar = '0' }
                $text = $text.PadLeft($width, [char]$padChar)
            }
        }
        [void]$builder.Append($text)
    }
    $builder.ToString()
}