Export-WhiteboardHtml.psm1

<#
.SYNOPSIS
    Exports Microsoft Whiteboards to HTML format using the Microsoft Graph API.
 
.DESCRIPTION
    Supports two modes:
 
    USER MODE (-Mode User)
        Connects as the currently signed-in user (delegated auth) and exports all of
        their whiteboards to:
            <Downloads>\WhiteboardExports\<Whiteboard Name>.html
 
        Required permissions (delegated):
            User.Read - read own profile / resolve signed-in user ID
            Files.Read - read own OneDrive and trigger content format conversion
 
    ADMIN MODE (-Mode Admin)
        Connects as a tenant admin and exports whiteboards for all users, or a subset
        provided via -UserListCsv / -UserList, to:
            <Downloads>\WhiteboardExports_<TenantId>\<user@domain>\<Whiteboard Name>.html
 
        Required permissions (delegated, admin-consented):
            User.Read.All - enumerate tenant users
            Files.Read.All - read any user's OneDrive and trigger content format conversion
 
    The HTML is produced by the Graph "content format transform" endpoint:
        GET /users/{user-id}/drive/items/{item-id}/content?format=html
 
    Features:
        - Resume interrupted runs (state saved to export_state.json)
        - Per-whiteboard retry (up to 3 attempts, 3-second back-off)
        - -RetryFailed to re-attempt previously failed exports
        - -Force to start a completely fresh run
        - Progress bars with ETA at both user and whiteboard level
        - Detailed log file (export_log.txt) with timestamps
        - Summary JSON and console report at completion
 
.PARAMETER Mode
    Required. 'User' to export the signed-in user's own whiteboards.
              'Admin' to export whiteboards for all or selected tenant users.
 
.PARAMETER TenantId
    Required in Admin mode. Tenant ID (GUID or domain, e.g. contoso.onmicrosoft.com).
 
.PARAMETER UserListCsv
    Admin mode only. Path to a CSV whose first column (or a column named
    'UserPrincipalName') contains UPNs. If omitted, all tenant users are processed.
 
.PARAMETER UserList
    Admin mode only. Inline array of UPNs:
        -UserList @('alice@contoso.com','bob@contoso.com')
    Ignored when -UserListCsv is also supplied.
 
.PARAMETER OutputPath
    Base directory for the output folder. Defaults to the user's Downloads folder.
 
.PARAMETER Force
    Discard any saved state and start a completely fresh run.
 
.PARAMETER LogLevel
    Console verbosity. One of: Quiet | Normal | Verbose. Default: Normal.
    All levels are always written to the log file.
 
.PARAMETER ThrottleDelayMs
    Milliseconds to pause between Graph API calls. Default: 100.
    Increase to 300-500 for large tenants or when experiencing throttling.
 
.PARAMETER MaxUsers
    Admin mode only. Maximum number of users to process. Default: 9999.
 
.PARAMETER Environment
    Required. Target Microsoft cloud. Must be specified explicitly to prevent accidentally
    connecting a tenant to the wrong cloud endpoint.
    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)
 
.EXAMPLE
    # First-time setup: allow the script to run (run once as the current user)
    Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
 
.EXAMPLE
    # Export the signed-in user's own whiteboards
    .\Export-WhiteboardHtml.ps1 -Mode User
 
.EXAMPLE
    # Export all tenant users' whiteboards (admin)
    .\Export-WhiteboardHtml.ps1 -Mode Admin -TenantId contoso.onmicrosoft.com
 
.EXAMPLE
    # Export for a GCC-High tenant
    .\Export-WhiteboardHtml.ps1 -Mode Admin -TenantId contoso.onmicrosoft.com -Environment AzureUSGovernment
 
.EXAMPLE
    # Export for a DoD tenant
    .\Export-WhiteboardHtml.ps1 -Mode Admin -TenantId contoso.onmicrosoft.com -Environment AzureUSDoD
 
.EXAMPLE
    # Export for users listed in a CSV
    .\Export-WhiteboardHtml.ps1 -Mode Admin -TenantId contoso.onmicrosoft.com `
        -UserListCsv C:\migration\users.csv
 
.EXAMPLE
    # Export for a specific set of users
    .\Export-WhiteboardHtml.ps1 -Mode Admin -TenantId contoso.onmicrosoft.com `
        -UserList @('alice@contoso.com', 'bob@contoso.com')
 
