Resource: EXIF date based file renaming script

User interface
The script’s user interface. Image source: private.

Script to rename image files based on EXIF tags

Description

This script can be used to bulk rename image files based on the date they were taken according to the EXIF tags of the files. The interesting aspect is that you can add or subtract an offset in seconds, minutes or hours, which is extremely useful when you travel across different time zones and forget to adjust your camera’s clock accordingly. Exactly that happened to me one holiday, so I went and created this script.

Everything is controlled through a nice user interface (a simple form). The script comes with a sample set of settings and filters which it writes into an XML file in its directory. You can change the contents of this XML file to match your needs, so you don’t have to scan your files every time you use the script.

N.B.:

  • If you click somewhere during a scan or rename run, the progress bar freezes, but the script keeps running in the background. This is some nasty behaviour of Windows Forms when used within PowerShell – PowerShell is not aimed for graphical user interfaces…
  • On my Surface tablet running Windows 10, the dialogs are not displayed properly when running the script from the 64bit PowerShell ISE. If run from the command line it works fine.

+++ Update 13 June 2022 +++

The script has been updated, the original version contained a bug regarding the filtering for file types.

Script source code

<# 
.SYNOPSIS

    Parse folder for JPG / any files, read EXIF date/time and create filenames YYYY-MM-DD_hh-mm-ss.jpg.
    Fallback if no EXIF data: use last change time stamp. (c) 2020 Dirk Klassen.

.DESCRIPTION

    This script renames JPEG and other files according to the timestamp taken from the EXIF data.
    It uses the EXIF tag 0x9003 DateTimeOriginal for renaming, other tags are read and logged.
    If no EXIF data is found, the last modification time stamp is taken.
    The file name pattern used is YYYY-MM-DD_hh-mm-ss.jpg, with seconds added to the timestamp until
    the resulting name is unique.
    It displays a dialog where you can select a folder which contains the images. After that,
    you can scan the folder for existing EXIF data to be used for the make and model selection.
    After selecting filters for make, model and file name prefix, and setting a time offset to
    be added to or subtracted from the original time stamp, you can start the renaming of the 
    matching images. Duplicate files names are prevented by continuously single seconds to the 
    time stamp until the resulting name is unique.

    Default settings are stored in an XML file, by default settings.xml, in the script folder. 
    A sample file looks like this:

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Management.Automation.PSCustomObject</T>
      <T>System.Object</T>
    </TN>
    <MS>
      <S N="FolderPath">\\NAS\share\User\Eigene Dateien\Eigene Bilder</S>
      <S N="Makes">*,FUJIFILM,HUAWEI,Sony</S>
      <S N="Models">*,ANE-LX1,U9200,D6603,FinePix F500EXR</S>
      <S N="FilePatterns">*,_threema*,CIMG*,DSC*,DSCF*,IMG*</S>
    </MS>
  </Obj>
</Objs>

    Please note that all settings except FolderPath are comma seperated lists and require at least 
    a single * as value, and the file patterns require a trailing *. If loading of the file fails,
    a sample file is automatically created.

.INPUTS

    No input objects expected from pipeline.

.OUTPUTS

    No objects delivered to pipeline. See logfile for script results.

.EXAMPLE

    Outside PowerShell: powershell -WindowStyle hidden -file Set-FileName-EXIF.ps1

.NOTES

    Bugs, Ideas (in descending order of priority):

    - Save Path, Makes and/or Models to file or Windows Registry for future use
      > Decision: Use file instead of registry for easier copying / editing. 
        Idea: XML? Will work only for an object holding all settings. Use Export-/Import-CliXml to save / load the Object
      + First step: Use file as input for defaults only
      > Second step: use file also for saving settings offering various options:
        > Option 1: Overwrite settings
        > Option 2: Add current settings to existing
    + Rename mp4 videos as well (production date can be derived from file properties)
    + Integrate progress dialogue in main dialogue
      > Dismussed, PS event model too complicated.
      > Free up space by moving how-to section into seperate dialogue
      > Move contents of progress dialogue to freed space
      > Change file name info to "old name > new name"
      > When starting scanning or renaming, disable all other controls
      > Add stop button to stop scanning or renaming action and enable all other controls again
    + BUG: Leading Underscore is now also detected as pattern
    + Split status info in two parts: Numbers above progress bar, file name below
    + BUG: Invalid EXIF tags need to be set to "", e.g. time stamps in the Paris pictures
      > Store tags as found and use try...catch when processing the tags
      > Fallback for invalid tags can be defined during processing
    + Add GPS tags to list of retrieved EXIF data
      > Dismissed, values are split across four tags and hard to interpret
          > First, for logging
          > Second, for filtering
    + Add direct link to log file, or button for opening it
      > Makes no sense, as the dialog is displayed as the topmost window
    + Add radio button to select which of the three EXIF timestamps to use
      > Dismissed, will be too seldomly used
    + Add name of current file to scan / rename progress dialogs
    + Prevent resizing of dialogs
    + Add "break" handler to enable interrupting of scan and rename actions
	  > Idea: Event handler for cancel button sets global variable, which is checked in for-each loop
        > Does not work, because of the thread handling of forms and button event handlers. The Click event interrupts the script flow
          and does not return to the script after the handler code was executed
      > The CancelButton property of the form object is of no use, it just assigns a button
    + Add icons to main, scan and rename dialogs
      > No longer necessary, because the standard icon is gone together with controls in the the upper right corner
      > A background image might make sense - at some time in the future. Maybe.
    + Make the user interface a bit more intuitive and user friendly by better grouping / aligning of the elements
    + Include also video files (.mov, .avi, .mpg, .mp4) for renaming (only based on file timestamp as there are no meta data in video files)
      > Idea: Instead of statically adding numerous possible file extensions, use the scan feature to add only the existing ones,
        or just add a radio button "[x] JPG only  [ ] All files". A radio button would be easier, because then the value can immediately
        be used as input for the file filter (JPG only > .jpg, Allfiles > .*)
      > Now implemented with radio button
    + Export image data to file (.csv in addition to log file)
      > No, because CSV data is already in the log file and the required section just needs to be copied out
    + Change buttons to "Quit" and "Rename" to enable multiple renaming operations after scanning
      > Done
    + Use "DateTimeOriginal" instead of "DateTime" to ensure correct name (not last change)
      > Fixed
      > Additionally added DateTimeDigitized to list of EXIF tags 
    + Improve log file with some stats (total, renamed, name kept, timestamp increased, ...)
      > Implemented
    + Parse files in directory to set available Maker, Model and prefix values for filtering 
      (ScanOnly() function needed, Main() would need too many alterations)
      > Implemented for Make, Model and Pattern
      > Lines for optical seperation implemented
    + Add Minutes and Seconds to time stamp offset for fine tuning
      > Done
    + Use better dialog to select folder
      > Not possible using standard file dialogs
    + Enter offset to be added to hour of timestamp for file name creation 
      > Implemented
    + Write log file 
      > Done

    Interesting EXIF tags:
    0x0000 GPSLatitude        ascii string      Latitude deg/min/sec
    0x0001 GPSLatitudeRef     ascii string      Latitude N/S
    0x0002 GPSLongitude       ascii string      Longitude deg/min/sec
    0x0003 GPSLongitudeRef    ascii string      Longitude E/W
    0x010f Make               ascii string      Shows manufacturer of digicam
    0x0110 Model              ascii string      Shows model number of digicam
    0x0132 DateTime           ascii string  20  Date/Time of image was last modified. Data format is "YYYY:MM:DD HH:MM:SS"+0x00, total 20bytes. In usual, it has the same value of DateTimeOriginal(0x9003)
    0x9003 DateTimeOriginal   ascii string  20  Date/Time of original image taken. This value should not be modified by user program.
    0x9004 DateTimeDigitized  ascii string  20  Date/Time of image digitized. Usually, it contains the same value of DateTimeOriginal(0x9003).

    Forms tutorials:
    http://serverfixes.com/powershell-forms-part1
    https://blogs.technet.microsoft.com/stephap/2012/04/23/building-forms-with-powershell-part-1-the-form/
    https://www.martinlehmann.de/wp/download/powershell-gui-programmierung-fur-dummies-step-by-step/
    https://www.techotopia.com/index.php/Drawing_Graphics_using_PowerShell_1.0_and_GDI%2B
    https://docs.microsoft.com/de-de/dotnet/api/system.drawing.graphics.drawline?view=netframework-4.8

