OneDriveHelpers.psm1

<#
.SYNOPSIS
Helper functions for OneDrive file attributes and logging.
.DESCRIPTION
This module provides functions to:
- Write log messages to console and log file
- Resolve full paths
- Check OneDrive files/folders for online-only or local availability
- Force files/folders to be locally available or online-only
- Wait for OneDrive file/folder attribute state changes
#>


#region Logging

<#
.SYNOPSIS
Writes a log message to console and optionally to a log file.
.PARAMETER message
The log message text.
.PARAMETER level
Log level. Acceptable values: Info, Warning, Error, Success, Debug.
.PARAMETER NoNewLine
If set, does not output a newline at the end.
.PARAMETER StartNewLine
If set, starts with a new line before the log entry.
.EXAMPLE
Write-Log -message "Process started" -level "Info"
#>

function Write-Log {
    param(
        [Parameter(Mandatory=$true)]
        [string]$message,
        [ValidateSet("Info","Warning","Error","Success","Debug")]
        [string]$level = "Info",
        [switch]$NoNewLine,
        [switch]$StartNewLine
    )
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $log_entry = "[$timestamp] [$level] $message"

    if ($StartNewLine) { 
        $log_entry = "`n$log_entry"
        Write-Host ""
    }

    # Write to log file if $script:log_file is set
    if ($script:log_file) {
        try { Add-Content -Path $script:log_file -Value $log_entry -ErrorAction Stop } 
        catch { Write-Host "Failed to write to log file: $($_.Exception.Message)" -ForegroundColor Red }
    }

    # Write to console with color
    $foreground_color = switch ($level.ToLower()) {
        "info" {"White"}
        "warning" {"Yellow"}
        "error" {"Red"}
        "success" {"Green"}
        "debug" {"Cyan"}
        default {"White"}
    }

    Write-Host -NoNewline "[$timestamp]" -ForegroundColor "DarkGray"
    Write-Host " [$level] $message" -ForegroundColor $foreground_color -NoNewline

    if (-not $NoNewLine) { Write-Host "" }
}

#endregion

#region Path Helpers

<#
.SYNOPSIS
Resolves a full, absolute path from a given string.
.PARAMETER Path
The input path string.
.EXAMPLE
Resolve-FullPath -Path ".\file.txt"
#>

function Resolve-FullPath {
    param([Parameter(Mandatory=$true)][string]$Path)
    try { return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath } 
    catch { throw "Resolve-FullPath: cannot resolve '$Path' - $($_.Exception.Message)" }
}

#endregion

#region OneDrive Attribute Helpers

<#
.SYNOPSIS
Tests if a OneDrive file or folder is online-only.
.PARAMETER Path
The path to the file or folder.
.RETURNS
$true if online-only (Offline attribute set), $false if locally available.
.EXAMPLE
Test-OneDriveOnlineOnly -Path "C:\Users\User\OneDrive\file.txt"
#>

function Test-OneDriveOnlineOnly {
    param([Parameter(Mandatory=$true)][string]$Path)
    $full = Resolve-FullPath -Path $Path
    $item = Get-Item -LiteralPath $full -ErrorAction Stop
    $attrs = $item.Attributes
    return ([int]($attrs -band [System.IO.FileAttributes]::Offline) -ne 0)
}

<#
.SYNOPSIS
Internal helper to invoke attrib.exe with arguments.
.PARAMETER Args
Array of attrib.exe command line arguments.
.EXAMPLE
Invoke-AttribInternal -Args @("+p", "C:\file.txt")
#>

function Invoke-AttribInternal {
    param([Parameter(Mandatory=$true)][string[]]$Args)
    $attribExe = Join-Path $env:SystemRoot 'System32\attrib.exe'
    if (-not (Test-Path $attribExe)) { $attribExe = 'attrib.exe' }

    try {
        $out = & $attribExe @Args 2>&1
        $exit = $LASTEXITCODE
        $outputText = if ($out -is [array]) { $out -join "`n" } else { [string]$out }
        return @{ ExitCode = $exit; Output = $outputText; Command = "$attribExe $($Args -join ' ')" }
    } catch {
        return @{ ExitCode=-1; Output=$_.Exception.Message; Command="$attribExe $($Args -join ' ')" }
    }
}

<#
.SYNOPSIS
Requests a OneDrive file/folder to become locally available.
.PARAMETER Path
The path to the file or folder.
.PARAMETER RetryAttrib
Number of times to retry the attrib command (default 1).
.EXAMPLE
Set-OneDriveLocalAvailable -Path "C:\Users\User\OneDrive\file.txt"
#>

function Set-OneDriveLocalAvailable {
    [CmdletBinding()]
    param([Parameter(Mandatory=$true)][string]$Path, [int]$RetryAttrib=1)
    try { $full = Resolve-FullPath -Path $Path } catch { Write-Warning "Resolve-FullPath failed: $($_.Exception.Message)"; return $false }
    try { if (-not (Test-OneDriveOnlineOnly -Path $full)) { return $true } } catch { Write-Warning "Test-OneDriveOnlineOnly failed: $($_.Exception.Message)"; return $false }
    $last=$null
    for ($i=1; $i -le [Math]::Max(1,$RetryAttrib); $i++) { 
        $last = Invoke-AttribInternal -Args @('+p', $full)
        if ($last.ExitCode -eq 0) { return $true }
        Start-Sleep -Milliseconds 200
    }
    Write-Warning "Set-OneDriveLocalAvailable: attrib +p failed for '$full'. Last exit: $($last.ExitCode)."
    return $false
}