.EXAMPLE
    # Force a completely fresh run (discard saved state)
    .\Export-WhiteboardHtml.ps1 -Mode Admin -TenantId contoso.onmicrosoft.com -Force
#>


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

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

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

    [Parameter(Mandatory = $false)]
    [string[]]$UserList = @(),

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

    [Parameter(Mandatory = $false)]
    [switch]$Force,

    [Parameter(Mandatory = $false)]
    [ValidateSet('Quiet', 'Normal', 'Verbose')]
    [string]$LogLevel = 'Normal',

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

    [Parameter(Mandatory = $false)]
    [int]$MaxUsers = 9999,

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

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

$ErrorActionPreference = 'Stop'

# ── Environment → Graph base URL and Connect-MgGraph environment name ────────────
# AzureCloud → graph.microsoft.com (Connect-MgGraph: Global)
# AzureUSGovernment → graph.microsoft.us (Connect-MgGraph: USGov)
# AzureUSDoD → dod-graph.microsoft.us (Connect-MgGraph: USGovDoD)
# AzureUSNat → graph.eaglex.ic.gov (air-gapped; pre-configured SDK)
# AzureUSSec → graph.microsoft.scloud (air-gapped; pre-configured SDK)
$script: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' }   # AzureCloud
}
$script:mgEnvironment = switch ($Environment) {
    'AzureUSGovernment' { 'USGov' }
    'AzureUSDoD'        { 'USGovDoD' }
    'AzureUSNat'        { 'USNat' }
    'AzureUSSec'        { 'USSec' }
    default             { 'Global' }
}

# ── Required Permissions Reference ──────────────────────────────────────────────
# USER MODE (delegated) : User.Read, Files.Read
# ADMIN MODE (delegated) : User.Read.All, Files.Read.All
# Admin consent required for Files.Read.All and User.Read.All.
# Grant via: Entra ID > App registrations > Microsoft Graph PowerShell >
# API Permissions > Add a permission > Microsoft Graph > Delegated.
# ────────────────────────────────────────────────────────────────────────────────

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

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

if ($UserListCsv -and -not (Test-Path $UserListCsv)) {
    Write-Error "User list CSV not found: $UserListCsv"
}

#endregion

#region ── Output folders ────────────────────────────────────────────────────────

$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"
}

New-Item -ItemType Directory -Path $baseOutDir -Force | Out-Null

$script:logFile     = Join-Path $baseOutDir 'export_log.txt'
$stateFile          = Join-Path $baseOutDir 'export_state.json'
$summaryFile        = Join-Path $baseOutDir 'export_summary.json'

#endregion

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