#>

param (

    # Initial value of the folder holding the images
    $FolderPath = "\\NAS\share\User\Eigene Dateien\Eigene Bilder",
    # Name of the settings file
    $SettingsFile = "Settings.xml",
    # Initial value for the file type, valid values are "*" and "jpg"
    $FilterType = "jpg",
    # Initial value for the manufacturer filter
    $FilterMake = "*",
    # Initial value for the model filter
    $FilterModel = "*",
    # Initial value for the file name pattern
    $FilterFileName = "*",
    # Initial value for the hours to be added to / subtracted from the time stamp
    $OffsetHours = 0,
    # Initial value for the minutes to be added to / subtracted from the time stamp
    $OffsetMinutes = 0,
    # Initial value for the seconds to be added to / subtracted from the time stamp
    $OffsetSeconds = 0,
    # Default value for opening the log on exiting
    $OpenLogOnExit = $false

)

# All variables need to be declared before they can be used
Set-StrictMode -Version "2.0"
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms

# Known camera makes + models, used for sample file and as fall back
$SampleMakes = @("*", "FUJIFILM", "HUAWEI", "Panasonic", "Sony")
$SampleModels = @("*", "ANE-LX1", "U9200", "D6603", "DMC-FZ330", "FinePix F500EXR")
$SampleFilePatterns = @("*", "CIMG*", "DSC*", "DSCF*", "IMG*", "P*")
$Makes = @("*")
$Models = @("*")
$FilePatterns = @("*")

# For converting time stamps
$DateTimePattern = "yyyy:MM:dd HH:mm:ss"
$DateTimePatternMP4 = "ddMMyyyyHHmm"

# New line character for log output
$crlf = [Environment]::NewLine
$RetCode = $false

# For Error handling
$_ErrorActionPreference = $ErrorActionPreference 
$_DebugPreference = $DebugPreference

# Get path and name of current script
$scriptDir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent
$CurrentScriptName = $MyInvocation.MyCommand.Name

# Settings file
$SettingsFileName = "$scriptDir\$SettingsFile"

# For file handling
$LogFileName = "$scriptDir\$CurrentScriptName.log"
$OverwriteLog = $true

# Some statistics
$Stats = @{
    'Files total' = 0
    'Files matching pattern' = 0
    'Files matching criteria' = 0
    'Files renamed (total)' = 0
    'Files renamed (EXIF)' = 0
    'Files renamed (file TS)' = 0
    'Files not renamed' = 0
    'Duplicates resolved' = 0
}

# Write log message with timestamp to console
function WriteLog {
param([String]$LogMessage,
      [Bool] $Overwrite = $False)

	$TimeStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
	$Error.Clear()
	Try
	{
		if ($Overwrite) {
			Write-Output "$TimeStamp - $LogMessage" | Out-File $LogFileName -Encoding "Default"
		} else {
			Write-Output "$TimeStamp - $LogMessage" | Out-File $LogFileName -Encoding "Default" -Append
		}
	}
	Catch
	{
	    $Exception = $Error | Select-Object -Property Exception
		Write-Error "Writing to log file $LogFileName failed: $($Exception.Exception.ToString())"
	}

}

# Expand error message and write to console and log
function ExpandError {
param([String]$ErrorMessage)

    $Exception = $Error | Select-Object -Property Exception
    $ExceptionString = $Exception.Exception.ToString()
	WriteLog "$ErrorMessage"
	WriteLog $ExceptionString
	$Error.Clear()

}

# Initialize script environment
function Initialize {

	# Enable try..catch
	$Script:ErrorActionPreference = "Stop"
	$Script:DebugPreference = "Continue"
	
	WriteLog "+ + + Starting script execution" $OverwriteLog

	return $TRUE

}

# Tidy up everything
function CleanUp {

    WriteLog "Some statistics about this script run:"
    $Script:Stats | Sort-Object | Out-File $LogFileName -Encoding default -Append    
    
    WriteLog "+ + + Ending script execution"

	# Restore error handling setting
	$Script:ErrorActionPreference = $_ErrorActionPreference 
	$Script:DebugPreference = $_DebugPreference

	return $TRUE

} 

