Invoke-WhiteboardHtmlRetry.psm1

<#
.SYNOPSIS
    Retries failed whiteboard HTML exports from a previous Export-WhiteboardHtml run.
 
.DESCRIPTION
    Reads the export_state.json produced by Export-WhiteboardHtml, identifies all failed
    exports, and re-attempts each one. On success the item is removed from the failures
    list and recorded in exportedFiles. The state file is updated in place.
 
    The output folder is derived automatically from -Mode, -TenantId, and -OutputPath —
    the same way Export-WhiteboardHtml resolves it, so no -StateFile path is needed.
 
.PARAMETER Mode
    Required. Must match the mode used in the original Export-WhiteboardHtml run:
    'User' or 'Admin'.
 
.PARAMETER Environment
    Required. Must match the environment used in the original Export-WhiteboardHtml run.
    AzureCloud - Public / GCC (graph.microsoft.com)
    AzureUSGovernment - GCC-High (graph.microsoft.us)
    AzureUSDoD - DoD (dod-graph.microsoft.us)
    AzureUSNat - Air-Gap / USNat (graph.eaglex.ic.gov)
    AzureUSSec - Air-Gap / USSec (graph.microsoft.scloud)
 
.PARAMETER TenantId
    Required in Admin mode. Must match the TenantId used in the original run.
 
.PARAMETER OutputPath
    Base directory for the output folder. Must match -OutputPath from the original run.
    Defaults to the user's Downloads folder.
 
.PARAMETER LoginAs
    Optional. UPN hint shown before the browser sign-in prompt.
 
.PARAMETER ThrottleDelayMs
    Milliseconds to pause between Graph API calls. Default: 100.
 
.EXAMPLE
    # Retry failed exports from an Admin run
    Invoke-WhiteboardHtmlRetry -Mode Admin -TenantId contoso.onmicrosoft.com -Environment AzureCloud
 
.EXAMPLE
    # Retry failed exports from a User run
    Invoke-WhiteboardHtmlRetry -Mode User -Environment AzureCloud
 
.EXAMPLE
    # Retry with a custom output path (must match the original Export-WhiteboardHtml run)
    Invoke-WhiteboardHtmlRetry -Mode Admin -TenantId contoso.onmicrosoft.com -Environment AzureCloud `
        -OutputPath C:\Exports
#>


function Invoke-WhiteboardHtmlRetry {
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [ValidateSet('User', 'Admin')]
    [string]$Mode,

    [Parameter(Mandatory = $true)]
    [ValidateSet('AzureCloud', 'AzureUSGovernment', 'AzureUSDoD', 'AzureUSNat', 'AzureUSSec')]
    [string]$Environment,

    [Parameter(Mandatory = $false)]
    [string]$TenantId,

    [Parameter(Mandatory = $false)]
    [string]$OutputPath,

    [Parameter(Mandatory = $false)]
    [string]$LoginAs,

    [Parameter(Mandatory = $false)]
    [int]$ThrottleDelayMs = 100
)

$ErrorActionPreference = 'Stop'

#region ── Parameter validation ──────────────────────────────────────────────────

if ($Mode -eq 'Admin' -and [string]::IsNullOrWhiteSpace($TenantId)) {
    Write-Error "Admin mode requires -TenantId. Example: -TenantId contoso.onmicrosoft.com"
}

#endregion

#region ── Resolve output folder and state file (mirrors Export-WhiteboardHtml) ──

$downloadsRoot = if ($OutputPath) { $OutputPath } else { Join-Path $env:USERPROFILE 'Downloads' }

$baseOutDir = if ($Mode -eq 'User') {
    Join-Path $downloadsRoot 'WhiteboardExports'
} else {
    Join-Path $downloadsRoot "WhiteboardExports_$TenantId"
}

$stateFile = Join-Path $baseOutDir 'export_state.json'
$logFile   = Join-Path $baseOutDir 'export_log.txt'

#endregion

#region ── Load and validate state file ──────────────────────────────────────────

if (-not (Test-Path $stateFile)) {
    Write-Error "State file not found: $stateFile`nRun Export-WhiteboardHtml first."
}

try {
    $state = Get-Content $stateFile -Raw | ConvertFrom-Json
} catch {
    Write-Error "Could not parse state file: $_"
}

$failed = @($state.failedExports)

if ($failed.Count -eq 0) {
    Write-Host 'No failed exports in state file. Nothing to retry.' -ForegroundColor Green
    return
}