function Write-Log {
    param(
        [string]$Message,
        [ValidateSet('INFO', 'SUCCESS', 'WARNING', 'ERROR', 'DEBUG')]
        [string]$Level = 'INFO',
        [switch]$AlwaysShow
    )

    $ts    = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    Add-Content -Path $script:logFile -Value "[$ts] [$Level] $Message" -Encoding UTF8

    $show = $AlwaysShow -or (
        $LogLevel -eq 'Verbose' -or
        ($LogLevel -eq 'Normal' -and $Level -in @('INFO','SUCCESS','WARNING','ERROR')) -or
        ($LogLevel -eq 'Quiet'  -and $Level -in @('SUCCESS','ERROR'))
    )

    if (-not $show) { return }

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

#endregion

#region ── Progress bars ─────────────────────────────────────────────────────────

function Show-Progress {
    param(
        [int]$Current,
        [int]$Total,
        [string]$Activity,
        [string]$Status,
        [datetime]$StartTime,
        [int]$Id = 0,
        [int]$ParentId = -1
    )

    $pct = if ($Total -gt 0) { [math]::Min(100, [int](($Current / $Total) * 100)) } else { 0 }

    $etaStr = ''
    if ($Current -gt 0 -and $Total -gt $Current) {
        $elapsed = (Get-Date) - $StartTime
        $eta     = [TimeSpan]::FromSeconds(($elapsed.TotalSeconds / $Current) * ($Total - $Current))
        $etaStr  = " ETA {0:D2}h {1:D2}m {2:D2}s" -f $eta.Hours, $eta.Minutes, $eta.Seconds
    }

    $params = @{
        Id               = $Id
        Activity         = $Activity
        Status           = "$Status$etaStr"
        PercentComplete  = $pct
        CurrentOperation = "$Current / $Total"
    }
    if ($ParentId -ge 0) { $params.ParentId = $ParentId }
    Write-Progress @params
}

#endregion

#region ── State management ──────────────────────────────────────────────────────

function New-RunState {
    return [ordered]@{
        mode           = $Mode
        tenantId       = $TenantId
        environment    = $Environment
        graphBaseUrl   = $script:graphBaseUrl
        startedAt      = (Get-Date -Format 'o')
        lastUpdatedAt  = (Get-Date -Format 'o')
        status         = 'in_progress'
        processedUsers = @()
        exportedFiles  = [ordered]@{}   # UPN -> string[] of exported file names
        failedExports  = @()            # PSCustomObject[]
        stats          = [ordered]@{
            totalUsers           = 0
            usersProcessed       = 0
            usersWithWhiteboards = 0
            usersNoOneDrive      = 0
            usersNoWhiteboards   = 0
            totalWhiteboards     = 0
            successfulExports    = 0
            failedExports        = 0
        }
    }
}

function Get-SavedState {
    if ($Force) {
        Write-Log 'Force flag set - discarding previous state.' 'WARNING' -AlwaysShow
        return $null
    }
    if (-not (Test-Path $stateFile)) { return $null }
    try {
        $s = Get-Content $stateFile -Raw | ConvertFrom-Json
        if ($s.status -eq 'in_progress') {
            Write-Log "Resuming previous run (started $($s.startedAt))." 'INFO' -AlwaysShow
            return $s
        }
    } catch {
        Write-Log 'Could not parse saved state - starting fresh.' 'WARNING'
    }
    return $null
}

function Save-State {
    param([object]$s)
    $s.lastUpdatedAt = (Get-Date -Format 'o')
    $s | ConvertTo-Json -Depth 10 | Set-Content $stateFile -Encoding UTF8
}

# Returns $true if UPN has been fully processed in a previous (saved) run
function Test-UserProcessed {
    param([object]$s, [string]$Upn)
    return ($s.processedUsers -contains $Upn)
}

# Returns the list of already-exported file names for a user key
function Get-ExportedList {
    param([object]$s, [string]$Key)
    $prop = $s.exportedFiles.PSObject.Properties[$Key]
    if ($prop) { return @($prop.Value) }
    return @()
}

# Records a successfully exported file in state
function Add-ExportedFile {
    param([object]$s, [string]$Key, [string]$FileName)
    $existing = $s.exportedFiles.PSObject.Properties[$Key]
    if ($existing) {
        $s.exportedFiles.$Key += $FileName
    } else {
        $s.exportedFiles | Add-Member -NotePropertyName $Key -NotePropertyValue @($FileName) -Force
    }
}

#endregion

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

function Connect-ToGraph {
    param([string[]]$Scopes)

    # When -Force is set, always disconnect first so a fresh token is obtained.
    # This ensures newly granted admin consent is picked up immediately.
    if ($Force) {
        try { Disconnect-MgGraph -ErrorAction SilentlyContinue } catch { }
    } else {
        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'
                    return
                }
                Write-Log "Reconnecting - missing scopes: $($missing -join ', ')." 'WARNING' -AlwaysShow
                try { Disconnect-MgGraph -ErrorAction SilentlyContinue } catch { }
            }
        } catch { }
    }

    # USNat/USSec are air-gapped clouds where the SDK is pre-configured for the environment;
    # Connect-MgGraph -Environment only accepts Global/China/USGov/USGovDoD.
    $params = @{ Scopes = $Scopes; NoWelcome = $true }
    if ($script:mgEnvironment -notin @('USNat', 'USSec')) { $params.Environment = $script:mgEnvironment }
    if (-not [string]::IsNullOrWhiteSpace($TenantId)) { $params.TenantId = $TenantId }
    if (-not [string]::IsNullOrWhiteSpace($LoginAs)) {
        Write-Log "*** Sign in as: $LoginAs ***" 'WARNING' -AlwaysShow
        Write-Log " If an account picker appears, select '$LoginAs'." 'WARNING' -AlwaysShow
        Write-Log " If the wrong account is pre-selected, click 'Use another account'." 'WARNING' -AlwaysShow
    }

    Write-Log 'Connecting to Microsoft Graph...' 'INFO' -AlwaysShow
    Connect-MgGraph @params
    Write-Log "Connected as $((Get-MgContext).Account)." 'SUCCESS' -AlwaysShow
}