# Create sample settings XML file
function WriteSettings {

	WriteLog "Creating sample settings file as $SettingsFileName"
    $Error.Clear()
	Try
	{
        # Create one settings object to store the settings in
        $SampleSettings = New-Object -TypeName PSObject
        # Default path containing the images
        $SampleSettings | Add-Member -MemberType NoteProperty -Name "FolderPath" -Value $FolderPath
        # Convert arrays to strings to have them in one XML tag, not in an object structure
        $SampleSettings | Add-Member -MemberType NoteProperty -Name "Makes" -Value ($SampleMakes -join ",")
        $SampleSettings | Add-Member -MemberType NoteProperty -Name "Models" -Value ($SampleModels -join ",")
        $SampleSettings | Add-Member -MemberType NoteProperty -Name "FilePatterns" -Value ($SampleFilePatterns -join ",")
        Export-Clixml -Path $SettingsFileName -InputObject $SampleSettings -Encoding Default -Force
        WriteLog "Created sample settings file $SettingsFileName"
	}
	Catch
	{
		ExpandError "Failed creating settings file $SettingsFileName"
	}

}

# Read settings from settings XML file
# If it fails, deliver fall-back values and try to create sample file
function GetSettings {

	WriteLog "Getting settings from settings file $SettingsFileName"
    $Error.Clear()
	Try
	{
        # Import settings from XML file
        $Settings = Import-Clixml -Path $SettingsFileName
        $Script:FolderPath = [string]$Settings.Folderpath
        $Script:Makes = [array]$Settings.Makes -split ","
        $Script:Models = [array]$Settings.Models -split ","
        $Script:FilePatterns = [array]$Settings.FilePatterns -split ","
        WriteLog "Found the following settings:"
        $Settings | Out-File $LogFileName -Encoding default -Append
	}
	Catch
	{
		ExpandError "Failed getting settings from file $SettingsFileName, using fall-back values"
        $Script:FolderPath = [environment]::getfolderpath("MyPictures")
        $Script:Makes = $SampleMakes
        $Script:Models = $SampleModels
        $Script:FilePatterns = $SampleFilePatterns
        WriteSettings
	}

}

# Read EXIF properties from image files
# If no EXIF tags are found, empty strings are returned
function Get-ExifProperties
{
param
(
    [string] $ImagePath
)
 
    $EXIFtags = @{
        ID = 0x010f;
        Name = "Make";
        Value = ""
    },
    @{
        ID = 0x0110;
        Name = "Model";
        Value = ""
    },
    @{
        ID = 0x0132;
        Name = "DateTime";
        Value = ""
    },
    @{
        ID = 0x9003;
        Name = "DateTimeOriginal";
        Value = ""
    },
    @{
        ID = 0x9004;
        Name = "DateTimeDigitized";
        Value = ""
    }

    # For mp4 files
    $PropertyID = 208

    $fullPath = $ImagePath
    $folderPath = Split-Path -Path $ImagePath -Parent
    $FileName = Split-Path -Path $ImagePath -Leaf

    # Add elseif for MP4 getting property 208
    if ($fullPath -like "*.jpg") {

        WriteLog "Opening $fullPath to retrieve the EXIF tags"
 
        $fs = [System.IO.File]::OpenRead($fullPath)
        $image = [System.Drawing.Image]::FromStream($fs, $false, $false)

        foreach ($EXIFtag in $EXIFtags) { 
            #WriteLog "Checking for EXIF tag $($EXIFtag.ID) ($($EXIFtag.Name))"
            if (-not $image.PropertyIdList.Contains($EXIFtag.ID))
            {
                $EXIFtag.Value = ""
            } else {
                $propertyItem = $image.GetPropertyItem($EXIFtag.ID)
                $valueBytes = $propertyItem.Value
                $EXIFtag.Value = [System.Text.Encoding]::ASCII.GetString($valueBytes) -replace "`0$"
            }
        }
    
        $fs.Close()

    } elseif ($fullPath -like "*.mp4") {

        WriteLog "Opening $fullPath to retrieve the meta data"

        $shell = New-Object -ComObject "Shell.Application"
        $ObjDir = $shell.NameSpace($folderPath)
        $ObjFile = $ObjDir.parsename($FileName)

        If($ObjDir.GetDetailsOf($ObjFile, $PropertyID)) #To avoid empty values
        {
            $MetaData = [string]$ObjDir.GetDetailsOf($ObjFile, $PropertyID)
        }

        # Tag contains some nasty Unicode characters
        $EXIFtags[3].Value = $MetaData -replace "\D", ""
                 

    } else {

        WriteLog "$fullPath is not a JPG image or MP4 video, returning an empty set of tags"

    }

    return $EXIFtags
}


