Scripts/GFK.Image.ExifTool.psm1

#Requires -PSEdition Core
#Requires -Module GFK.Image

Set-StrictMode -Version Latest

class ImageMetadata {
    [string] $FilePath
    [PSObject] $Tags
}

function Get-ImageMetadata {
    <#
    .SYNOPSIS
        Uses ExifTool for getting tags or shortcut tags by tag names
    .DESCRIPTION
        This command outputs metadata tags for one or multiple files
    .PARAMETER FilePaths
        One or multiple strings representing paths to image files. Can be full file names, directories, and can contain wildcards
        If one or more paths contain explicit extensions, then only files with those extensions will be considered
        e.g. '.' will read from all files in the current directory, but '*.mpg','.' will only read from .mpg files
        It is possible to specify multiple extensions that way e.g. '*.jpg','*.mpg'
    .PARAMETER SourceFiles
        This parameter enables including sidecars, which are useful to manage metadata files that cannot be written (e.g. RAW, movies)
        The syntax is defined by ExifTool (https://www.exiftool.org/metafiles.html)
        If you include at least a value, it is recommended to include '@' or only the sidecar will be read
        If a sidecar does not exist it is ignored
        Order is important as for each image file, tags will be read in the first source file that exist
    .PARAMETER TagNames
        List of tags as defined in ExifTool (https://www.exiftool.org/TagNames/index.html) or custom tags as defined in the configuration
        Do not prefix with '-'!
    .PARAMETER Simple
        Optional, sets the mode to simple (see examples for more details)
    .PARAMETER Full
        Optional, sets the mode to full (see examples for more details)
    .PARAMETER Groups
        Enables grouping in full mode and controls how metadata groups should be named
        These values will be concatenated and passed to the ExifTool -g parameter
        See https://www.exiftool.org/exiftool_pod.html#Input-output-text-formatting for the -g parameter
        See https://www.exiftool.org/ExifTool.html#GetGroup for groups definitions
        Example: if the requested tag is XMP:CreateDate
        - 0 will result in the group name being 'XMP'
        - 1 will result in the group name being 'XMP-xmp'
        - 0,1 will result in the group name being 'XMP:XMP-xmp'
        This will influence how deduplication work for tag results
        For instance if a file contains a value for EXIF:CreateDate, XMP-xmp:CreateDate and XMP-exif:CreateDate
        there would be two results for @(0) and 3 results for @(1) or @(0,1)
        When grouping is enabled without specifying groups, ExifTool defaults to group family 0.
    .PARAMETER Recurse
    Recurse in subdirectories. The option will have no effect if the path is not a directory
    .PARAMETER ConfigurationPath
    Optional configuration path. See example here: https://www.exiftool.org/config.html
    .EXAMPLE
    There are two modes (parameter sets) in which this command can run:

    PS C:\> Get-ImageMetadata -FilePath 'C:\Users\Gordon Freeman\Pictures\Black Mesa Research Center.jpg' -TagName Artist, CreateDate
    The command returns one string per tag per file as an array except if one of the tags used resolves to multiple tags
    Choose this if you want the fastest mode to use with fully defined single files and without shortcut tags
    Note: when using this for multiple files or multiple tags, since the tag names are not in the output, it is recommended to fully
    qualify tag names and not use shortcut tags as if multiple matches occur it will not be obvious which tags they belong to
    For instance CreateDate will return two rows if both EXIF:CreateDate and XMP:CreateDate are set, therefore it is preferable to specify
    EXIF:CreateDate

    PS C:\> Get-ImageMetadata -FilePath 'C:\Users\Gordon Freeman\Pictures\Black Mesa Research Center.jpg' -TagName Artist, CreateDate -Full -Groups 0
    In full mode, the command returns one ImageMetadata object per file found (each contains the file path and a Tags property)
    The Tags property contains flat tag properties by default. Use -Groups to return tags under their top-level groups.
    There will be no file or tag collisions in this mode
    Choose this if you want to handle multiple files and want an exhaustive report on the tags available on the file
    .NOTES
    - You can show the actual ExifTool command with the -Verbose switch
    #>

    [CmdletBinding(PositionalBinding = $false, DefaultParameterSetName = 'Simple')]
    [OutputType([string[]], ParameterSetName = 'Simple')]
    [OutputType([ImageMetadata[]], ParameterSetName = 'Full')]
    Param(
        [SupportsWildcards()]
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]] $FilePaths,

        [string[]] $SourceFiles = @(),

        [Parameter(Mandatory)]
        [string[]] $TagNames,

        [Parameter(ParameterSetName = 'Simple')]
        [switch] $Simple,

        [Parameter(ParameterSetName = 'Full')]
        [switch] $Full,

        [Parameter(ParameterSetName = 'Full')]
        [uint[]] $Groups,

        [switch] $Recurse,

        [string] $ConfigurationPath
    )

    Begin {
        Test-ExifTool
    }

    Process {
        if ($ConfigurationPath) {
            # Add configuration file
            $arguments = @('-config', $ConfigurationPath)
        }
        else {
            $arguments = @()
        }
        
        # Add source files
        $arguments += $SourceFiles | ForEach-Object { '-srcfile', $_ }

        # If one or multiple paths contain extensions, explicitly set extensions
        $arguments += $FilePaths |
        Split-Path -Extension |
        Where-Object { $_ } |
        Select-Object -Unique |
        ForEach-Object { '-ext', $_ }

        if ($Recurse) {
            # Add recursion
            $arguments += '-r'
        }
        
        # Add tags
        $arguments += $TagNames | Get-TagNameArgument

        if ($PsCmdlet.ParameterSetName -eq 'Full') {
            if ($PSBoundParameters.ContainsKey('Groups')) {
                $arguments += "-g$($Groups -join ':')"
            }
        }
        else {
            $arguments += '-G'
        }

        # Extract all tags including composite and unknown, format results by file and group as json with signed GPS coordinates
        $arguments += '-a', '-f', '-u', '-j', '-c', '%+.6f'

        # Ignore minor errors, terminate argument list and add file paths
        $arguments += @('-m', '--'; $FilePaths)
    
        # -Verbose support
        Write-Verbose "exiftool $(($arguments | Foreach-Object { "'$($_ -replace "'","''")'" }) -join ' ')"

        # Read JSON
        $toolResults = (&exiftool $arguments) -join "`n" | ConvertFrom-Json
        if ($PsCmdlet.ParameterSetName -eq 'Full') {
            # Build structured results
            return $toolResults | ConvertTo-ImageMetadata
        }
        else {
            # Show values only
            return $toolResults | ConvertTo-ValuesOnly
        }
    }
}

