PSMFAttendance.psm1

# The location of the file that we'll store the Access Token SecureString
# which cannot/should not roam with the user.
[string] $script:MFCredentialPath = [System.IO.Path]::Combine(
    [System.Environment]::GetFolderPath('LocalApplicationData'),
    'krymtkts',
    'PSMFAttendance',
    'credential')

$script:Mfc = 'https://attendance.moneyforward.com/'
$script:LocaleEN = New-Object System.Globalization.CultureInfo("en-US") #English (US) Locale

class MFCredentialStore {
    [string] $OfficeAccountName
    [string] $AccountNameOrEmail
    [SecureString] $Password
    MFCredentialStore(
        [string] $OfficeAccountName,
        [string] $AccountNameOrEmail,
        [SecureString] $Password
    ) {
        $this.OfficeAccountName = $OfficeAccountName
        $this.AccountNameOrEmail = $AccountNameOrEmail
        $this.Password = $Password
    }
}

$script:MFCredential = $null
$script:MFSession = $null
$script:MySession = $null

function Set-MFAuthentication {
    [CmdletBinding(SupportsShouldProcess)]
    [CmdletBinding(DefaultParameterSetName = 'Cache')]
    param(
        [Parameter(Mandatory = $false, ParameterSetName = 'Cache')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Session')]
        [string] $OfficeAccountName,
        [Parameter(Mandatory = $false, ParameterSetName = 'Cache')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Session')]
        [PSCredential] $Credential
    )

    if (-not $OfficeAccountName) {
        $OfficeAccountName = Read-Host -Prompt "Please provide your MF office account.`n"
    }
    if (-not $Credential) {
        $message = 'Please provide your MF user and password.'
        if ($PsCmdlet.ParameterSetName -eq "Cache") {
            $message = $message + 'These credential is being cached across PowerShell sessions. To clear caching, call Clear-MFAuthentication.'
        }
        $Credential = Get-Credential -Message $message
    }
    $script:MFCredential = [MFCredentialStore]::new(
        $OfficeAccountName, $Credential.UserName, $Credential.Password)

    if ( $PsCmdlet.ParameterSetName -ne "Cache") {
        return;
    }
    $store = @{
        OfficeAccountName  = $OfficeAccountName;
        AccountNameOrEmail = $Credential.UserName;
        Password           = $Credential.Password | ConvertFrom-SecureString;
    }

    if ($PSCmdlet.ShouldProcess($script:MFCredentialPath)) {
        New-Item -Path $script:MFCredentialPath -Force | Out-Null
        $store | ConvertTo-Json -Compress | Set-Content -Path $script:MFCredentialPath -Force
    }
}

function Get-MFAuthentication {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
    )

    if ( $script:MFCredential) {
        return
    }
    $content = Get-Content -Path $script:MFCredentialPath -ErrorAction Ignore
    if ([String]::IsNullOrEmpty($content)) {
        Set-MFAuthentication
    }
    else {
        try {
            $cred = $content | ConvertFrom-Json
            $script:MFCredential = [MFCredentialStore]::new(
                $cred.OfficeAccountName, $cred.AccountNameOrEmail, ($cred.PassWord | ConvertTo-SecureString)
            )
            return
        }
        catch {
            Write-Error "Invalid SecureString stored for this module. Use Set-MFAuthentication to update it."
        }
    }
}

function Clear-MFAuthentication {
    [CmdletBinding(SupportsShouldProcess)]
    param(
    )

    $script:MFCredential = $null
    Remove-Item -Path $script:MFCredentialPath -Force -ErrorAction SilentlyContinue
}

function Get-DateForDisplay {
    [CmdletBinding()]
    param (
        [Parameter(HelpMessage = 'The value of date time to format.')]
        [DateTime]
        $Date = (Get-Date)
    )

    $Date.ToLocalTime().ToString("yyyy-MM-dd(ddd) HH:mm:ss K", $script:LocaleEN)
}


function Find-CsrfToken {
    [CmdletBinding()]
    [OutputType([String])]
    param (
        [Parameter(Mandatory)]
        [String]
        $Content
    )

    process {
        $Content -match '<meta name="csrf-token" content="(?<token>\S+)" />' | Out-Null
        $CsrfToken = $matches['token']
        if (!$CsrfToken) {
            throw "Cannot scrape csrf token."
        }
        return $CsrfToken
    }
}

function Get-RecordTime {
    process {
        $Now = Get-Date -AsUTC
        [PSCustomObject]@{
            Raw        = $Now
            Date       = $Now.ToString("MM/dd/yyyy")
            RecordTime = $Now.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
        }
    }
}

function Connect-MFCloudAttendance {
    begin {
        Write-Host "Trying to connect MF Attendance..."
    }

    process {
        $Login = "$script:Mfc/email_employee_session/"
        $NewSession = "$Login/new"
        $NewSessionParams = @{
            Method          = 'Get'
            Uri             = $NewSession
            SessionVariable = 'script:MySession'
        }
        Write-Verbose ($NewSessionParams | Out-String)
        try {
            $Res = Invoke-WebRequest @NewSessionParams
            $CsrfToken = Find-CsrfToken -Content $Res.Content
            Write-Verbose $CsrfToken
        }
        catch {
            Write-Error "Failed to connect $Login . $_"
            Write-Verbose ($NewSessionParams | Out-String)
            throw
        }

        $LoginParams = @{
            Method     = 'Post'
            Uri        = $Login
            WebSession = $script:MySession
            Body       = @{
                authenticity_token                             = $CsrfToken
                'employee_session_form[office_account_name]'   = $script:MFCredential.OfficeAccountName
                'employee_session_form[account_name_or_email]' = $script:MFCredential.AccountNameOrEmail
                'employee_session_form[password]'              = $script:MFCredential.Password | ConvertFrom-SecureString -AsPlainText
            }
        }
        try {
            $Res = Invoke-WebRequest @LoginParams
            $TmpMFSession = [PSCustomObject]@{
                SessionId  = ''
                EmployeeId = ''
                LocationId = ''
            }
            $TmpMFSession.SessionId = $MySession.Cookies.GetCookies($script:Mfc).Value
            $Res.Content -match '<meta.+content="(?<uid>\d+)"' | Out-Null # dirty hack. id is numeric value.
            $TmpMFSession.EmployeeId = $matches['uid']
            if (!$TmpMFSession.EmployeeId) {
                throw "Cannot scrape employee id from response of $Login."
            }
            $Res.Content -match '<input (data-target="my-page--web-time-recorders.inputOfficeLocationId"|id="web_time_recorder_form_office_location_id").+value="(?<lid>\d+)"' | Out-Null # dirty hack. id is numeric value.
            $TmpMFSession.LocationId = $matches['lid']
            if (!$TmpMFSession.EmployeeId) {
                throw "Cannot scrape employee id from response of $Login."
            }
            $script:MFSession = $TmpMFSession
        }
        catch {
            Write-Error "Failed to login $Login. $_"
            throw
        }
    }

    end {
        if ($script:MFSession) {
            Write-Host "Login succeed. $(Get-DateForDisplay)"
        }
        else {
            Write-Error "Login failed. $(Get-DateForDisplay)"
        }
    }
}

function Find-AttendanceRecord {
    [CmdletBinding()]
    [OutputType([Hashtable])]
    param (
        [Parameter(Mandatory)]
        [String]
        $Content
    )

    process {
        $DatePattern = [regex]'<span class="attendance-table-text-day">(?<date>\d+)</span>'
        $DateMatches = $DatePattern.Matches($Content)
        if (!$DateMatches) {
            throw "No attendance found."
        }
        Write-Verbose ($DateMatches | Out-String)
        $Dates = $DateMatches | ForEach-Object { [int]$_.Groups['date'].Value } | Sort-Object
        $TimePattern = [regex]'<td class="column-attendance attendance-text-align-center attendance-table-column-">(?<time>(\d{2}:\d{2})?)</td>'
        $Times = $TimePattern.Matches($Content)
        if (!$Times) {
            throw "Cannot scrape attendance time entries."
        }
        $Result = @{}
        $i = 0
        foreach ($Date in $Dates) {
            $Result.Add($Date, [PSCustomObject]@{
                    Start = $Null
                    End   = $Null
                })
            if ($Times[$i] -and $Times[$i].Groups['time']) {
                $Result[$Date].Start = $Times[$i].Groups['time'].Value
            }
            $i = $i + 1
            if ($Times[$i] -and $Times[$i].Groups['time']) {
                $Result[$Date].End = $Times[$i].Groups['time'].Value
            }
            $i = $i + 1
        }
        return $Result
    }
}

function Get-AttendanceRecord {
    [CmdletBinding()]
    param (
    )

    begin {
        Write-Verbose ($script:MySession | Out-String)
        Write-Verbose ($script:MFSession | Out-String)
    }

    process {
        $MyPage = "$script:Mfc/my_page"
        $NewSessionParams = @{
            Method     = 'Get'
            Uri        = $MyPage
            WebSession = $script:MySession
        }
        Write-Verbose ($NewSessionParams | Out-String)
        try {
            $Res = Invoke-WebRequest @NewSessionParams
            $CsrfToken = Find-CsrfToken -Content $Res.Content
            Write-Verbose $CsrfToken
        }
        catch {
            Write-Error "Failed to connect $MyPage. $_"
            throw
        }
        $Attendances = "$MyPage/attendances"
        $LoginParams = @{
            Method     = 'Get'
            Uri        = $Attendances
            WebSession = $MySession
        }
        Write-Verbose ($LoginParams | Out-String)
        try {
            $Res = Invoke-WebRequest @LoginParams
            Write-Host "Succeed to get content. $(Get-DateForDisplay (Get-Date))"
            if (!$Res) {
                Write-Error "Failed to get content from $Attendances."
                return
            }
            $Records = Find-AttendanceRecord -Content $Res.Content
            return $Records
        }
        catch {
            Write-Error "Failed to send time record."
            throw
        }
    }
}

function Test-CanRecord {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet("clock_in", "clock_out")]
        $TimeRecordEvent
    )
    process {
        $Today = (Get-Date).Day
        $Records = Get-AttendanceRecord
        switch ($TimeRecordEvent) {
            "clock_in" {
                return -not [boolean] $Records[$Today].Start
            }
            "clock_out" {
                return -not [boolean] $Records[$Today].End
            }
        }
    }
}