#endregion

#region ── Environment mapping ───────────────────────────────────────────────────

$graphBaseUrl  = switch ($Environment) {
    'AzureUSGovernment' { 'https://graph.microsoft.us'         }
    'AzureUSDoD'        { 'https://dod-graph.microsoft.us'     }
    'AzureUSNat'        { 'https://graph.eaglex.ic.gov'        }
    'AzureUSSec'        { 'https://graph.microsoft.scloud'     }
    default             { 'https://graph.microsoft.com'        }
}
$mgEnvironment = switch ($Environment) {
    'AzureUSGovernment' { 'USGov'    }
    'AzureUSDoD'        { 'USGovDoD' }
    'AzureUSNat'        { 'USNat'    }
    'AzureUSSec'        { 'USSec'    }
    default             { 'Global'   }
}

#endregion

#region ── Logging ────────────────────────────────────────────────────────────────

function Write-Log {
    param(
        [string]$Message,
        [ValidateSet('INFO', 'SUCCESS', 'WARNING', 'ERROR')]
        [string]$Level = 'INFO'
    )
    $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    Add-Content -Path $logFile -Value "[$ts] [$Level] $Message" -Encoding UTF8

    $color = switch ($Level) {
        'SUCCESS' { 'Green'  }
        'WARNING' { 'Yellow' }
        'ERROR'   { 'Red'    }
        default   { 'White'  }
    }
    Write-Host $Message -ForegroundColor $color
}

#endregion

#region ── Graph connection ───────────────────────────────────────────────────────

$scopes = if ($Mode -eq 'User') {
    @('User.Read', 'Files.Read')
} else {
    @('User.Read.All', 'Files.Read.All', 'Sites.Read.All')
}

try {
    $ctx = Get-MgContext
    if ($ctx) {
        $missing = @($scopes | Where-Object { $_ -notin @($ctx.Scopes) })
        if ($missing.Count -eq 0) {
            Write-Log "Already connected as $($ctx.Account)." 'SUCCESS'
        } else {
            Write-Log "Reconnecting - missing scopes: $($missing -join ', ')." 'WARNING'
            try { Disconnect-MgGraph -ErrorAction SilentlyContinue } catch { }
        }
    }
} catch { }

if (-not (Get-MgContext)) {
    $connectParams = @{ Scopes = $scopes; NoWelcome = $true }
    if ($mgEnvironment -notin @('USNat', 'USSec')) { $connectParams.Environment = $mgEnvironment }
    if (-not [string]::IsNullOrWhiteSpace($TenantId)) { $connectParams.TenantId = $TenantId }
    if (-not [string]::IsNullOrWhiteSpace($LoginAs)) {
        Write-Log "*** Sign in as: $LoginAs ***" 'WARNING'
        Write-Log " If an account picker appears, select '$LoginAs'." 'WARNING'
        Write-Log " If the wrong account is pre-selected, click 'Use another account'." 'WARNING'
    }
    Write-Log 'Connecting to Microsoft Graph...' 'INFO'
    Connect-MgGraph @connectParams
    Write-Log "Connected as $((Get-MgContext).Account)." 'SUCCESS'
}

#endregion

#region ── UPN → Object ID resolution ────────────────────────────────────────────

$userIdCache = @{}

function Resolve-UserObjectId {
    param([string]$Upn)
    if ($userIdCache.ContainsKey($Upn)) { return $userIdCache[$Upn] }
    try {
        $uri  = "$graphBaseUrl/v1.0/users/$Upn`?`$select=id"
        $user = Invoke-MgGraphRequest -Method GET -Uri $uri
        $userIdCache[$Upn] = $user.id
        return $user.id
    } catch {
        Write-Log "Could not resolve user '$Upn': $_" 'ERROR'
        return $null
    }
}

#endregion

#region ── Retry loop ─────────────────────────────────────────────────────────────

Write-Host ''
Write-Host '============================================' -ForegroundColor Cyan
Write-Host " Retrying $($failed.Count) failed export(s)" -ForegroundColor Cyan
Write-Host " Mode : $Mode"                             -ForegroundColor Cyan
if ($TenantId) {
    Write-Host " Tenant : $TenantId"                     -ForegroundColor Cyan
}
Write-Host '============================================' -ForegroundColor Cyan
Write-Host ''

$successCount = 0
$stillFailed  = [System.Collections.Generic.List[object]]::new()
$maxTries     = 4