function RenameFile {
param (
    $ImageFile
)
    $OldFullName = $ImageFile.FullName
    $OldExtension = ([System.IO.Path]::GetExtension($OldFullName)).ToLower()
    $PathDelim = "\"

    # If no EXIF timestamp is given, use last write time from file system
    # > In else, use try...catch (with the current else branch in try and the current if branch in catch to not generate unnecessary errors)
    if ($ImageFile.DateTimeOriginal -eq "") {
        $OldDateTime = [datetime]::ParseExact($ImageFile.LastWriteTime, $DateTimePattern, [Globalization.CultureInfo]::InvariantCulture)
        $hasEXIF = $False
    } else {
        try {
            if ($OldExtension -eq ".mp4") {
                $OldDateTime = [datetime]::ParseExact($ImageFile.DateTimeOriginal, $DateTimePatternMP4, [Globalization.CultureInfo]::InvariantCulture)
            } else {
                $OldDateTime = [datetime]::ParseExact($ImageFile.DateTimeOriginal, $DateTimePattern, [Globalization.CultureInfo]::InvariantCulture)
            }
            $hasEXIF = $True
        }
        catch {
            ExpandError "$($ImageFile.Name) has invalid EXIF timestamp, using file change timestamp"
            $OldDateTime = [datetime]::ParseExact($ImageFile.LastWriteTime, $DateTimePattern, [Globalization.CultureInfo]::InvariantCulture)
            $hasEXIF = $False
        }
    }

    # First, add the offset to the hours; later, increment by single seconds until file name is unique
    $NewDateTime = (($OldDateTime.AddHours($OffsetHours)).AddMinutes($OffsetMinutes)).AddSeconds($OffsetSeconds)

    # Create new file name from time stamp
    $NewName = $NewDateTime.ToString($DateTimePattern) + "$OldExtension" -replace ":","-" -replace " ","_"
    $NewNameTmp = $NewName
    # Make sure to have only one backslash between path and file name
    if ($FolderPath -like "*\") {
        $PathDelim = ""
    }
    $NewFullName = $FolderPath + $PathDelim + $NewName

    
    if ($OldFullName -eq $NewFullName) {
        
        WriteLog "$NewName already has the correct name (based on EXIF: $hasEXIF)"
        $Script:Stats.'Files not renamed' += 1

    } else {

        # Check for new file name already existing, and increment seconds in case of
        while ((Test-Path $NewFullName) -eq $True) {
            WriteLog "New name $NewNameTmp for $($ImageFile.Name) already exists, incrementing seconds to make it unique"
            $NewDateTime = $NewDateTime.AddSeconds(1)
            $NewNameTmp = $NewDateTime.ToString($DateTimePattern) + "$OldExtension" -replace ":","-" -replace " ","_"
            $NewFullName = $FolderPath + $PathDelim + $NewNameTmp
            $Script:Stats.'Duplicates resolved' += 1
        }
        $NewName = $NewNameTmp
	
        WriteLog "Old: $($ImageFile.Name) - New: $NewName (based on EXIF: $hasEXIF)"

	    $Error.Clear()
        try {
            Rename-Item -Path $OldFullName -NewName $NewName #-WhatIf
            $Script:Stats.'Files renamed (total)' += 1
            if ($hasEXIF) {
                $Script:Stats.'Files renamed (EXIF)' += 1
            } else {
                $Script:Stats.'Files renamed (file TS)' += 1
            }
        }
        catch {
            [void] [Windows.Forms.MessageBox]::Show("Error renaming $crlf $OldFullName, see $crlf $LogFileName $crlf for details")
            ExpandError "Error renaming $OldFullName"
        }
    
    }

    $ImageFile | Add-Member -MemberType NoteProperty -Name "NewName" -Value $NewName
    return $ImageFile

}

# Select a folder to be scanned, starting point is the $FolderPath from the command line
function SelectFolder {
param ( 
    $FolderPath
)

    $FolderBrowser = New-Object System.Windows.Forms.FolderBrowserDialog
    # RootFolder only works with "Windows Special Folders"
    #$FolderBrowser.RootFolder = $FolderPath
    $FolderBrowser.SelectedPath = $FolderPath
    $FolderBrowser.Description = "Select folder containing pictures to be renamed"
    $FolderBrowser.ShowNewFolderButton = $false
 
    $result = $FolderBrowser.ShowDialog((New-Object System.Windows.Forms.Form -Property @{TopMost = $true }))
    if ($result -eq [Windows.Forms.DialogResult]::OK){
        return $FolderBrowser.SelectedPath
    }
    else {
        return $FolderPath
    }
 
}