function Send-TimeRecord {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet("clock_in", "clock_out")]
        $TimeRecordEvent
    )

    begin {
        Write-Verbose ($script:MySession | Out-String)
        Write-Verbose ($script:MFSession | Out-String)
    }

    process {
        $MyPage = "$script:Mfc/my_page"
        $NewSessionParams = @{
            Method     = 'Get'
            Uri        = $MyPage
            WebSession = $script:MySession
        }
        Write-Verbose ($NewSessionParams | Out-String)
        try {
            $Res = Invoke-WebRequest @NewSessionParams
            $CsrfToken = Find-CsrfToken -Content $Res.Content
            Write-Verbose $CsrfToken
        }
        catch {
            Write-Error "Failed to connect $MyPage. $_"
            throw
        }
        $TimeRecorder = "$MyPage/web_time_recorder"
        $Now = Get-RecordTime

        $Body = @{
            authenticity_token                           = $CsrfToken
            'web_time_recorder_form[event]'              = $TimeRecordEvent
            'web_time_recorder_form[date]'               = $Now.Date
            'web_time_recorder_form[user_time]'          = $Now.RecordTime
            'web_time_recorder_form[office_location_id]' = $script:MFSession.LocationId
        }
        $LoginParams = @{
            Method     = 'Post'
            Uri        = $TimeRecorder
            WebSession = $MySession
            Body       = $Body
            # Headers = $MockHeaders
        }
        Write-Verbose ($LoginParams | Out-String)
        Write-Verbose ($Body | Out-String)
        try {
            $Res = Invoke-WebRequest @LoginParams
            Write-Host "Succeed to send time record. $TimeRecordEvent $(Get-DateForDisplay $Now.Raw)"
        }
        catch {
            Write-Error "Failed to send time record. $TimeRecorder. $TimeRecordEvent"
            throw
        }
    }
}


