From 7460bc25b92c6dfe68724ef8a6c76ec4cb4a71f9 Mon Sep 17 00:00:00 2001 From: Nick Domako Date: Mon, 27 Apr 2026 15:37:40 -0400 Subject: [PATCH 1/5] feat(logging,ocr): persistent state/audit log and configurable OCR scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging: - Replace per-run rename-log-{timestamp}.json with rename-state.json (small file, overwritten each run) and rename-log.json (append-only audit log, deduplicated on OriginalFile) - Prior state loading uses a 3-level fallback: state file → audit log → output directory scan - Add -StartOdometer parameter to seed odometer on first-ever run - Remove $reportsDir, $logFile, and timestamped log approach OCR: - Add -ScalePct parameter to Get-OdometerReading; decode image at configurable percentage before OCR (default 25%) - Thread OcrScalePercent setting through Add-OcrToPhotoContext and main body; reads from settings.json key OcrScalePercent closes #22 --- scripts/Rename-Photos.ps1 | 185 ++++++++++++++++++++++++++++---------- 1 file changed, 136 insertions(+), 49 deletions(-) diff --git a/scripts/Rename-Photos.ps1 b/scripts/Rename-Photos.ps1 index 00d07d9..a6dccf5 100644 --- a/scripts/Rename-Photos.ps1 +++ b/scripts/Rename-Photos.ps1 @@ -31,11 +31,18 @@ Maximum plausible vehicle speed in mph. Used with the elapsed time since the last accepted reading to compute an upper bound on the expected odometer delta. +.PARAMETER StartOdometer + Seeds the initial last-known odometer value when no prior state can be found via the + three-level fallback chain (rename-state.json, rename-log.json, output directory scan). + After the first real run completes, rename-state.json is written and this parameter is + never needed again. + .EXAMPLE .\Rename-Photos.ps1 -WhatIf .\Rename-Photos.ps1 .\Rename-Photos.ps1 -Source "D:\Photos\Odometer" -WhatIf .\Rename-Photos.ps1 -Confirm + .\Rename-Photos.ps1 -StartOdometer 224570 #> [CmdletBinding(SupportsShouldProcess)] param( @@ -43,7 +50,8 @@ param( [string]$LocationsJson = "$PSScriptRoot\..\config\locations.json", [string]$ExifToolPath = "$PSScriptRoot\..\exiftool-13.53_64\exiftool.exe", [double]$ProximityThresholdMiles = 1.0, - [double]$MaxSpeedMph = 80 + [double]$MaxSpeedMph = 80, + [int] $StartOdometer ) . "$PSScriptRoot\MileageTrackerHelpers.ps1" @@ -287,6 +295,10 @@ function Get-OdometerReading { Loads the image via WinRT BitmapDecoder, runs it through OcrEngine, and selects the longest digit run as the odometer value. + Decodes the image at a configurable scale percentage before OCR. Downscaling + normalises digit size for close-up photos and smooths pixelation artefacts + from digital displays. Defaults to 25% (suitable for 4032x3024 source images). + Requires Windows PowerShell 5.1. Returns Confidence="error" immediately under PowerShell 6+, which lacks the required WinRT APIs. @@ -303,7 +315,10 @@ function Get-OdometerReading { PSCustomObject with Reading (string), Confidence (string), RawText (string), Digits (string[]), and Error (string) fields. #> - param([string]$ImagePath) + param( + [string]$ImagePath, + [int] $ScalePct = 25 + ) $result = [PSCustomObject]@{ Reading = $null @@ -322,9 +337,15 @@ function Get-OdometerReading { try { Add-Type -AssemblyName System.Runtime.WindowsRuntime - $null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime] - $null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime] - $null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.BitmapTransform, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.BitmapPixelFormat, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.BitmapAlphaMode, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.BitmapInterpolationMode, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.ExifOrientationMode, Windows.Foundation, ContentType=WindowsRuntime] + $null = [Windows.Graphics.Imaging.ColorManagementMode, Windows.Foundation, ContentType=WindowsRuntime] $asTaskMethod = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { @@ -343,43 +364,46 @@ function Get-OdometerReading { $winStream = [System.IO.WindowsRuntimeStreamExtensions]::AsRandomAccessStream($netStream) $decoder = Invoke-WinRTAsync ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($winStream)) ([Windows.Graphics.Imaging.BitmapDecoder]) - $bitmap = Invoke-WinRTAsync ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap]) - - $winStream.Dispose() - $netStream.Dispose() $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages() if (-not $engine) { + $winStream.Dispose() + $netStream.Dispose() $result.Confidence = "error" $result.Error = "OCR engine unavailable" return $result } - $ocrResult = Invoke-WinRTAsync ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult]) + $scale = [Math]::Min(1.0, $ScalePct / 100.0) + $transform = [Windows.Graphics.Imaging.BitmapTransform]::new() + $transform.ScaledWidth = [uint32][Math]::Max(1, [Math]::Round($decoder.PixelWidth * $scale)) + $transform.ScaledHeight = [uint32][Math]::Max(1, [Math]::Round($decoder.PixelHeight * $scale)) + $transform.InterpolationMode = [Windows.Graphics.Imaging.BitmapInterpolationMode]::Linear + + $bitmap = Invoke-WinRTAsync ($decoder.GetSoftwareBitmapAsync( + [Windows.Graphics.Imaging.BitmapPixelFormat]::Bgra8, + [Windows.Graphics.Imaging.BitmapAlphaMode]::Premultiplied, + $transform, + [Windows.Graphics.Imaging.ExifOrientationMode]::IgnoreExifOrientation, + [Windows.Graphics.Imaging.ColorManagementMode]::DoNotColorManage + )) ([Windows.Graphics.Imaging.SoftwareBitmap]) + + $winStream.Dispose() + $netStream.Dispose() + $ocrResult = Invoke-WinRTAsync ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult]) $result.RawText = $ocrResult.Text - # --- digit extraction ------------------------------------------ $digitMatches = [regex]::Matches($result.RawText, '\d+') - if ($digitMatches.Count -eq 0) { - $result.Confidence = "none" + $result.Confidence = 'none' return $result } - $digits = $digitMatches | ForEach-Object { $_.Value } - $result.Digits = $digits - + $result.Digits = @($digitMatches | ForEach-Object { $_.Value }) $best = $digitMatches | Sort-Object Length | Select-Object -Last 1 - - if ($best.Length -ge 4) { - $result.Reading = $best.Value - $result.Confidence = "ok" - } - else { - $result.Reading = $best.Value - $result.Confidence = "low" - } + $result.Reading = $best.Value + $result.Confidence = if ($best.Length -ge 4) { 'ok' } else { 'low' } return $result } @@ -403,14 +427,19 @@ function Add-OcrToPhotoContext { .PARAMETER Photo The photo context object created by New-PhotoContext. + .PARAMETER OcrScalePct + Percentage of original image dimensions to decode at before running OCR. + Passed through to Get-OdometerReading. + .OUTPUTS None. Modifies Photo.OCR in place. #> param( - [pscustomobject]$Photo + [pscustomobject]$Photo, + [int] $OcrScalePct = 25 ) - $result = Get-OdometerReading -ImagePath $Photo.File.FullName + $result = Get-OdometerReading -ImagePath $Photo.File.FullName -ScalePct $OcrScalePct $Photo.OCR.Reading = $result.Reading $Photo.OCR.Confidence = $result.Confidence $Photo.OCR.RawText = $result.RawText @@ -798,8 +827,9 @@ if (-not $PSBoundParameters.ContainsKey('LocationsJson')) { $Locations if (-not $PSBoundParameters.ContainsKey('ExifToolPath')) { $ExifToolPath = Resolve-RelativeSetting 'ExifToolPath' } if (-not $PSBoundParameters.ContainsKey('ProximityThresholdMiles') -and $settings.ContainsKey('ProximityThresholdMiles')) { $ProximityThresholdMiles = [double]$settings['ProximityThresholdMiles'] } if (-not $PSBoundParameters.ContainsKey('MaxSpeedMph') -and $settings.ContainsKey('MaxSpeedMph')) { $MaxSpeedMph = [double]$settings['MaxSpeedMph'] } -$roadFactor = if ($settings.ContainsKey('RoadFactor')) { [double]$settings['RoadFactor'] } else { 1.25 } -$tolerancePct = if ($settings.ContainsKey('TolerancePct')) { [double]$settings['TolerancePct'] } else { 0.20 } +$roadFactor = if ($settings.ContainsKey('RoadFactor')) { [double]$settings['RoadFactor'] } else { 1.25 } +$tolerancePct = if ($settings.ContainsKey('TolerancePct')) { [double]$settings['TolerancePct'] } else { 0.20 } +$ocrScalePct = if ($settings.ContainsKey('OcrScalePercent')) { [int]$settings['OcrScalePercent'] } else { 25 } $fallbackLocation = if ($settings.ContainsKey('FallbackLocation') -and $settings['FallbackLocation']) { $settings['FallbackLocation'] @@ -835,47 +865,82 @@ $outputFolder = if ($paths -and $paths.PSObject.Properties['Output'] -and $paths $paths.Output } else { $Source } -$reportsDir = if ($paths -and $paths.PSObject.Properties['Reports'] -and $paths.Reports) { - $paths.Reports -} else { Join-Path $PSScriptRoot "..\logs" } -if (-not (Test-Path $reportsDir)) { New-Item -ItemType Directory -Path $reportsDir | Out-Null } -$runStamp = Get-Date -Format "yyyyMMdd-HHmmss" -$logFile = Join-Path $reportsDir "rename-log-$runStamp.json" - $logsDir = if ($paths -and $paths.PSObject.Properties['Logs'] -and $paths.Logs) { $paths.Logs } else { Join-Path $PSScriptRoot "..\logs" } if (-not (Test-Path $logsDir)) { New-Item -ItemType Directory -Path $logsDir | Out-Null } +$stateFile = Join-Path $logsDir "rename-state.json" +$auditLog = Join-Path $logsDir "rename-log.json" Write-Information "[Rename-Photos] Starting - source: $Source" -InformationAction Continue $lastOdometer = $null $lastGoodDateTime = $null $lastGoodLocation = $null -$logEntries = @() $skipped = [System.Collections.Generic.List[PSCustomObject]]::new() $renamedCount = 0 -$existingLogs = @(Get-ChildItem -Path $reportsDir -Filter "rename-log-*.json" -ErrorAction SilentlyContinue | Sort-Object Name) -if ($existingLogs.Count -gt 0) { +# Level 1: rename-state.json +if (Test-Path $stateFile) { + try { + $state = Get-Content $stateFile -Raw | ConvertFrom-Json + if ($state.Odometer -and $state.DateTimeOriginal) { + $lastOdometer = [int]$state.Odometer + $lastGoodDateTime = [datetime]::ParseExact($state.DateTimeOriginal.Trim(), "yyyy:MM:dd HH:mm:ss", $null) + $lastGoodLocation = $state.Location + Write-Verbose " Prior state (state file): odometer=$lastOdometer dateTime=$lastGoodDateTime location=$lastGoodLocation" + } + } catch { + Write-Warning "Could not load state file ($stateFile): $($_.Exception.Message)" + } +} + +# Level 2: rename-log.json +if ($null -eq $lastOdometer -and (Test-Path $auditLog)) { try { - $prevEntries = @(Get-Content $existingLogs[-1].FullName -Raw | ConvertFrom-Json) - $lastGood = $prevEntries | Where-Object { $_.OdometerConfidence -eq 'ok' -or $_.OdometerConfidence -eq 'recovered' } | Select-Object -Last 1 + $lastGood = @(Get-Content $auditLog -Raw | ConvertFrom-Json) | + Where-Object { $_.OdometerConfidence -eq 'ok' -or $_.OdometerConfidence -eq 'recovered' } | + Select-Object -Last 1 if ($lastGood) { $lastOdometer = [int]$lastGood.Odometer $lastGoodDateTime = [datetime]::ParseExact($lastGood.DateTimeOriginal.Trim(), "yyyy:MM:dd HH:mm:ss", $null) $lastGoodLocation = $lastGood.Location - Write-Verbose " Prior state: odometer=$lastOdometer dateTime=$lastGoodDateTime location=$lastGoodLocation" + Write-Verbose " Prior state (audit log): odometer=$lastOdometer dateTime=$lastGoodDateTime location=$lastGoodLocation" + } + } catch { + Write-Warning "Could not load prior state from audit log ($auditLog): $($_.Exception.Message)" + } +} + +# Level 3: output directory scan +if ($null -eq $lastOdometer) { + try { + $lastRenamed = Get-ChildItem -Path $outputFolder -Recurse -Filter "*.jpg" -ErrorAction SilentlyContinue | + Where-Object { $_.BaseName -match '^\d{6}-\d{4} ' } | + Sort-Object Name | + Select-Object -Last 1 + if ($lastRenamed) { + $tokens = $lastRenamed.BaseName -split ' ' + if ($tokens.Count -ge 3 -and $tokens[-1] -match '^\d+$') { + $lastOdometer = [int]$tokens[-1] + Write-Verbose " Prior state (directory scan): odometer=$lastOdometer from $($lastRenamed.Name)" + } } } catch { - Write-Warning "Could not load prior state from $($existingLogs[-1].Name): $($_.Exception.Message)" + Write-Warning "Directory scan for prior state failed: $($_.Exception.Message)" } } +# Level 4: -StartOdometer parameter +if ($null -eq $lastOdometer -and $PSBoundParameters.ContainsKey('StartOdometer')) { + $lastOdometer = $StartOdometer + Write-Verbose " Prior state (StartOdometer): odometer=$lastOdometer" +} + # --------------------------------------------------------------------------- # Data pipeline # --------------------------------------------------------------------------- -$photos = Get-ChildItem -Path $Source -Filter "IMG_*.jpeg" +$photos = @(Get-ChildItem -Path $Source -Filter "IMG_*.jpeg") if ($photos.Count -eq 0) { Write-Information "No IMG_*.jpeg files found in $Source" -InformationAction Continue exit 0 @@ -889,7 +954,7 @@ foreach ($file in $photos) { $photo = New-PhotoContext -File $file # 2. EXIF extraction - $exifLines = Get-ExifRawData -ExifToolPath $ExifToolPath -FilePath $file.FullName + $exifLines = @(Get-ExifRawData -ExifToolPath $ExifToolPath -FilePath $file.FullName) Write-Verbose " EXIF raw output ($($exifLines.Count) lines): $($exifLines -join ' | ')" # 3. DateTime enrichment @@ -910,7 +975,7 @@ foreach ($file in $photos) { } # 6. OCR reading - Add-OcrToPhotoContext -Photo $photo + Add-OcrToPhotoContext -Photo $photo -OcrScalePct $ocrScalePct Write-Verbose " OCR result: reading=$($photo.OCR.Reading) confidence=$($photo.OCR.Confidence)" if ($photo.OCR.Confidence -eq 'none' -or $photo.OCR.Confidence -eq 'error') { @@ -919,6 +984,13 @@ foreach ($file in $photos) { continue } + # 6a. Plausibility guard — skip readings that can't fit in Int32 or exceed 9,999,999 mi + if ($photo.OCR.Reading -match '^\d+$' -and ($photo.OCR.Reading.Length -gt 7 -or [long]$photo.OCR.Reading -gt 9999999)) { + Write-Warning " OCR reading '$($photo.OCR.Reading)' out of plausible range for $($photo.File.Name) - skipping" + $skipped.Add([PSCustomObject]@{ File = $photo.File.Name; Reason = "OCR reading out of range: $($photo.OCR.Reading)" }) + continue + } + # 7. Odometer validation if (-not (Test-OdometerReading -Photo $photo -LastOdometer $lastOdometer -LastGoodDateTime $lastGoodDateTime -LastGoodLocation $lastGoodLocation -LocationMap $locationMap -MaxSpeedMph $MaxSpeedMph -RoadFactor $roadFactor -TolerancePct $tolerancePct)) { Write-Warning " $($photo.Error)" @@ -938,7 +1010,8 @@ foreach ($file in $photos) { if (-not $newPath) { continue } # WhatIf $renamedCount++ - $logEntries += [PSCustomObject]@{ + + $entry = [PSCustomObject]@{ OriginalFile = $photo.File.Name NewFile = [System.IO.Path]::GetFileName($newPath) DestinationPath = $newPath @@ -949,7 +1022,21 @@ foreach ($file in $photos) { GPSLat = $photo.Exif.GPS.Lat GPSLon = $photo.Exif.GPS.Lon } - $logEntries | ConvertTo-Json | Out-File $logFile -Encoding utf8 + + [PSCustomObject]@{ + Odometer = $photo.OCR.Reading + DateTimeOriginal = $photo.Exif.RawLines[0] + Location = $photo.Location + OdometerConfidence = $photo.OCR.Confidence + } | ConvertTo-Json | Out-File $stateFile -Encoding utf8 + + $existing = @() + if (Test-Path $auditLog) { + try { $existing = @(Get-Content $auditLog -Raw | ConvertFrom-Json) } catch {} + } + $existing = @($existing | Where-Object { $_.OriginalFile -ne $entry.OriginalFile }) + $existing += $entry + ConvertTo-Json -InputObject $existing | Out-File $auditLog -Encoding utf8 } $processedCount = @($photos | Where-Object { $_.Name -notmatch '^\d{6}-\d{4} ' }).Count @@ -963,5 +1050,5 @@ if ($skipped.Count -gt 0) { Write-Information " - $($s.File): $($s.Reason)" -InformationAction Continue } } -Write-Information " Log : $logFile" -InformationAction Continue +Write-Information " Log : $auditLog" -InformationAction Continue Write-Information "[Rename-Photos] Done." -InformationAction Continue From c966146409dfda70f849fb1dae7d703dc639e978 Mon Sep 17 00:00:00 2001 From: Nick Domako Date: Mon, 27 Apr 2026 15:52:44 -0400 Subject: [PATCH 2/5] fix(test): update -WhatIf test to use -Source instead of -Folder --- tests/Rename-Photos.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Rename-Photos.Tests.ps1 b/tests/Rename-Photos.Tests.ps1 index 895c986..0560bc5 100644 --- a/tests/Rename-Photos.Tests.ps1 +++ b/tests/Rename-Photos.Tests.ps1 @@ -215,10 +215,10 @@ Describe "-WhatIf does not write or move files" -Skip:(-not (Test-Path "$PSScrip It "exits cleanly and creates no renamed files when source folder is empty" { $scriptPath = Join-Path $PSScriptRoot "..\scripts\Rename-Photos.ps1" - # Pass -Folder to override settings; with no IMG_*.jpg files the + # Pass -Source to override settings; with no IMG_*.jpeg files the # script exits before any file operations take place. $null = & powershell.exe -NonInteractive -File $scriptPath ` - -Folder $script:tempSource -WhatIf 2>&1 + -Source $script:tempSource -WhatIf 2>&1 $renamed = Get-ChildItem $script:tempSource -Recurse | Where-Object { $_.Name -notmatch '^IMG_' -and $_.Extension -eq '.jpg' } $renamed.Count | Should -Be 0 From 4524548e398de5b2d002804e91a4e8c1653fe7ed Mon Sep 17 00:00:00 2001 From: Nick Domako Date: Thu, 30 Apr 2026 12:06:44 -0400 Subject: [PATCH 3/5] fix(validation): scope prior-state fallback chain to pre-date current batch All three file-based fallback levels (rename-state.json, rename-log.json, directory scan) now ignore entries whose datetime is >= the earliest EXIF datetime in the source batch. A batch ExifTool pre-pass collects that lower bound before the fallback chain runs. Level 3 also now populates lastGoodDateTime from the selected filename so time-bound validation works after a directory-scan fallback. Without this fix, processing a batch of older photos when newer photos are already in the output folder would cause all readings to fail with "Odometer decreased" because the baseline was pulled from a more recent run. --- scripts/Rename-Photos.ps1 | 57 ++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/scripts/Rename-Photos.ps1 b/scripts/Rename-Photos.ps1 index a6dccf5..54c5291 100644 --- a/scripts/Rename-Photos.ps1 +++ b/scripts/Rename-Photos.ps1 @@ -874,6 +874,28 @@ $auditLog = Join-Path $logsDir "rename-log.json" Write-Information "[Rename-Photos] Starting - source: $Source" -InformationAction Continue +# Collect source photos before the fallback chain so temporal filtering is available. +$photos = @(Get-ChildItem -Path $Source -Filter "IMG_*.jpeg") +if ($photos.Count -eq 0) { + Write-Information "No IMG_*.jpeg files found in $Source" -InformationAction Continue + exit 0 +} + +# Pre-pass: find the earliest EXIF datetime across all source photos. +# Levels 1-3 of the prior-state fallback chain use this to skip state that is +# newer than the current batch, which would cause validation to fail on older photos. +$earliestSourceDate = $null +$prePassArgs = @('-s3', '-DateTimeOriginal') + ($photos | ForEach-Object { $_.FullName }) +foreach ($raw in (& $ExifToolPath @prePassArgs 2>&1)) { + if ($raw -match '^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2})') { + try { + $d = [datetime]::new([int]$Matches[1],[int]$Matches[2],[int]$Matches[3],[int]$Matches[4],[int]$Matches[5],0) + if ($null -eq $earliestSourceDate -or $d -lt $earliestSourceDate) { $earliestSourceDate = $d } + } catch {} + } +} +Write-Verbose " Earliest source photo: $(if ($earliestSourceDate) { $earliestSourceDate } else { '(unknown)' })" + $lastOdometer = $null $lastGoodDateTime = $null $lastGoodLocation = $null @@ -885,10 +907,15 @@ if (Test-Path $stateFile) { try { $state = Get-Content $stateFile -Raw | ConvertFrom-Json if ($state.Odometer -and $state.DateTimeOriginal) { - $lastOdometer = [int]$state.Odometer - $lastGoodDateTime = [datetime]::ParseExact($state.DateTimeOriginal.Trim(), "yyyy:MM:dd HH:mm:ss", $null) - $lastGoodLocation = $state.Location - Write-Verbose " Prior state (state file): odometer=$lastOdometer dateTime=$lastGoodDateTime location=$lastGoodLocation" + $stateDateTime = [datetime]::ParseExact($state.DateTimeOriginal.Trim(), "yyyy:MM:dd HH:mm:ss", $null) + if ($null -ne $earliestSourceDate -and $stateDateTime -ge $earliestSourceDate) { + Write-Verbose " State file skipped: state datetime ($stateDateTime) is not before earliest source photo ($earliestSourceDate)" + } else { + $lastOdometer = [int]$state.Odometer + $lastGoodDateTime = $stateDateTime + $lastGoodLocation = $state.Location + Write-Verbose " Prior state (state file): odometer=$lastOdometer dateTime=$lastGoodDateTime location=$lastGoodLocation" + } } } catch { Write-Warning "Could not load state file ($stateFile): $($_.Exception.Message)" @@ -900,6 +927,11 @@ if ($null -eq $lastOdometer -and (Test-Path $auditLog)) { try { $lastGood = @(Get-Content $auditLog -Raw | ConvertFrom-Json) | Where-Object { $_.OdometerConfidence -eq 'ok' -or $_.OdometerConfidence -eq 'recovered' } | + Where-Object { + if ($null -eq $earliestSourceDate) { return $true } + try { ([datetime]::ParseExact($_.DateTimeOriginal.Trim(), "yyyy:MM:dd HH:mm:ss", $null)) -lt $earliestSourceDate } + catch { $true } + } | Select-Object -Last 1 if ($lastGood) { $lastOdometer = [int]$lastGood.Odometer @@ -917,12 +949,23 @@ if ($null -eq $lastOdometer) { try { $lastRenamed = Get-ChildItem -Path $outputFolder -Recurse -Filter "*.jpg" -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -match '^\d{6}-\d{4} ' } | + Where-Object { + if ($null -eq $earliestSourceDate) { return $true } + if ($_.BaseName -match '^(\d{2})(\d{2})(\d{2})-(\d{2})(\d{2}) ') { + try { [datetime]::new(2000 + [int]$Matches[1],[int]$Matches[2],[int]$Matches[3],[int]$Matches[4],[int]$Matches[5],0) -lt $earliestSourceDate } + catch { $false } + } else { $false } + } | Sort-Object Name | Select-Object -Last 1 if ($lastRenamed) { $tokens = $lastRenamed.BaseName -split ' ' if ($tokens.Count -ge 3 -and $tokens[-1] -match '^\d+$') { $lastOdometer = [int]$tokens[-1] + if ($lastRenamed.BaseName -match '^(\d{2})(\d{2})(\d{2})-(\d{2})(\d{2}) ') { + try { $lastGoodDateTime = [datetime]::new(2000 + [int]$Matches[1],[int]$Matches[2],[int]$Matches[3],[int]$Matches[4],[int]$Matches[5],0) } + catch {} + } Write-Verbose " Prior state (directory scan): odometer=$lastOdometer from $($lastRenamed.Name)" } } @@ -940,12 +983,6 @@ if ($null -eq $lastOdometer -and $PSBoundParameters.ContainsKey('StartOdometer') # --------------------------------------------------------------------------- # Data pipeline # --------------------------------------------------------------------------- -$photos = @(Get-ChildItem -Path $Source -Filter "IMG_*.jpeg") -if ($photos.Count -eq 0) { - Write-Information "No IMG_*.jpeg files found in $Source" -InformationAction Continue - exit 0 -} - foreach ($file in $photos) { Write-Information "Processing $($file.Name)..." -InformationAction Continue From 971a809188478f6c270ed68bb17a8fd44bceb1fc Mon Sep 17 00:00:00 2001 From: Nick Domako Date: Thu, 30 Apr 2026 12:16:20 -0400 Subject: [PATCH 4/5] fix(settings): respect -Source command-line override The -Source parameter was unconditionally overwritten by settings.json Paths.Source, unlike every other parameter which checks $PSBoundParameters.ContainsKey() first. Added the same guard so a command-line -Source takes precedence over settings. This caused the -WhatIf Pester test to silently run against the settings-defined source folder instead of the empty temp folder passed by the test. --- scripts/Rename-Photos.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/Rename-Photos.ps1 b/scripts/Rename-Photos.ps1 index 54c5291..a7f99ed 100644 --- a/scripts/Rename-Photos.ps1 +++ b/scripts/Rename-Photos.ps1 @@ -820,8 +820,10 @@ function Resolve-RelativeSetting { return Join-Path (Split-Path $settingsFile -Parent) $raw } -$Source = if ($paths -and $paths.PSObject.Properties['Source'] -and $paths.Source) { - $paths.Source +if (-not $PSBoundParameters.ContainsKey('Source')) { + $Source = if ($paths -and $paths.PSObject.Properties['Source'] -and $paths.Source) { + $paths.Source + } } if (-not $PSBoundParameters.ContainsKey('LocationsJson')) { $LocationsJson = Resolve-RelativeSetting 'LocationsJson' } if (-not $PSBoundParameters.ContainsKey('ExifToolPath')) { $ExifToolPath = Resolve-RelativeSetting 'ExifToolPath' } From 671ea34e40d7c939e642d5c2742dec6b85f7bd3e Mon Sep 17 00:00:00 2001 From: Nick Domako Date: Thu, 30 Apr 2026 12:19:33 -0400 Subject: [PATCH 5/5] fix(test): wrap Get-ChildItem result in @() to guard against null Count When the source folder is empty the pipeline produces no output and the variable is $null rather than an empty array. Accessing .Count on $null throws PropertyNotFoundException. @() ensures an empty array in all cases. --- tests/Rename-Photos.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Rename-Photos.Tests.ps1 b/tests/Rename-Photos.Tests.ps1 index 0560bc5..6697a74 100644 --- a/tests/Rename-Photos.Tests.ps1 +++ b/tests/Rename-Photos.Tests.ps1 @@ -219,8 +219,8 @@ Describe "-WhatIf does not write or move files" -Skip:(-not (Test-Path "$PSScrip # script exits before any file operations take place. $null = & powershell.exe -NonInteractive -File $scriptPath ` -Source $script:tempSource -WhatIf 2>&1 - $renamed = Get-ChildItem $script:tempSource -Recurse | - Where-Object { $_.Name -notmatch '^IMG_' -and $_.Extension -eq '.jpg' } + $renamed = @(Get-ChildItem $script:tempSource -Recurse | + Where-Object { $_.Name -notmatch '^IMG_' -and $_.Extension -eq '.jpg' }) $renamed.Count | Should -Be 0 }