# Display dialog to enter the parameters needed for script execution, pre-set by commandline params
function SetParameters {

    # Main form
    $objForm = New-Object System.Windows.Forms.Form
    #$objForm.Backcolor="WhiteSmoke"
    $objForm.Backcolor="White"
    #$objForm.Backcolor="lightblue"
    $objForm.StartPosition = "CenterScreen"
    $objForm.Size = New-Object System.Drawing.Size(805,620)
    $objForm.Text = "EXIF tag based image renaming tool - (c) 2020, 2021, 2022 Dirk Klassen"
    $objForm.ControlBox = $false
    $objForm.Topmost = $True
    $objForm.FormBorderStyle = "Fixed3D" # Options: Fixed3D, FixedDialog, FixedSingle, FixedToolWindow (requires $Form.ShowInTaskbar = $False)

    # Activate and bring to front on showing
    $objForm.Add_Shown({$objForm.Activate()})

    # For some fancy stuff
    $objBrush = New-Object Drawing.SolidBrush black
    $objPen = New-Object Drawing.Pen black
    $objPen.Width = 1
    $objGraphics = $objForm.CreateGraphics()

    # Divider lines
    $objForm.Add_Paint({
        $objGraphics.DrawLine($objPen, 100, 135, 660, 135)
        $objGraphics.DrawLine($objPen, 100, 230, 660, 230)
        $objGraphics.DrawLine($objPen, 100, 370, 660, 370)
        $objGraphics.DrawLine($objPen, 100, 450, 660, 450)
    })

    # Main form intro text
    $objLabelInfo = New-Object System.Windows.Forms.Label
    $objLabelInfo.Location = New-Object System.Drawing.Size(100,50) 
    $objLabelInfo.Size = New-Object System.Drawing.Size(600,85) 
    $objLabelInfo.Text = @"
1. Select the folder containing the files you want to rename, and the file type
2. Optionally, scan the folder for camera makes and models and available file patterns
3. If required, choose a camera make OR model, and/or a file pattern to filter for
4. Set a number of hours/minutes/seconds to be added or subtracted to the EXIF timestamp
5. Click "Rename Files", wait for the "Done!" message, repeat any of the steps, or quit
"@
    $objForm.Controls.Add($objLabelInfo) 

    # Path: Field label
    $objLabelFolder = New-Object System.Windows.Forms.Label
    $objLabelFolder.Location = New-Object System.Drawing.Size(100,150) 
    $objLabelFolder.Size = New-Object System.Drawing.Size(450,20) 
    $objLabelFolder.Text = "Please enter/select the folder path and select the file type:"
    $objForm.Controls.Add($objLabelFolder) 

    # Path: field
    $objTextboxFolder = New-Object System.Windows.Forms.Textbox 
    $objTextboxFolder.Location = New-Object System.Drawing.Size(100,170) 
    $objTextboxFolder.Size = New-Object System.Drawing.Size(450,25) 
    $objForm.Controls.Add($objTextboxFolder) 
    $objTextboxFolder.Text = $FolderPath

    # Path: Select Button 
    $SelectButton = New-Object System.Windows.Forms.Button
    $SelectButton.Location = New-Object System.Drawing.Size(560,168)
    $SelectButton.Size = New-Object System.Drawing.Size(100,25)
    $SelectButton.Text = "Select..."
    $SelectButton.Name = "SelectFolder"
    $SelectButton.BackColor = "WhiteSmoke"
    #$SelectButton.DialogResult = "OK" # Ansonsten wird Fenster geschlossen
    $SelectButton.Add_Click({ $objTextboxFolder.Text = SelectFolder($objTextboxFolder.Text) })
    $objForm.Controls.Add($SelectButton) 

    # File type: JPG
    $RadioButtonJPG = New-Object System.Windows.Forms.RadioButton
    $RadioButtonJPG.Location = New-Object System.Drawing.Size(5,8)
    #$RadioButtonJPG.size = New-Object System.Drawing.Size(350,20)
    $RadioButtonJPG.Checked = $true 
    $RadioButtonJPG.Text = ".jpg only"

    # File type: MP4
    $RadioButtonMP4 = New-Object System.Windows.Forms.RadioButton
    $RadioButtonMP4.Location = New-Object System.Drawing.Size(125,8)
    #$RadioButtonMP4.size = New-Object System.Drawing.Size(350,20)
    $RadioButtonMP4.Checked = $false
    $RadioButtonMP4.Text = ".mp4 only"

    # File type: All
    $RadioButtonAll = New-Object System.Windows.Forms.RadioButton
    $RadioButtonAll.Location = New-Object System.Drawing.Size(245,8)
    #$RadioButtonAll.size = New-Object System.Drawing.Size(350,20)
    $RadioButtonAll.Checked = $false
    $RadioButtonAll.Text = "All files"

    # File type selection: Radio button group
    $RadioButtonsType = New-Object System.Windows.Forms.GroupBox
    $RadioButtonsType.Location = New-Object System.Drawing.Size(100,190) 
    $RadioButtonsType.Size = New-Object System.Drawing.Size(400,35) 
    $objForm.Controls.Add($RadioButtonsType) 
    $RadioButtonsType.Controls.AddRange(@($RadioButtonJPG, $RadioButtonMP4, $RadioButtonAll))
    $RadioButtonJPG.SendToBack()

    # Make: Field label
    $objLabelMake = New-Object System.Windows.Forms.Label
    $objLabelMake.Location = New-Object System.Drawing.Size(100,250) 
    $objLabelMake.Size = New-Object System.Drawing.Size(245,20) 
    $objLabelMake.Text = "Select the camera make (* for all):"
    $objForm.Controls.Add($objLabelMake) 

    # Make: Field
    $objComboboxMake = New-Object System.Windows.Forms.Combobox 
    $objComboboxMake.Location = New-Object System.Drawing.Size(100,270) 
    $objComboboxMake.Size = New-Object System.Drawing.Size(200,25) 
    $objComboboxMake.DropDownStyle = 2  # ComboBoxStyle.DropDownList, prevents editing (default is 1, DropDown)
    $objForm.Controls.Add($objComboboxMake) 
    $objComboboxMake.Items.AddRange($Script:Makes) 
    $objComboboxMake.SelectedItem = "*"
    $objComboboxMake.Add_SelectedIndexChanged({ if ($objComboboxMake.SelectedItem -ne "*") { $objComboboxModel.SelectedItem = "*" } })

    # Model: Field label
    $objLabelModel = New-Object System.Windows.Forms.Label
    $objLabelModel.Location = New-Object System.Drawing.Size(330,250) 
    $objLabelModel.Size = New-Object System.Drawing.Size(280,20) 
    $objLabelModel.Text = "Select the camera model (* for all):"
    $objForm.Controls.Add($objLabelModel) 
    $objLabelMake.SendToBack()

    # Model: Field
    $objComboboxModel = New-Object System.Windows.Forms.Combobox 
    $objComboboxModel.Location = New-Object System.Drawing.Size(330,270) 
    $objComboboxModel.Size = New-Object System.Drawing.Size(200,25) 
    $objComboboxModel.DropDownStyle = 2  # ComboBoxStyle.DropDownList, prevents editing (default is 1, DropDown)
    $objForm.Controls.Add($objComboboxModel) 
    $objComboboxModel.Items.AddRange($Script:Models) 
    $objComboboxModel.SelectedItem = "*"
    $objComboboxModel.Add_SelectedIndexChanged({ if ($objComboboxModel.SelectedItem -ne "*") { $objComboboxMake.SelectedItem = "*" } })
    $objComboboxModel.SendToBack()

    # Scan Button 
    $ScanButton = New-Object System.Windows.Forms.Button
    $ScanButton.Location = New-Object System.Drawing.Size(560,268)
    $ScanButton.Size = New-Object System.Drawing.Size(100,25)
    $ScanButton.Text = "Scan Files"
    $ScanButton.Name = "Scan Files"
    $ScanButton.BackColor = "Yellow"
    #$ScanButton.DialogResult = "OK" # Ansonsten wird Fenster geschlossen
    $ScanButton.Add_Click({ 
        $Script:FolderPath = $objTextboxFolder.Text
        $Script:FilterType = if ($RadioButtonJPG.Checked) { "jpg" } elseif ($RadioButtonMP4.Checked) { "mp4" } else { "*" }
        ScanOnly | Out-Null
        $objComboboxMake.Items.Clear()
        $objComboboxMake.Items.AddRange(($Script:Makes | Sort-Object)) 
        $objComboboxMake.SelectedItem = "*"
        $objComboboxModel.Items.Clear() 
        $objComboboxModel.Items.AddRange(($Script:Models | Sort-Object)) 
        $objComboboxModel.SelectedItem = "*"
        $objComboboxName.Items.Clear() 
        $objComboboxName.Items.AddRange(($Script:FilePatterns | Sort-Object)) 
        $objComboboxName.SelectedItem = "*"
        #$objTextboxName.Text
    })
    $objForm.Controls.Add($ScanButton) 
    $ScanButton.BringToFront()

    # Name pattern: Field label
    $objLabelName = New-Object System.Windows.Forms.Label
    $objLabelName.Location = New-Object System.Drawing.Size(100,310) 
    $objLabelName.Size = New-Object System.Drawing.Size(600,20) 
    $objLabelName.Text = "Provide a file name pattern (* for all, file extension will be added automatically):"
    $objForm.Controls.Add($objLabelName) 

    # Name pattern: Field
    $objComboboxName = New-Object System.Windows.Forms.Combobox 
    $objComboboxName.Location = New-Object System.Drawing.Size(100,330) 
    $objComboboxName.Size = New-Object System.Drawing.Size(200,25) 
    $objComboboxName.DropDownStyle = 1
    $objComboboxName.Items.AddRange($Script:FilePatterns) 
    $objComboboxName.SelectedItem = "*"
    $objComboboxName.Add_SelectedIndexChanged({ $objComboboxModel.SelectedItem = "*" })
    $objForm.Controls.Add($objComboboxName) 

    # Time offset: Field label
    $objLabelOffset = New-Object System.Windows.Forms.Label
    $objLabelOffset.Location = New-Object System.Drawing.Size(100,390) 
    $objLabelOffset.Size = New-Object System.Drawing.Size(600,20) 
    $objLabelOffset.Text = "Select the hours, minutes and seconds to be added/subtracted (0 for no change):"
    $objForm.Controls.Add($objLabelOffset) 

    # Time offset (hours): Field
    $objNumericUpDownOffsetHours = New-Object System.Windows.Forms.NumericUpDown 
    $objNumericUpDownOffsetHours.Location = New-Object System.Drawing.Size(100,410) 
    $objNumericUpDownOffsetHours.Size = New-Object System.Drawing.Size(80,25) 
    $objNumericUpDownOffsetHours.Value = $OffsetHours
    $objNumericUpDownOffsetHours.Minimum = -23
    $objNumericUpDownOffsetHours.Maximum = 23
    $objForm.Controls.Add($objNumericUpDownOffsetHours) 

    # Time offset (minutes): Field
    $objNumericUpDownOffsetMinutes = New-Object System.Windows.Forms.NumericUpDown 
    $objNumericUpDownOffsetMinutes.Location = New-Object System.Drawing.Size(200,410) 
    $objNumericUpDownOffsetMinutes.Size = New-Object System.Drawing.Size(80,25) 
    $objNumericUpDownOffsetMinutes.Value = $OffsetMinutes
    $objNumericUpDownOffsetMinutes.Minimum = -23
    $objNumericUpDownOffsetMinutes.Maximum = 23
    $objForm.Controls.Add($objNumericUpDownOffsetMinutes) 

    # Time offset (Seconds): Field
    $objNumericUpDownOffsetSeconds = New-Object System.Windows.Forms.NumericUpDown 
    $objNumericUpDownOffsetSeconds.Location = New-Object System.Drawing.Size(300,410) 
    $objNumericUpDownOffsetSeconds.Size = New-Object System.Drawing.Size(80,25) 
    $objNumericUpDownOffsetSeconds.Value = $OffsetSeconds
    $objNumericUpDownOffsetSeconds.Minimum = -59
    $objNumericUpDownOffsetSeconds.Maximum = 59
    $objForm.Controls.Add($objNumericUpDownOffsetSeconds) 

    # OK Button 
    $OKButton = New-Object System.Windows.Forms.Button
    $OKButton.Location = New-Object System.Drawing.Size(560,408)
    $OKButton.Size = New-Object System.Drawing.Size(100,25)
    $OKButton.Text = "Rename Files"
    $OKButton.Name = "Rename Files"
    $OKButton.BackColor = "green"
    $OKButton.ForeColor = "White"
    #$OKButton.DialogResult = "OK" # Ansonsten wird Fenster geschlossen
    $OKButton.Add_Click({ 
        $Script:FilterType = if ($RadioButtonJPG.Checked) { "jpg" } elseif ($RadioButtonMP4.Checked) { "mp4" } else { "*" }
        $Script:FolderPath = $objTextboxFolder.Text
        $Script:FilterMake = $objComboboxMake.SelectedItem
        $Script:FilterModel = $objComboboxModel.SelectedItem
        $Script:FilterFileName = $objComboboxName.Text
        $Script:OffsetHours = $objNumericUpDownOffsetHours.Value
        $Script:OffsetMinutes = $objNumericUpDownOffsetMinutes.Value
        $Script:OffsetSeconds = $objNumericUpDownOffsetSeconds.Value
        Main | Out-Null
    })
    $objForm.Controls.Add($OKButton) 
    $objLabelOffset.SendToBack()

    # Log file: Checkbox
    $objCheckboxLog = New-Object System.Windows.Forms.Checkbox 
    $objCheckboxLog.Location = New-Object System.Drawing.Size(100,470) 
    $objCheckboxLog.Size = New-Object System.Drawing.Size(200,25)
    $objCheckboxLog.Text = "Open log file when done"
    $objCheckboxLog.Checked = $OpenLogOnExit
    $objForm.Controls.Add($objCheckboxLog)

    # Quit button
    $QuitButton = New-Object System.Windows.Forms.Button
    $QuitButton.Location = New-Object System.Drawing.Size(560,470)
    $QuitButton.Size = New-Object System.Drawing.Size(100,25)
    $QuitButton.Text = "Quit"
    $QuitButton.Name = "Quit"
    $QuitButton.BackColor = "red"
    $QuitButton.ForeColor = "White"
    $QuitButton.DialogResult = "Abort"
    $QuitButton.Add_Click({ 
        $Script:OpenLogOnExit = $objCheckboxLog.Checked
        $objForm.Close() 
    })
    $objForm.Controls.Add($QuitButton) 
    $objForm.CancelButton = $QuitButton
         
    [void] $objForm.ShowDialog()

}