function Send-BeginningWork {
    begin {
        Write-Host 'try to begin work.'
    }

    process {
        Get-MFAuthentication
        Connect-MFCloudAttendance
        $Recordable = Test-CanRecord clock_in
        if ($Recordable) {
            Send-TimeRecord -TimeRecordEvent clock_in
        }
    }

    end {
        if ($Recordable) {
            Write-Host 'began work!! 😪'
        }
        else {
            Write-Host "Cannot record. It's already begun. 😅"
        }
    }
}

function Send-FinishingWork {
    begin {
        Write-Host 'try to finish work.'
    }

    process {
        Get-MFAuthentication
        Connect-MFCloudAttendance
        $Recordable = Test-CanRecord clock_out
        if ($Recordable) {
            Send-TimeRecord -TimeRecordEvent clock_out
        }
    }

    end {
        if ($Recordable) {
            Write-Host 'finished work!! 🍻'
        }
        else {
            Write-Host "Cannot record. It was already over. 😅"
        }
    }
}

function Get-MFAttendance {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
    )

    begin {
        Write-Host 'try to get attendances.'
    }

    process {
        Get-MFAuthentication
        Connect-MFCloudAttendance
        $Records = Get-AttendanceRecord
        $Keys = $Records.Keys | Sort-Object
        $Result = @()
        foreach ($Date in $Keys) {
            $Result += [PSCustomObject]@{
                Date  = $Date
                Start = $Records[$Date].Start
                End   = $Records[$Date].End
            }
        }
        $Result
    }
}