function Set-ImageMetadata() {
    <#
    .SYNOPSIS
        Uses ExifTool for setting tags or shortcut tags
    .DESCRIPTION
        This command writes metadata tags to one or multiple files
    .PARAMETER FilePaths
        One or multiple strings representing paths to image files. Can be full file names, directories, and can contain wildcards.
        If one or more paths contain explicit extensions, then only files with those extensions will be considered
        e.g. '.' will write to all files in the current directory, but '*.mpg','.' will only write to .mpg files
        It is possible to specify multiple extensions that way e.g. '*.jpg','*.mpg'
    .PARAMETER SourceFiles
        This parameter enables including sidecars, which are useful to manage metadata files that cannot be written (e.g. RAW, movies)
        The syntax is defined by ExifTool (https://www.exiftool.org/metafiles.html)
        If you include at least a value, it is recommended to include '@' or only the sidecar will be read
        If a sidecar does not exist it is ignored
        Order is important as for each image file, tags will be read in the first source file that exist
    .PARAMETER Tags
        Hashtable where the keys are expected to be tag names as defined in ExifTool (https://www.exiftool.org/TagNames/index.html),
        or custom tags as defined in the configuration, and the value associated to each key is the value to be set on the tag
        Do not prefix tag names with '-'!
        Tag values that are arrays will be concatenated with ';'
        Note that the values can be the name of other tags (for copying tags) and contain advanced formatting as defined by ExifTool
        This can even include Perl commands (https://www.exiftool.org/exiftool_pod.html#Advanced-formatting-feature)
    .PARAMETER Recurse
        Recurse in subdirectories. The option will have no effect if the path is not a directory
    .PARAMETER ConfigurationPath
        Optional configuration path. See example here: https://www.exiftool.org/config.html
    .EXAMPLE
        Set-ImageMetadata `
            -FilePath 'C:\Users\Gordon Freeman\Pictures\Black Mesa Research Center.jpg' `
            -Tags @{
                Artist = 'Gordon Freeman','Adrian Shephard';
                '-XMP-xmp:MetadataDate' = Get-Date
            }
    .NOTES
        - You can show the actual ExifTool command with the -Verbose switch, and show only the command without running
        it with -Verbose -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess, PositionalBinding = $false)]
    Param(
        [SupportsWildcards()]
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$FilePaths,

        [string[]] $SourceFiles = @(),

        [Parameter(Mandatory)]
        [hashtable] $Tags,

        [switch] $Recurse,

        [string] $ConfigurationPath
    )

    Begin {
        Test-ExifTool
    }

    Process {
        if ($ConfigurationPath) {
            # Add configuration file
            $arguments = @('-config', $ConfigurationPath)
        }
        else {
            $arguments = @()
        }

        # Default to making changes in place
        $arguments += '-overwrite_original'

        # Add source files
        $arguments += $SourceFiles | ForEach-Object { '-srcfile', $_ }

        # If one or multiple paths contain extensions, explicitly set extensions
        $arguments += $FilePaths |
        Split-Path -Extension |
        Where-Object { $_ } |
        Select-Object -Unique |
        ForEach-Object { '-ext', $_ }

        # Define tag assignements
        $index = 0
        foreach ($tagName in $Tags.Keys) {
            # Shortcut tag values cannot be set with the `=` operator and need to be set with the `<` operator.
            # In turn, the `<` operator does not play well with constant values when they contain special characters such as `$`.
            # Therefore to support both constant and non-constant values on tags and on shortcut tags, we use the '-userParam' option.
            $tagNameArgument = Get-TagNameArgument -TagName $tagName
            $paramName = "Param$(($index++))"
            $tagValue = ConvertTo-TagValue -Value $Tags[$tagName]
            $arguments += "$tagNameArgument<`$$paramName", '-userParam', "$paramName=$tagValue"
        }

        # Ignore minor errors, terminate argument list and add file paths
        $arguments += @('-m', '--'; $FilePaths)
    
        # -Verbose support
        Write-Verbose "exiftool $(($arguments | Foreach-Object { "'$($_ -replace "'","''")'" }) -join ' ')"

        # -WhatIf support
        if ($PSCmdlet.ShouldProcess($FilePaths)) {
            &exiftool $arguments
        }    
    }
}