# This function scans all image files for their EXIF properties
function ScanOnly {

    $Progress = 0

    # Create a progress bar
    $objForm = New-Object System.Windows.Forms.Form
    $objForm.Backcolor="WhiteSmoke"
    $objForm.StartPosition = "CenterScreen"
    $objForm.Size = New-Object System.Drawing.Size(700,250)
    $objForm.Text = "Work in progress"
    $objForm.Name = 'ProgressScanning'
    $objForm.FormBorderStyle = "Fixed3D" # Options: Fixed3D, FixedDialog, FixedSingle, FixedToolWindow (requires $Form.ShowInTaskbar = $False)
    $objForm.ControlBox = $false

    # File count
    $StatusCount = New-Object System.Windows.Forms.Label
    $StatusCount.Location = New-Object System.Drawing.Size(50,65) 
    $StatusCount.Size = New-Object System.Drawing.Size(600,20) 
    $StatusCount.TextAlign = "TopCenter"
    $StatusCount.Text = "0 / x"
    $objForm.Controls.Add($StatusCount) 

    $progressBar1 = New-Object System.Windows.Forms.ProgressBar
    $progressBar1.Name = 'progressBar1'
    $progressBar1.Value = $Progress
    $progressBar1.Minimum = 0
    $progressBar1.Step = 1
    $progressBar1.Text = [math]::Round($Progress)
    $progressBar1.Style="Continuous"
    $progressBar1.Location = New-Object System.Drawing.Size(50,90) 
    $progressBar1.Size = New-Object System.Drawing.Size(600,20) 
    $objForm.Controls.Add($progressBar1) 
    $objForm.Topmost = $True

    # Name of file currently worked on
    $StatusFileName = New-Object System.Windows.Forms.Label
    $StatusFileName.Location = New-Object System.Drawing.Size(50,120) 
    $StatusFileName.Size = New-Object System.Drawing.Size(600,20) 
    $StatusFileName.TextAlign = "TopCenter"
    $StatusFileName.Text = "Opening folder..."
    $objForm.Controls.Add($StatusFileName) 

    # Activate and bring to front on showing
    $objForm.Add_Shown({$objForm.Activate()})

    # All imagefiles and their (exif) properties
    $ImageFiles = @()

    # All images found in target folder
    $AllImages = Get-ChildItem -Path $FolderPath | Where-Object {$_.Name -like "*.$($FilterType)"} | Sort-Object -Property Name

    $ImagesTotal = ($AllImages | Measure-Object).Count
    $ImagesProcessed = 0
    $Progress = 0

    if ($ImagesTotal -gt 0) {

        # Pre-set the filters with wildcard
        $Script:Makes = @("*")
        $Script:Models = @("*")
        $Script:FilePatterns = @("*")

        $Script:Stats.'Files total' = $ImagesTotal

        $progressBar1.Maximum = $ImagesTotal
        $objForm.Text = "Scanning $ImagesTotal jpg files in folder..."
        [void] $objForm.Show()
        WriteLog "Start scanning $ImagesTotal files for EXIF properties as filter values"
        foreach ($CurrentImage in $AllImages) {
    
            $StatusCount.Text = "$($ImagesProcessed + 1) / $ImagesTotal"
            $StatusFileName.Text = "$($CurrentImage.Name)"
            
            # Refresh before each step for better user experience
            $objForm.Refresh()

            $ImageFile = New-Object -TypeName PSObject
            $ImageFile | Add-Member -MemberType NoteProperty -Name Name -Value $CurrentImage.Name -Force
            $FilePrefix = ($ImageFile.Name -replace ($ImageFile.Name -replace "^[_a-z]*", ""), "") + "*"

            $ExifProperties = Get-ExifProperties $CurrentImage.FullName
            foreach ($ExifProperty in $ExifProperties) {

                $ImageFile | Add-Member -MemberType NoteProperty -Name $ExifProperty.Name -Value $ExifProperty.Value -Force

            }
            $ImageFile | Add-Member -MemberType NoteProperty -Name LastWriteTime -Value (($CurrentImage.LastWriteTime).ToString($DateTimePattern)) -Force
            $ImageFile | Add-Member -MemberType NoteProperty -Name FullName -Value $CurrentImage.FullName -Force

            $ImageFiles += $ImageFile

            # Add found makes, models and patterns to selection lists
            if ($ImageFile.Make -ne "" -and $Script:Makes -notcontains $ImageFile.Make) {
                $Script:Makes += $ImageFile.Make
            }
            if ($ImageFile.Model -ne "" -and $Script:Models -notcontains $ImageFile.Model) {
                $Script:Models += $ImageFile.Model
            }
            if ($FilePrefix -ne "" -and $Script:FilePatterns -notcontains $FilePrefix) {
                $Script:FilePatterns += $FilePrefix
            }

            $ImagesProcessed += 1
            $Progress = $ImagesProcessed / $ImagesTotal * 100
            #$progressBar1.Value = $Progress
            $progressBar1.PerformStep()
            $progressBar1.Text = [math]::Round($Progress)

        }
        
        
        # Write all file details to the log file
        WriteLog "Got the following file properties"
        $ImageFiles | ConvertTo-Csv -NoTypeInformation | Out-File $LogFileName -Encoding default -Append

        [void] $objForm.Hide()


    } else {
        WriteLog "Found no images in target folder $FolderPath"
    }

    # Write all file details to the log file
    WriteLog "Found the following Makes:"
    $Script:Makes | Out-File $LogFileName -Encoding default -Append
    WriteLog "Found the following Models:"
    $Script:Models | Out-File $LogFileName -Encoding default -Append
    WriteLog "Found the following file patterns:"
    $Script:FilePatterns | Out-File $LogFileName -Encoding default -Append

    [void] $objForm.Close()

    [void] [Windows.Forms.MessageBox]::Show("Scanning of $ImagesProcessed files done!","Done",0,[System.Windows.Forms.MessageBoxIcon]::Information)

}