#endregion

#region ── Pre-flight validation ─────────────────────────────────────────────────

function Test-Preflight {
    param([string[]]$RequiredScopes)

    Write-Log 'Running pre-flight checks...' 'INFO' -AlwaysShow
    $ok = $true

    $ctx = Get-MgContext
    if (-not $ctx) {
        Write-Log ' Graph connection: NOT connected.' 'ERROR' -AlwaysShow
        return $false
    }
    Write-Log " Graph connection : OK ($($ctx.Account))" 'SUCCESS'

    $missing = @($RequiredScopes | Where-Object { $_ -notin @($ctx.Scopes) })
    if ($missing.Count -gt 0) {
        Write-Log " Permissions : MISSING - $($missing -join ', ')" 'ERROR' -AlwaysShow
        Write-Log " Grant consent in Entra ID -> App registrations -> Microsoft Graph PowerShell -> API permissions." 'ERROR' -AlwaysShow
        $ok = $false
    } else {
        Write-Log ' Permissions : OK' 'SUCCESS'
    }

    try {
        $drv    = Get-Item $env:USERPROFILE
        $freeGB = [math]::Round($drv.PSDrive.Free / 1GB, 1)
        $lvl    = if ($freeGB -lt 1) { 'WARNING' } else { 'SUCCESS' }
        Write-Log " Disk space : ${freeGB} GB free" $lvl
    } catch { }

    if ($ok) { Write-Log 'Pre-flight passed.' 'SUCCESS' -AlwaysShow }
    return $ok
}

#endregion

#region ── User resolution ───────────────────────────────────────────────────────

function Resolve-SingleUser {
    param([string]$Identity)
    try {
        $uri = "$script:graphBaseUrl/v1.0/users/$Identity`?`$select=id,displayName,userPrincipalName"
        return Invoke-MgGraphRequest -Method GET -Uri $uri
    } catch {
        Write-Log "Could not resolve user '$Identity': $_" 'ERROR' -AlwaysShow
        throw
    }
}