<#
.SYNOPSIS
Requests a OneDrive file/folder to become online-only.
.PARAMETER Path
The path to the file or folder.
.PARAMETER RetryAttrib
Number of times to retry the attrib command (default 1).
.EXAMPLE
Clear-OneDriveLocalSpace -Path "C:\Users\User\OneDrive\file.txt"
#>

function Clear-OneDriveLocalSpace {
    [CmdletBinding()]
    param([Parameter(Mandatory=$true)][string]$Path, [int]$RetryAttrib=1)
    try { $full = Resolve-FullPath -Path $Path } catch { Write-Warning "Resolve-FullPath failed: $($_.Exception.Message)"; return $false }
    try { if (Test-OneDriveOnlineOnly -Path $full) { return $true } } catch { Write-Warning "Test-OneDriveOnlineOnly failed: $($_.Exception.Message)"; return $false }
    $last=$null
    for ($i=1; $i -le [Math]::Max(1,$RetryAttrib); $i++) { 
        $last = Invoke-AttribInternal -Args @('-p', $full)
        if ($last.ExitCode -eq 0) { return $true }
        Start-Sleep -Milliseconds 200
    }
    Write-Warning "Clear-OneDriveLocalSpace: attrib -p failed for '$full'. Last exit: $($last.ExitCode)."
    return $false
}

#endregion

#region Wait Helpers

<#
.SYNOPSIS
Waits for OneDrive file/folder attribute state (local or online-only).
.PARAMETER Path
The path to the file or folder.
.PARAMETER Mode
Target mode: 'BecomeLocal' or 'BecomeOnlineOnly'.
.PARAMETER TimeoutSeconds
Timeout in seconds (0 = wait forever).
.PARAMETER CheckIntervalSeconds
Interval between checks (seconds).
.PARAMETER RetryAttribOnLoop
If set, re-issues attrib command on each loop.
.PARAMETER RetryAttrib
Number of attempts for each attrib execution.
.EXAMPLE
Wait-ForAttributeState_AttribOnly -Path "C:\file.txt" -Mode "BecomeLocal"
#>

function Wait-ForAttributeState_AttribOnly {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)][string]$Path,
        [ValidateSet('BecomeLocal','BecomeOnlineOnly')][string]$Mode,
        [int]$TimeoutSeconds = 0,
        [int]$CheckIntervalSeconds = 3,
        [switch]$RetryAttribOnLoop,
        [int]$RetryAttrib = 1
    )

    try { $full = Resolve-FullPath -Path $Path } 
    catch { Write-Error "Resolve-FullPath failed: $($_.Exception.Message)"; return $false }

    $action = if ($Mode -eq 'BecomeLocal') { '+p' } else { '-p' }

    for ($r=1; $r -le [Math]::Max(1,$RetryAttrib); $r++) {
        $startRes = Invoke-AttribInternal -Args @($action, $full)
        if ($startRes.ExitCode -eq 0) { break }
        Start-Sleep -Milliseconds 150
    }

    $start = [DateTime]::UtcNow

    while ($true) {
        Start-Sleep -Seconds $CheckIntervalSeconds

        try {
            $curOnline = Test-OneDriveOnlineOnly -Path $full
            if (($Mode -eq 'BecomeLocal' -and -not $curOnline) -or ($Mode -eq 'BecomeOnlineOnly' -and $curOnline)) { return $true }
        } catch { }

        if ($RetryAttribOnLoop) {
            for ($r=1; $r -le [Math]::Max(1,$RetryAttrib); $r++) { Invoke-AttribInternal -Args @($action, $full) | Out-Null; Start-Sleep -Milliseconds 150 }
        }

        if ($TimeoutSeconds -gt 0) {
            $elapsed = ([DateTime]::UtcNow - $start).TotalSeconds
            if ($elapsed -ge $TimeoutSeconds) { 
                Write-Warning "Wait-ForAttributeState_AttribOnly timed out after $TimeoutSeconds s for '$full' (Mode=$Mode)."
                return $false
            }
        }
    }
}

<#
.SYNOPSIS
Waits until a OneDrive file/folder becomes locally available.
#>

function Wait-OneDriveLocalAvailable {
    param(
        [Parameter(Mandatory=$true)][string]$Path,
        [int]$TimeoutSeconds=0,
        [int]$CheckIntervalSeconds=3,
        [switch]$RetryAttribOnLoop,
        [int]$RetryAttrib=1
    )
    return Wait-ForAttributeState_AttribOnly -Path $Path -Mode 'BecomeLocal' -TimeoutSeconds $TimeoutSeconds -CheckIntervalSeconds $CheckIntervalSeconds -RetryAttribOnLoop:$RetryAttribOnLoop -RetryAttrib $RetryAttrib
}

<#
.SYNOPSIS
Waits until a OneDrive file/folder becomes online-only.
#>

function Wait-ClearOneDriveLocalSpace {
    param(
        [Parameter(Mandatory=$true)][string]$Path,
        [int]$TimeoutSeconds=0,
        [int]$CheckIntervalSeconds=3,
        [switch]$RetryAttribOnLoop,
        [int]$RetryAttrib=1
    )
    return Wait-ForAttributeState_AttribOnly -Path $Path -Mode 'BecomeOnlineOnly' -TimeoutSeconds $TimeoutSeconds -CheckIntervalSeconds $CheckIntervalSeconds -RetryAttribOnLoop:$RetryAttribOnLoop -RetryAttrib $RetryAttrib
}

#endregion