Functions/GenXdev.AI.Queries/Invoke-ImageMetadataUpdate.ps1

###############################################################################
<#
.SYNOPSIS
Updates EXIF metadata for images in a directory.
 
.DESCRIPTION
This function extracts and updates EXIF metadata for images in specified directories.
It processes each image to extract detailed EXIF metadata including camera details,
GPS coordinates, exposure settings, and other technical information. The metadata
is stored in alternate NTFS streams as :EXIF.json for later use by indexing and
search functions.
 
.PARAMETER ImageDirectories
Array of directory paths to process for image metadata updates.
 
.PARAMETER RetryFailed
Specifies whether to retry previously failed image metadata updates.
 
.PARAMETER OnlyNew
If specified, only processes images that don't already have metadata files or
have empty metadata files.
 
.PARAMETER Recurse
If specified, processes images in the specified directory and all
subdirectories recursively.
 
.PARAMETER Force
Force rebuilding of metadata even if it already exists.
 
.PARAMETER PassThru
Return structured objects instead of outputting to console.
 
.PARAMETER SessionOnly
Use alternative settings stored in session for AI preferences like Language,
Image collections, etc.
 
.PARAMETER ClearSession
Clear alternative settings stored in session for AI preferences like Language,
Image collections, etc.
 
.PARAMETER SkipSession
Don't use alternative settings stored in session for AI preferences like
Language, Image collections, etc.
 
.PARAMETER PreferencesDatabasePath
Database path for preference data files.
 
.EXAMPLE
Invoke-ImageMetadataUpdate -ImageDirectories @("C:\Photos", "D:\Pictures") -Force
 
.EXAMPLE
Invoke-ImageMetadataUpdate @("C:\Photos", "C:\Archive") -Force -PassThru | Export-Csv -Path metadata-log.csv
#>