$idx = 0
foreach ($entry in $failed) {
    $idx++
    Write-Log "[$idx/$($failed.Count)] $($entry.user) / $($entry.file)" 'INFO'

    $userId = Resolve-UserObjectId -Upn $entry.user
    if (-not $userId) {
        $stillFailed.Add($entry)
        continue
    }

    # Reconstruct output directory (mirrors Export-WhiteboardHtml logic)
    if ($Mode -eq 'User') {
        $outDir = $baseOutDir
    } else {
        $safeUpn = $entry.user -replace '[\\/:*?"<>|]', '_'
        $outDir  = Join-Path $baseOutDir $safeUpn
    }
    New-Item -ItemType Directory -Path $outDir -Force | Out-Null

    $baseName = [System.IO.Path]::GetFileNameWithoutExtension($entry.file)
    $safeBase = $baseName -replace '[\\/:*?"<>|]', '_'
    $htmlFile = Join-Path $outDir "$safeBase.html"
    $uri      = "$graphBaseUrl/v1.0/users/$userId/drive/items/$($entry.itemId)/content?format=html"
    $ok       = $false

    for ($attempt = 1; $attempt -le $maxTries; $attempt++) {
        try {
            Invoke-MgGraphRequest -Method GET -Uri $uri -OutputFilePath $htmlFile
            Write-Log " [OK] $safeBase.html" 'SUCCESS'
            $ok = $true
            $successCount++

            # Record as exported in state and remove from failures list
            $key  = $entry.user
            $prop = $state.exportedFiles.PSObject.Properties[$key]
            if ($prop) {
                $state.exportedFiles.$key += $entry.file
            } else {
                $state.exportedFiles | Add-Member -NotePropertyName $key -NotePropertyValue @($entry.file) -Force
            }
            break
        } catch {
            if ($attempt -lt $maxTries) {
                Write-Log " Attempt $attempt failed - retrying in 3 s... ($_)" 'WARNING'
                Start-Sleep -Seconds 3
            } else {
                Write-Log " [FAIL] $($entry.file) after $maxTries attempts: $_" 'ERROR'
                $stillFailed.Add([PSCustomObject]@{
                    user      = $entry.user
                    file      = $entry.file
                    itemId    = $entry.itemId
                    error     = $_.ToString()
                    timestamp = (Get-Date -Format 'o')
                })
            }
        }
    }

    if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
}

#endregion

#region ── Update state and summary files ────────────────────────────────────────

$state.failedExports           = $stillFailed.ToArray()
$state.stats.successfulExports = $state.stats.successfulExports + $successCount
$state.stats.failedExports     = $stillFailed.Count
$state.lastUpdatedAt           = (Get-Date -Format 'o')
if ($stillFailed.Count -eq 0) { $state.status = 'completed' }

$state | ConvertTo-Json -Depth 10 | Set-Content $stateFile -Encoding UTF8
Write-Log "State file updated: $stateFile" 'INFO'

$summaryFile = Join-Path $baseOutDir 'export_summary.json'
$summaryObj  = [ordered]@{
    mode              = $Mode
    tenantId          = $TenantId
    exportCompletedAt = (Get-Date -Format 'o')
    statistics        = $state.stats
    failures          = @($state.failedExports)
    outputFolder      = $baseOutDir
}
$summaryObj | ConvertTo-Json -Depth 10 | Set-Content $summaryFile -Encoding UTF8
Write-Log "Summary file updated: $summaryFile" 'INFO'

#endregion

#region ── Summary ────────────────────────────────────────────────────────────────

$failColor = if ($stillFailed.Count -gt 0) { 'Red' } else { 'Green' }

Write-Host ''
Write-Host '============================================' -ForegroundColor Cyan
Write-Host ' WHITEBOARD RETRY - SUMMARY'          -ForegroundColor Cyan
Write-Host '============================================' -ForegroundColor Cyan
Write-Host "Retried : $($failed.Count)"
Write-Host "Succeeded : $successCount"              -ForegroundColor Green
Write-Host "Still failed : $($stillFailed.Count)"      -ForegroundColor $failColor
Write-Host '--------------------------------------------'
Write-Host "Output folder: $baseOutDir"                -ForegroundColor Green
Write-Host "State file : $stateFile"
Write-Host '============================================' -ForegroundColor Cyan

#endregion

} # end function Invoke-WhiteboardHtmlRetry