function Get-UsersToProcess {
    # ── User mode ──
    if ($Mode -eq 'User') {
        try {
            $me = Invoke-MgGraphRequest -Method GET `
                -Uri "$script:graphBaseUrl/v1.0/me?`$select=id,displayName,userPrincipalName"
            Write-Log "Signed-in user: $($me.displayName) ($($me.userPrincipalName))" 'INFO' -AlwaysShow
            return @($me)
        } catch {
            Write-Log "Failed to retrieve signed-in user profile: $_" 'ERROR' -AlwaysShow
            throw
        }
    }

    # ── Admin mode - CSV file ──
    if (-not [string]::IsNullOrWhiteSpace($UserListCsv)) {
        Write-Log "Loading user list from CSV: $UserListCsv" 'INFO' -AlwaysShow
        $rows = Import-Csv -Path $UserListCsv
        if ($rows.Count -eq 0) {
            Write-Log 'CSV file is empty.' 'ERROR' -AlwaysShow; throw 'CSV file is empty.'
        }

        $colName = if ($rows[0].PSObject.Properties.Name -contains 'UserPrincipalName') {
            'UserPrincipalName'
        } else {
            $rows[0].PSObject.Properties.Name | Select-Object -First 1
        }

        $upns = @($rows.$colName | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        Write-Log "Found $($upns.Count) UPN(s) in CSV." 'INFO' -AlwaysShow
        return Resolve-UpnArray -Upns $upns
    }

    # ── Admin mode - inline list ──
    if ($UserList.Count -gt 0) {
        Write-Log "Processing $($UserList.Count) user(s) from -UserList." 'INFO' -AlwaysShow
        return Resolve-UpnArray -Upns $UserList
    }

    # ── Admin mode - all tenant users ──
    Write-Log "Fetching all enabled users in tenant (max $MaxUsers)..." 'INFO' -AlwaysShow
    $collected = [System.Collections.Generic.List[object]]::new()
    $nextUri   = "$script:graphBaseUrl/v1.0/users?`$top=999&`$select=id,displayName,userPrincipalName&`$filter=accountEnabled eq true"

    try {
        do {
            $resp    = Invoke-MgGraphRequest -Method GET -Uri $nextUri
            foreach ($u in $resp.value) { $collected.Add($u) }
            $nextUri = if ($resp.PSObject.Properties['@odata.nextLink']) { $resp.'@odata.nextLink' } else { $null }
            if ($collected.Count -ge $MaxUsers) { break }
        } while ($nextUri)
    } catch {
        Write-Log "Failed to retrieve tenant users: $_" 'ERROR' -AlwaysShow
        throw
    }

    $result = $collected | Select-Object -First $MaxUsers
    Write-Log "Found $($collected.Count) user(s); processing $(@($result).Count)." 'INFO' -AlwaysShow
    return @($result)
}

function Resolve-UpnArray {
    param([string[]]$Upns)
    $out = [System.Collections.Generic.List[object]]::new()
    foreach ($upn in $Upns) {
        try {
            $uri  = "$script:graphBaseUrl/v1.0/users/$upn`?`$select=id,displayName,userPrincipalName"
            $user = Invoke-MgGraphRequest -Method GET -Uri $uri
            $out.Add($user)
        } catch {
            Write-Log "Could not resolve '$upn' - skipping: $_" 'WARNING'
        }
    }
    return $out.ToArray()
}

#endregion


#region ── Whiteboard discovery in OneDrive ──────────────────────────────────────

function Get-WhiteboardItems {
    # Returns an array of drive item objects (.whiteboard files) found anywhere inside
    # the Whiteboards folder and all its subfolders, or $null if the user has no
    # OneDrive / no Whiteboards folder / access denied.
    # Each returned item has a '_driveId' property for use in Export-OneWhiteboard.
    param(
        [string]$UserObjectId,
        [string]$DisplayLabel     # used only for log messages
    )

    $allFiles    = [System.Collections.Generic.List[object]]::new()
    $folderQueue = [System.Collections.Generic.Queue[string]]::new()

    # ── Seed: Whiteboards root via direct path (avoids drive-root 403) ──────────
    # Any error here means the user has no OneDrive or no access — return $null.
    try {
        $nextUri = "$script:graphBaseUrl/v1.0/users/$UserObjectId/drive/root:/Whiteboards:/children"
        do {
            $resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
            foreach ($item in $resp.value) {
                if ($item.folder) {
                    Write-Log " Found subfolder: $($item.name) — queuing for enumeration." 'DEBUG'
                    $folderQueue.Enqueue("$script:graphBaseUrl/v1.0/users/$UserObjectId/drive/items/$($item.id)/children")
                } else {
                    $allFiles.Add($item)
                }
            }
            $nextUri = if ($resp.'@odata.nextLink') { $resp.'@odata.nextLink' } else { $null }
        } while ($nextUri)
    } catch {
        $errMsg  = $_.Exception.Message
        $errBody = if ($_.ErrorDetails.Message) { $_.ErrorDetails.Message } else { '' }
        if ($errMsg -match 'itemNotFound|Not Found' -or $errBody -match 'itemNotFound') {
            Write-Log " No Whiteboards folder / no OneDrive for $DisplayLabel - skipping." 'WARNING'
        } elseif ($errMsg -match 'accessDenied|Access denied|Forbidden' -or $errBody -match 'accessDenied') {
            Write-Log " Access denied to Whiteboards for $DisplayLabel - skipping." 'WARNING'
        } else {
            $detail = if ($errBody) { "$errMsg | $errBody" } else { $errMsg }
            Write-Log " Could not list Whiteboards for $DisplayLabel - skipping. Error: $detail" 'WARNING'
        }
        return $null
    }

    # ── Recurse into subfolders ──────────────────────────────────────────────────
    # Errors on individual subfolders are non-fatal — log and continue.
    while ($folderQueue.Count -gt 0) {
        $folderUri = $folderQueue.Dequeue()
        try {
            $nextUri = $folderUri
            do {
                $resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
                foreach ($item in $resp.value) {
                    if ($item.folder) {
                        Write-Log " Found nested subfolder: $($item.name) — queuing for enumeration." 'DEBUG'
                        $folderQueue.Enqueue("$script:graphBaseUrl/v1.0/users/$UserObjectId/drive/items/$($item.id)/children")
                    } else {
                        $allFiles.Add($item)
                    }
                }
                $nextUri = if ($resp.'@odata.nextLink') { $resp.'@odata.nextLink' } else { $null }
            } while ($nextUri)
        } catch {
            Write-Log " Could not enumerate subfolder for $DisplayLabel - skipping it. Error: $($_.Exception.Message)" 'WARNING'
        }
    }

    # Stamp each item with its drive ID (from parentReference) for use in export.
    $wbFiles = @($allFiles | Where-Object { ($_.name -like '*.whiteboard' -or $_.name -like '*.wbtx') -and -not $_.folder })
    foreach ($f in $wbFiles) {
        $driveId = if ($f.parentReference -and $f.parentReference.driveId) { $f.parentReference.driveId } else { $null }
        $f | Add-Member -NotePropertyName '_driveId' -NotePropertyValue $driveId -Force
    }
    return $wbFiles
}

#endregion

#region ── Single whiteboard HTML export ─────────────────────────────────────────

function Export-OneWhiteboard {
    # Returns: 'success' | 'skipped' | 'failed'
    param(
        [string]$UserObjectId,
        [string]$UserUpn,
        [object]$Item,
        [string]$OutDir,
        [object]$State,
        [string]$StateKey    # key into state.exportedFiles
    )

    $baseName  = [System.IO.Path]::GetFileNameWithoutExtension($Item.name)
    $safeBase  = $baseName -replace '[\\/:*?"<>|]', '_'
    $htmlFile  = Join-Path $OutDir "$safeBase.html"

    # Skip if already exported (resume mode)
    $doneList = Get-ExportedList -s $State -Key $StateKey
    if ($doneList -contains $Item.name) {
        Write-Log " [SKIP] Already exported: $($Item.name)" 'DEBUG'
        return 'skipped'
    }

    # Skip if previously failed — run Invoke-WhiteboardHtmlRetry to retry
    $prevFail = @($State.failedExports) | Where-Object {
        $_.user -eq $UserUpn -and $_.file -eq $Item.name
    }
    if ($prevFail) {
        Write-Log " [SKIP] Previously failed (run Invoke-WhiteboardHtmlRetry to retry): $($Item.name)" 'WARNING'
        return 'skipped'
    }

    Write-Log " Exporting: $($Item.name)..." 'INFO'

    # Prefer drive-ID-based URI (works for both user-based and site-based access).
    $uri = if ($Item._driveId) {
        "$script:graphBaseUrl/v1.0/drives/$($Item._driveId)/items/$($Item.id)/content?format=html"
    } else {
        "$script:graphBaseUrl/v1.0/users/$UserObjectId/drive/items/$($Item.id)/content?format=html"
    }
    $maxTries = 4

    for ($attempt = 1; $attempt -le $maxTries; $attempt++) {
        try {
            Invoke-MgGraphRequest -Method GET -Uri $uri -OutputFilePath $htmlFile
            Write-Log " [OK] $safeBase.html" 'SUCCESS'
            $State.stats.successfulExports++
            Add-ExportedFile -s $State -Key $StateKey -FileName $Item.name
            Save-State $State
            return 'success'
        } catch {
            if ($attempt -lt $maxTries) {
                Write-Log " Attempt $attempt failed - retrying in 3 s... ($_)" 'WARNING'
                Start-Sleep -Seconds 3
            } else {
                Write-Log " [FAIL] $($Item.name) after $maxTries attempts (3 retries): $_" 'ERROR'
                $State.stats.failedExports++

                # Remove stale failure record before adding fresh one
                $State.failedExports = @($State.failedExports | Where-Object {
                    -not ($_.user -eq $UserUpn -and $_.file -eq $Item.name)
                })
                $State.failedExports += [PSCustomObject]@{
                    user      = $UserUpn
                    file      = $Item.name
                    itemId    = $Item.id
                    error     = $_.ToString()
                    timestamp = (Get-Date -Format 'o')
                }
                Save-State $State
                return 'failed'
            }
        }
    }
}

#endregion

#region ── Per-user export orchestration ─────────────────────────────────────────

function Invoke-UserExport {
    param(
        [object]$User,
        [object]$State,
        [string]$UserOutDir,
        [string]$StateKey,       # key for state.exportedFiles
        [datetime]$RunStart
    )

    $userId  = $User.id
    $upn     = $User.userPrincipalName
    $display = $User.displayName

    Write-Log "Processing: $display ($upn)" 'INFO'
    $State.stats.usersProcessed++

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

    $wbItems = Get-WhiteboardItems -UserObjectId $userId -DisplayLabel "$display ($upn)"

    if ($null -eq $wbItems) {
        $State.stats.usersNoOneDrive++
        return
    }

    if ($wbItems.Count -eq 0) {
        Write-Log " No .whiteboard files found for $upn." 'WARNING'
        $State.stats.usersNoWhiteboards++
        return
    }

    Write-Log " Found $($wbItems.Count) whiteboard(s)." 'SUCCESS'
    $State.stats.usersWithWhiteboards++
    $State.stats.totalWhiteboards += $wbItems.Count

    New-Item -ItemType Directory -Path $UserOutDir -Force | Out-Null

    $wbIdx = 0
    foreach ($item in $wbItems) {
        $wbIdx++
        Show-Progress `
            -Current    $wbIdx `
            -Total      $wbItems.Count `
            -Activity   "Whiteboards for: $upn" `
            -Status     "$($item.name)" `
            -StartTime  $RunStart `
            -Id         1 `
            -ParentId   0

        Export-OneWhiteboard `
            -UserObjectId $userId `
            -UserUpn      $upn `
            -Item         $item `
            -OutDir       $UserOutDir `
            -State        $State `
            -StateKey     $StateKey | Out-Null

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

    Write-Progress -Id 1 -Activity "Whiteboards for: $upn" -Completed
}

#endregion

#region ── Final summary ─────────────────────────────────────────────────────────

function Write-FinalSummary {
    param([object]$State, [TimeSpan]$Duration)

    $durStr = '{0:D2}h {1:D2}m {2:D2}s' -f $Duration.Hours, $Duration.Minutes, $Duration.Seconds

    $summaryObj = [ordered]@{
        mode             = $State.mode
        tenantId         = $State.tenantId
        exportCompletedAt = (Get-Date -Format 'o')
        duration         = $durStr
        durationSeconds  = [int]$Duration.TotalSeconds
        statistics       = $State.stats
        failures         = @($State.failedExports)
        outputFolder     = $baseOutDir
    }
    $summaryObj | ConvertTo-Json -Depth 10 | Set-Content $summaryFile -Encoding UTF8

    $failColor = if ($State.stats.failedExports -gt 0) { 'Red' } else { 'Green' }

    Write-Host ''
    Write-Host '============================================' -ForegroundColor Cyan
    Write-Host ' WHITEBOARD EXPORT - SUMMARY'         -ForegroundColor Cyan
    Write-Host '============================================' -ForegroundColor Cyan
    Write-Host "Mode : $($State.mode)"
    if ($State.tenantId) {
        Write-Host "Tenant : $($State.tenantId)"
    }
    Write-Host "Duration : $durStr"
    Write-Host '--------------------------------------------'
    Write-Host "Users processed : $($State.stats.usersProcessed)"
    Write-Host "With whiteboards : $($State.stats.usersWithWhiteboards)"
    Write-Host "No OneDrive : $($State.stats.usersNoOneDrive)"
    Write-Host "No whiteboards : $($State.stats.usersNoWhiteboards)"
    Write-Host '--------------------------------------------'
    Write-Host "Total found : $($State.stats.totalWhiteboards)"
    Write-Host "Exported (OK) : $($State.stats.successfulExports)" -ForegroundColor Green
    Write-Host "Failed : $($State.stats.failedExports)"     -ForegroundColor $failColor
    Write-Host '--------------------------------------------'
    Write-Host "Output folder : $baseOutDir"  -ForegroundColor Green
    Write-Host "Log file : $($script:logFile)"
    Write-Host "Summary JSON : $summaryFile"
    Write-Host '============================================' -ForegroundColor Cyan

    if ($State.stats.failedExports -gt 0) {
        Write-Host ''
        Write-Host 'Tip: Run Invoke-WhiteboardHtmlRetry to retry failed exports.' -ForegroundColor Yellow
        Write-Host ' Check export_log.txt for per-failure details.'           -ForegroundColor Yellow
    }
}

#endregion

# ═════════════════════════════════════════════════════════════════════════════════
# MAIN
# ═════════════════════════════════════════════════════════════════════════════════

Write-Host ''
Write-Host '╔══════════════════════════════════════════════╗' -ForegroundColor Cyan
Write-Host '║ Microsoft Whiteboard HTML Exporter ║' -ForegroundColor Cyan
Write-Host "║ Mode : $($Mode.PadRight(36))║" -ForegroundColor Cyan
if ($TenantId) {
    Write-Host "║ Tenant: $($TenantId.PadRight(35))║" -ForegroundColor Cyan
}
Write-Host '╚══════════════════════════════════════════════╝' -ForegroundColor Cyan
Write-Host ''

# ── 1. Required Graph scopes ────────────────────────────────────────────────
$requiredScopes = if ($Mode -eq 'User') {
    @('User.Read', 'Files.Read')
} else {
    @('User.Read.All', 'Files.Read.All', 'Sites.Read.All')
}

# ── 2. Connect ──────────────────────────────────────────────────────────────
Connect-ToGraph -Scopes $requiredScopes

# ── 3. Pre-flight ───────────────────────────────────────────────────────────
if (-not (Test-Preflight -RequiredScopes $requiredScopes)) {
    Write-Log 'Pre-flight failed. Exiting.' 'ERROR' -AlwaysShow
    throw 'Export-WhiteboardHtml: pre-flight checks failed.'
}
Write-Host ''

# ── 5. Load or create run state ─────────────────────────────────────────────
$state = Get-SavedState
if ($state) {
    $resumed = $true
    Write-Log "Resuming - $(@($state.processedUsers).Count) user(s) already processed." 'INFO' -AlwaysShow
} else {
    $resumed = $false
    $state   = New-RunState
    Write-Log 'Starting new export.' 'INFO' -AlwaysShow
}

$runStart = Get-Date

# ── 6. Resolve the list of users ────────────────────────────────────────────
$users                    = @(Get-UsersToProcess)
$state.stats.totalUsers   = $users.Count
Write-Log "Users to process: $($users.Count)" 'INFO' -AlwaysShow
Write-Host ''

if ($users.Count -eq 0) {
    Write-Log 'No users to process. Exiting.' 'WARNING' -AlwaysShow
    return
}

# ── 7. Main loop ─────────────────────────────────────────────────────────────
$userIdx = 0
foreach ($user in $users) {
    $userIdx++
    $upn = $user.userPrincipalName

    # Top-level progress bar (users)
    Show-Progress `
        -Current   $userIdx `
        -Total     $users.Count `
        -Activity  "Whiteboard Export [$Mode]" `
        -Status    "User: $upn" `
        -StartTime $runStart `
        -Id        0

    # Skip users fully processed in a prior run
    if ($resumed -and (Test-UserProcessed -s $state -Upn $upn)) {
        Write-Log " [SKIP] Already fully processed: $upn" 'DEBUG'
        continue
    }

    Write-Host "[$userIdx/$($users.Count)] " -NoNewline -ForegroundColor DarkGray
    Write-Host $upn -ForegroundColor White

    # Per-user output folder and state key
    if ($Mode -eq 'User') {
        $userOutDir = $baseOutDir                          # flat: all HTMLs in base folder
        $stateKey   = $upn
    } else {
        $safeUpn    = $upn -replace '[\\/:*?"<>|]', '_'
        $userOutDir = Join-Path $baseOutDir $safeUpn
        $stateKey   = $upn
    }

    Invoke-UserExport `
        -User       $user `
        -State      $state `
        -UserOutDir $userOutDir `
        -StateKey   $stateKey `
        -RunStart   $runStart

    # Mark user as fully processed for this run
    $state.processedUsers += $upn
    Save-State $state
}

# ── 8. Finish ────────────────────────────────────────────────────────────────
Write-Progress -Id 0 -Activity "Whiteboard Export [$Mode]" -Completed

$state.status      = 'completed'
$state.completedAt = (Get-Date -Format 'o')
Save-State $state

$duration = (Get-Date) - $runStart
Write-Log "Export complete. Duration: $("{0:D2}h {1:D2}m {2:D2}s" -f $duration.Hours, $duration.Minutes, $duration.Seconds)" `
    'SUCCESS' -AlwaysShow

Write-FinalSummary -State $state -Duration $duration
} # end function Export-WhiteboardHtml