# This function controls the main features
function Main {

    $Progress = 0

    # Create a progress bar
    $objForm = New-Object System.Windows.Forms.Form
    $objForm.Backcolor="lightcyan"
    $objForm.StartPosition = "CenterScreen"
    $objForm.Size = New-Object System.Drawing.Size(700,250)
    $objForm.Text = "Work in progress"
    $objForm.ControlBox = $False
    $objForm.FormBorderStyle = "Fixed3D" # Options: Fixed3D, FixedDialog, FixedSingle, FixedToolWindow (requires $Form.ShowInTaskbar = $False)

    # File count
    $StatusCount = New-Object System.Windows.Forms.Label
    $StatusCount.Location = New-Object System.Drawing.Size(50,65) 
    $StatusCount.Size = New-Object System.Drawing.Size(600,20) 
    $StatusCount.TextAlign = "TopCenter"
    $StatusCount.Text = "0 / x"
    $objForm.Controls.Add($StatusCount) 

    $progressBar1 = New-Object System.Windows.Forms.ProgressBar
    $progressBar1.Name = 'progressBar1'
    $progressBar1.Value = $Progress
    $progressBar1.Minimum = 0
    $progressBar1.Step = 1
    $progressBar1.Text = [math]::Round($Progress)
    $progressBar1.Style="Continuous"
    $progressBar1.Location = New-Object System.Drawing.Size(50,90) 
    $progressBar1.Size = New-Object System.Drawing.Size(600,20) 
    $objForm.Controls.Add($progressBar1) 

    # Name of file currently worked on
    $StatusFileName = New-Object System.Windows.Forms.Label
    $StatusFileName.Location = New-Object System.Drawing.Size(50,120) 
    $StatusFileName.Size = New-Object System.Drawing.Size(600,20) 
    $StatusFileName.TextAlign = "TopCenter"
    $StatusFileName.Text = "Opening folder..."
    $objForm.Controls.Add($StatusFileName) 

    $objForm.Topmost = $True
    $objForm.Add_Shown({$objForm.Activate()})


    # All imagefiles and their (EXIF) properties
    $ImageFiles = @()

    # Statistics
    $Script:Stats.'Files total' = ((Get-ChildItem -Path $FolderPath | Where-Object {$_.Name -like "*.jpg"}) | Measure-Object).Count

    # All images found in target folder
    $AllImages = Get-ChildItem -Path $FolderPath | Where-Object {$_.Name -like "$($FilterFileName).$($FilterType)"} | Sort-Object -Property Name

    $ImagesTotal = ($AllImages | Measure-Object).Count
    $ImagesProcessed = 0
    $Progress = 0

    # Work on all images matching the file name pattern
    if ($ImagesTotal -gt 0) {

        $Script:Stats.'Files matching pattern' = $ImagesTotal

        $progressBar1.Maximum = $ImagesTotal
        $objForm.Text = "Scanning $ImagesTotal jpg files in folder..."
        [void] $objForm.Show()
        WriteLog "Start scanning $ImagesTotal files matching pattern $FilterFileName in $FolderPath for model $FilterModel and make $FilterMake"
        foreach ($CurrentImage in $AllImages) {
    
            $StatusCount.Text = "$($ImagesProcessed + 1) / $ImagesTotal"
            $StatusFileName.Text = "$($CurrentImage.Name)"
            
            # Refresh before each step for better user experience
            $objForm.Refresh()

            $ImageFile = New-Object -TypeName PSObject
            $ImageFile | Add-Member -MemberType NoteProperty -Name Name -Value $CurrentImage.Name -Force

            $ExifProperties = Get-ExifProperties $CurrentImage.FullName
            foreach ($ExifProperty in $ExifProperties) {

                $ImageFile | Add-Member -MemberType NoteProperty -Name $ExifProperty.Name -Value $ExifProperty.Value -Force

            }
            $ImageFile | Add-Member -MemberType NoteProperty -Name LastWriteTime -Value (($CurrentImage.LastWriteTime).ToString($DateTimePattern)) -Force
            $ImageFile | Add-Member -MemberType NoteProperty -Name FullName -Value $CurrentImage.FullName -Force

            if (($ImageFile.Make -like $FilterMake) -and ($ImageFile.Model -like $FilterModel)) {
                WriteLog "$($ImageFile.Name) matches filter criteria, adding it to list of images"
                $ImageFiles += $ImageFile
                $Script:Stats.'Files matching criteria' += 1
            } else {
                WriteLog "$($ImageFile.Name) does not match filter criteria, skipping it"
            }

            $ImagesProcessed += 1
            $Progress = $ImagesProcessed / $ImagesTotal * 100
            #$progressBar1.Value = $Progress
            $progressBar1.PerformStep()
            $progressBar1.Text = [math]::Round($Progress)

        }
        [void] $objForm.Hide()
    } else {
        WriteLog "Found no images matching pattern $FilterFileName in target folder $FolderPath"
    }

    # Write all file details to the log file
    WriteLog "Got the following file properties"
    $ImageFiles | ConvertTo-Csv -NoTypeInformation | Out-File $LogFileName -Encoding default -Append

    $ImagesTotal = ($ImageFiles | Measure-Object).Count
    $ImagesProcessed = 0
    $Progress = 0

    if ($ImagesTotal -gt 0) {

        $progressBar1.Maximum = $ImagesTotal
        $progressBar1.Value = 0
        $objForm.Text = "Renaming $ImagesTotal files in folder..."
        $StatusCount.Text = "0 / x"
        $StatusFileName.Text = "Opening folder..."
        [void] $objForm.Show()

        WriteLog "Now starting to rename all $ImagesTotal relevant files"
        foreach ($CurrentImage in $ImageFiles) {

            $StatusCount.Text = "$($ImagesProcessed + 1) / $ImagesTotal"
            $StatusFileName.Text = "$($CurrentImage.Name)"
            
            # Refresh before each step for better user experience
            $objForm.Refresh()

            $NewImage = RenameFile ($CurrentImage)
            $ImagesProcessed += 1
            $Progress = $ImagesProcessed / $ImagesTotal * 100
            #$progressBar1.Value = $Progress
            $progressBar1.PerformStep()
            $progressBar1.Text = [math]::Round($Progress)

        }

        [void] $objForm.Hide()

    } else {
        WriteLog "Found no relevant files"
    }

    $Script:Stats.'Files not renamed' = $Script:Stats.'Files matching criteria' - $Script:Stats.'Files renamed (total)'

    [void] $objForm.Close()

    [void] [Windows.Forms.MessageBox]::Show("Renaming of $ImagesProcessed files done!","Done",0,[System.Windows.Forms.MessageBoxIcon]::Information)

}

# + + + Start of main script + + +

# Initialize script environment
Initialize | Out-Null

# Get basic settings
GetSettings | Out-Null

# Set parameters and run renaming jobs
SetParameters | Out-Null

# Tidy up everything
CleanUp | Out-Null

# That's it
if ($OpenLogOnExit) {
    Start-Process $LogFileName
}

Leave a Reply

Your email address will not be published. Required fields are marked *

4 × four =