###############################################################################
function Invoke-ImageMetadataUpdate {

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    [Alias('imagepropdetection')]

    param(
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            Position = 0,
            HelpMessage = 'Array of directory paths to process for image metadata updates'
        )]
        [string[]] $ImageDirectories = @('.\'),
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Will retry previously failed image metadata updates'
        )]
        [switch] $RetryFailed,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Only process images that don''t already have metadata files'
        )]
        [switch] $OnlyNew,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'If specified, processes images in subdirectories recursively'
        )]
        [switch] $Recurse,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Force rebuilding of metadata even if it already exists'
        )]
        [Alias('ForceRebuild')]
        [switch] $Force,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Return structured objects instead of outputting to console'
        )]
        [Alias('pt')]
        [switch]$PassThru,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = ('Use alternative settings stored in session for ' +
                'AI preferences like Language, Image collections, etc')
        )]
        [switch] $SessionOnly,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = ('Clear alternative settings stored in session ' +
                'for AI preferences like Language, Image collections, etc')
        )]
        [switch] $ClearSession,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = ('Don''t use alternative settings stored in ' +
                'session for AI preferences like Language, Image ' +
                'collections, etc')
        )]
        [Alias('FromPreferences')]
        [switch] $SkipSession,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Database path for preference data files'
        )]
        [Alias('DatabasePath')]
        [string] $PreferencesDatabasePath
        ###############################################################################
    )

    begin {
        # process each directory provided
        $processedDirectories = @()
        foreach ($directory in $ImageDirectories) {
            # resolve the absolute path for the image directory
            $path = GenXdev.FileSystem\Expand-Path $directory

            # check if the specified directory exists
            if (-not (Microsoft.PowerShell.Management\Test-Path $path -PathType Container)) {
                Microsoft.PowerShell.Utility\Write-Warning "Directory not found: $path - skipping"
                continue
            }

            $processedDirectories += $path
            Microsoft.PowerShell.Utility\Write-Verbose (
                "Processing images in directory: $path"
            )
        }

        if ($processedDirectories.Count -eq 0) {
            Microsoft.PowerShell.Utility\Write-Warning "No valid directories found to process"
            return
        }

        # Set flags for processing behavior

        Microsoft.PowerShell.Utility\Write-Verbose "Starting metadata extraction for images..."
    }

    process {
        # process each validated directory
        foreach ($path in $processedDirectories) {
            Microsoft.PowerShell.Utility\Write-Verbose "Processing directory: $path"

            # discover all image files in the specified directory path, selectively
            # applying recursion only if the -Recurse switch was provided
            # get all supported image files from the specified directory
            $imageTypes = @(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".tif")
            $findParams = GenXdev.Helpers\Copy-IdenticalParamValues `
                -BoundParameters $PSBoundParameters `
                -FunctionName "GenXdev.FileSystem\Find-Item" `
                -DefaultValues (Microsoft.PowerShell.Utility\Get-Variable -Scope Local -ErrorAction SilentlyContinue)

            # Add NoRecurse parameter if Recurse was not specified
            if (-not $Recurse) {
                $findParams['NoRecurse'] = $true
            }

            # Get all image files matching the criteria
            GenXdev.FileSystem\Find-Item @findParams -PassThru -SearchMask "$path\*" -Directory:$false | Microsoft.PowerShell.Core\Where-Object {
                $imageTypes.IndexOf(([IO.Path]::GetExtension($_.FullName).ToLowerInvariant())) -ge 0
            } | Microsoft.PowerShell.Core\ForEach-Object {
                try {
                    $imagePath = $_.FullName

                    # Generate the NTFS alternate stream path for metadata
                    $metadataStream = "${imagePath}:EXIF.json"

                    # Check if we have valid existing content (for RetryFailed logic)
                    $fileExists = [System.IO.File]::Exists($metadataStream)
                    $hasValidContent = $false
                    if ($fileExists) {
                        try {
                            $content = [System.IO.File]::ReadAllText($metadataStream)
                            $existingData = $content | Microsoft.PowerShell.Utility\ConvertFrom-Json -ErrorAction SilentlyContinue
                            # Content is valid if it's successful processing OR if it has actual metadata (not just failure JSON)
                            $hasValidContent = (($null -ne $existingData) -and ($existingData.PSObject.Properties.Count -gt 0)) -and
                                              (-not ($existingData.success -eq $false))
                        }
                        catch {
                            $hasValidContent = $false
                        }
                    }

                    # Check if we should process this image
                    # Process if: Force OR not OnlyNew OR file doesn't exist OR (RetryFailed and no valid content)
                    $shouldProcess = $Force -or
                                    (-not $OnlyNew) -or
                                    (-not $fileExists) -or
                                    ($RetryFailed -and (-not $hasValidContent))

                    if (-not $shouldProcess) {

                        return;
                    }

                    # Validate file exists and is accessible before processing
                    if (-not [System.IO.File]::Exists($imagePath)) {
                        Microsoft.PowerShell.Utility\Write-Warning "Image file not found: $imagePath"
                        return
                    }

                    # Check file size - skip very large files that might cause memory issues
                    $fileInfo = [System.IO.FileInfo]::new($imagePath)
                    if ($fileInfo.Length -gt 1024*1024*300) {
                        Microsoft.PowerShell.Utility\Write-Warning "Skipping large file (${fileInfo.Length} bytes): $imagePath"
                        return
                    }

                    # Test if file is readable by trying to open it
                    try {
                        $testStream = [System.IO.File]::OpenRead($imagePath)
                        $testStream.Close()
                        $testStream.Dispose()
                    }
                    catch {
                        Microsoft.PowerShell.Utility\Write-Warning "Cannot read file (may be locked or corrupted): $imagePath"
                        return
                    }

                    # Extract metadata using Get-ImageMetadata with proper error handling
                    try {
                        $metadata = GenXdev.Helpers\Get-ImageMetadata -ImagePath $imagePath -ErrorAction Stop
                    }
                    catch {
                        # Handle out of memory and other metadata extraction errors
                        Microsoft.PowerShell.Utility\Write-Warning "Failed to process metadata for ${imagePath}: $($_.Exception.Message)"

                        # Write failure JSON to prevent infinite retries
                        try {
                            $failureMetadata = @{
                                success = $false
                                has_metadata = $false
                                processed_at = (Microsoft.PowerShell.Utility\Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
                                error = "Metadata extraction failed: $($_.Exception.Message)"
                            }
                            $failureJson = $failureMetadata | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 -Compress
                            [System.IO.File]::WriteAllText($metadataStream, $failureJson)
                        }
                        catch {
                            Microsoft.PowerShell.Utility\Write-Verbose "Failed to write error metadata for ${imagePath}: $($_.Exception.Message)"
                        }
                        return
                    }

                    if ($null -eq $metadata) {

                        # Write empty metadata JSON to indicate no metadata available
                        $emptyMetadata = @{
                            success = $true
                            has_metadata = $false
                            processed_at = (Microsoft.PowerShell.Utility\Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
                            error = "No EXIF metadata found"
                        }
                        $emptyJson = $emptyMetadata | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 -Compress

                        # Safely write to alternate data stream
                        try {
                            [System.IO.File]::WriteAllText($metadataStream, $emptyJson)
                        }
                        catch {
                            Microsoft.PowerShell.Utility\Write-Warning "Failed to write metadata stream for ${imagePath}: $($_.Exception.Message)"
                            return
                        }

                        Microsoft.PowerShell.Utility\Write-Warning "No metadata found for image: $imagePath"
                        return;
                    }

                    # Convert metadata to JSON and store in alternate stream
                    $metadataJson = $metadata | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 -Compress

                    # Safely write to alternate data stream
                    try {
                        [System.IO.File]::WriteAllText($metadataStream, $metadataJson)
                    }
                    catch {
                        Microsoft.PowerShell.Utility\Write-Warning "Failed to write metadata stream for ${imagePath}: $($_.Exception.Message)"
                        return
                    }

                    # Create result object for PassThru
                    $result = [PSCustomObject]@{
                        Path = $imagePath
                        Success = $true
                        HasMetadata = $true
                        ProcessedAt = Microsoft.PowerShell.Utility\Get-Date
                        MetadataSize = $metadataJson.Length
                        CameraMake = $metadata.Camera.Make
                        CameraModel = $metadata.Camera.Model
                        HasGPS = ($null -ne $metadata.GPS.Latitude -and $null -ne $metadata.GPS.Longitude)
                        GPSLatitude = $metadata.GPS.Latitude
                        GPSLongitude = $metadata.GPS.Longitude
                    }

                    if ($PassThru) {

                        Microsoft.PowerShell.Utility\Write-Output $result
                    } else {

                        Microsoft.PowerShell.Utility\Write-Verbose "Processed metadata for: $imagePath"
                    }
                }
                catch {
                    # write failure JSON to prevent infinite retries without -RetryFailed
                    try {
                        $failureData = @{
                            success = $false
                            has_metadata = $false
                            processed_at = (Microsoft.PowerShell.Utility\Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
                            error = "Metadata extraction failed: $($_.Exception.Message)"
                        }

                        $failureJson = $failureData | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress -Depth 10
                        [System.IO.File]::WriteAllText($metadataStream, $failureJson)
                    }
                    catch {
                        # If we can't even write the failure JSON, just log it
                        Microsoft.PowerShell.Utility\Write-Verbose "Failed to write error metadata for ${imagePath}: $($_.Exception.Message)"
                    }

                    $errorMessage = "Failed to process metadata for $imagePath : $($_.Exception.Message)"
                    Microsoft.PowerShell.Utility\Write-Warning $errorMessage

                    if ($PassThru) {

                        $result = [PSCustomObject]@{
                            Path = $imagePath
                            Success = $false
                            HasMetadata = $false
                            ProcessedAt = Microsoft.PowerShell.Utility\Get-Date
                            Error = $_.Exception.Message
                        }
                        Microsoft.PowerShell.Utility\Write-Output $result
                    }
                }
            }
        }
    }

    end {
    }
}
###############################################################################