function ConvertFrom-TagDateTime {
    <#
    .SYNOPSIS
        Converts a metadata date/time or date+time into a [datetime] object
    .DESCRIPTION
        Relies on ExifTool's default formats for such fields. Supports EXIF (naive full date), IPTC (date + naive or local time), XMP (naive or local full date)
    .EXAMPLE
        ConvertFrom-TagDateTime -Date '2022:01:19' -Time '15:16:17'
        ConvertFrom-TagDateTime -DateTime '2022:01:19 15:16:17+03:00'
        ConvertFrom-TagDateTime -DateTime (Get-ImageMetadata $filePath XMP:CreateDate)
    #>

    [CmdletBinding(PositionalBinding = $false)]
    [OutputType([datetime])]
    Param (
        [Parameter(Mandatory, ParameterSetName = 'OneField', ValueFromPipelineByPropertyName)]
        [string]$DateTime,

        [Parameter(Mandatory, ParameterSetName = 'TwoFields', ValueFromPipelineByPropertyName)]
        [string]$Date,

        [Parameter(Mandatory, ParameterSetName = 'TwoFields', ValueFromPipelineByPropertyName)]
        [string]$Time
    )

    Process {
        if ($PsCmdlet.ParameterSetName -EQ 'TwoFields') {
            $DateTime = "$Date $Time"
        }
        $formats = @('yyyy:MM:dd HH:mm:ss', 'yyyy:MM:dd HH:mm:sszzz')
        return [datetime]::ParseExact($DateTime, [string[]] $formats, [System.Globalization.CultureInfo]::InvariantCulture)
    }
}

#region Private functions

function Test-ExifTool {
    if (-not (Get-Command exiftool)) {
        throw 'ExifTool not found in the environment Path'
    }
}

function Get-TagNameArgument {
    [CmdletBinding(PositionalBinding = $false)]
    [OutputType([string])]
    Param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$TagName
    )

    Process {
        if (-not ($TagName -match '^(?:[\w-]+:)?\w+$')) {
            throw "Expected 'TagName' or 'Namespace:TagName' but found '$TagName'"
        }
        return "-$TagName"    
    }
}

function ConvertFrom-TagValue {
    [CmdletBinding(PositionalBinding = $false)]
    Param (
        [Parameter(ValueFromPipeline)]
        [string]$TagValue
    )

    Process {
        if ($TagValue -eq '-') {
            return ''
        }
        return $TagValue
    }
}

function ConvertTo-TagValue {
    [CmdletBinding(PositionalBinding = $false)]
    Param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [object]$Value
    )

    Process {
        if ($Value -is [datetime] -or $Value -is [System.DateTimeOffset]) {
            return '{0:yyyy-MM-ddTHH:mm:sszzz}' -f $Value
        }
    
        if ($Value -is [array]) {
            return $Value -join ', '
        }
    
        return [string] $Value    
    }
}

function ConvertTo-ImageMetadata {
    [CmdletBinding(PositionalBinding = $false)]
    Param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSObject] $Tags
    )

    Process {
        $sourceFile = $Tags.SourceFile
        $Tags.PSObject.Properties.Remove('SourceFile')
        if ($Tags | Get-Member Unknown) {
            # ExifTool replaces missing values with '-', reverting this
            $Tags.Unknown |
            Get-Member -MemberType NoteProperty |
            Select-Object -ExpandProperty Name |
            Foreach-Object { $Tags.Unknown.$($_) = $Tags.Unknown.$($_) | ConvertFrom-TagValue }
        }

        return [ImageMetadata] @{
            FilePath = $sourceFile;
            Tags     = $Tags
        }
    }
}

function ConvertTo-ValuesOnly {
    [CmdletBinding(PositionalBinding = $false)]
    Param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSObject] $Tags
    )

    Process {
        # Using PSObject.Properties to list the properties instead of Get-Member incidentally preserves the order
        # in which the original json is written which itself preserves the order in which the tags were requested
        return $Tags.PSObject.Properties |
        Where-Object MemberType -EQ NoteProperty |
        Select-Object -ExpandProperty Name |
        Where-Object {$_ -ne 'SourceFile' } |
        ForEach-Object {
            $value = $Tags.$($_)
            # ExifTool replaces missing values with '-', reverting this
            if (($_ -split ':')[0] -eq 'Unknown') {
                return ($value | ConvertFrom-TagValue)
            }
            else {
                return $value
            }
        }
    }
}

#endregion