Last active
May 23, 2026 07:18
-
-
Save Hashbrown777/579e3ad2ed1597451db5712b4ce0565e to your computer and use it in GitHub Desktop.
Defensively use AlbumData.xml to convert `./Masters/YYYY/MM/DD/yyyymmdd-xxyyzz/*.{jpg,MOV,png}` into `./$album/{IMG,VID}_$year$month$day_$hour$minute$second.$ext` without losing/corrupting date data.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #interpret the object version of iPhoto's AlbumData.xml | |
| Function AlbumData { Param($root) | |
| $images = @{} | |
| $captions = @{} | |
| $root.'Master Image List'.psobject.properties ` | |
| | %{ | |
| $key = $_.Name | |
| $_ = $_.Value | |
| if ($key -in $duplicates) { | |
| return | |
| } | |
| #sanity check stuff that is unfilled/superfluous/duplicated in my dataset | |
| if ( | |
| (!($key -and $_.Caption)) -or | |
| $images[$key] -or | |
| $captions[$_.Caption] -ne $NULL -or | |
| $_.Comment -notmatch '^\s*$' -or | |
| $_.ImagePath -notmatch "/$([regex]::Escape($_.Caption))\.[^.]+$" -or | |
| (!$_.DateGMT) -or | |
| $_.Date -gt $_.ModDate.AddHours(1) -or | |
| $_.Date -gt $_.MetaModDate -or | |
| [math]::Abs(($_.Date - $_.DateGMT).TotalDays) -gt 0.5 | |
| ) { | |
| throw $_.GUID | |
| } | |
| $captions[$_.Caption] = @() | |
| $images[$key] = [PSCustomObject]@{ | |
| name = $_.Caption | |
| date = $_.DateGMT | |
| } | |
| if ($_.latitude) { | |
| $images[$key] ` | |
| | Add-Member ` | |
| -Type NoteProperty ` | |
| -Name 'location' ` | |
| -Value ($_.latitude,$_.longitude) | |
| } | |
| } | |
| #| ?{ $_.AlbumName -ne 'Photos' } ` | |
| $albums = @{} | |
| $root.'List of Albums' ` | |
| | %{ | |
| $id = $_.GUID | |
| $count = $_.PhotoCount | |
| $name = $_.AlbumName -replace [char]0xA0,' ' | |
| $date = $_.ProjectEarliestDate | |
| if (!($album = $albums[$name])) { | |
| $album = [PSCustomObject]@{ | |
| date = $date | |
| list = @{} | |
| } | |
| } | |
| elseif ($album.images -gt $date) { | |
| $album.date = $date | |
| } | |
| $_.KeyList ` | |
| | %{ | |
| if (!$_) { | |
| return | |
| } | |
| --$count | |
| if ($_ -in $duplicates) { | |
| return | |
| } | |
| if ( | |
| (!$images[$_]) -or | |
| $album.list[$_] | |
| ) { | |
| throw "$id $_" | |
| } | |
| $captions[$images[$_].Name] += @($name) | |
| $album.list[$_] = 1 | |
| } | |
| if ($count) { | |
| throw $id | |
| } | |
| if ($album.list.Count -and !$albums[$name]) { | |
| $albums[$name] = $album | |
| } | |
| } | |
| #rolls appear to be a copy of the auto-generated albums | |
| $root.'List of Rolls' ` | |
| | %{ | |
| $id = $_.RollID | |
| $count = $_.PhotoCount | |
| $album = $albums[($_.RollName -replace [char]0xA0,' ')] | |
| if ($album -in $albums['Last Import'],$albums['Photos']) { | |
| throw $_.RollName | |
| } | |
| #check we got a match | |
| if ( | |
| (!$album) -or | |
| [math]::Abs(($album.date - $_.RollDate).TotalDays) -gt 0.6 | |
| ) { | |
| throw $id | |
| } | |
| #mark as checked | |
| $album.list[''] = $True | |
| $_.KeyList ` | |
| | %{ | |
| if (!$_) { | |
| return | |
| } | |
| --$count | |
| if ($_ -in $duplicates) { | |
| return | |
| } | |
| #check we dont contain new information | |
| if (!$album.list[$_]) { | |
| throw "$id $_" | |
| } | |
| #mark the image as counted | |
| --$album.list[$_] | |
| } | |
| if ($count) { | |
| throw $id | |
| } | |
| } | |
| $captions.GetEnumerator() ` | |
| | %{ | |
| if ('Photos' -notin $_.Value) { | |
| $_.Name + ' is an orphan' | Out-Host | |
| } | |
| } | |
| #this album is simply a list of all photos | |
| #make it instead a list of images in no other album | |
| @($albums['Photos'].list.Keys) ` | |
| | %{ | |
| $name = $images[$_].name | |
| if ($captions[$name].Count -gt 1) { | |
| $captions[$name] = $captions[$name] | ?{ $_ -ne 'Photos' } | |
| $albums['Photos'].list.Remove($_) | |
| } | |
| } | |
| #this album is simply a list of photos recently added in another album | |
| #make it instead a list of images in no other album, should some somehow exist | |
| @($albums['Last Import'].list.Keys) ` | |
| | %{ | |
| $name = $images[$_].name | |
| if ($captions[$name].Count -gt 1) { | |
| $captions[$name] = $captions[$name] | ?{ $_ -ne 'Last Import' } | |
| $albums['Last Import'].list.Remove($_) | |
| } | |
| } | |
| $albums.GetEnumerator() ` | |
| | %{ | |
| $name = $_.Name | |
| $album = $_.Value | |
| $album.list = $album.list.GetEnumerator() ` | |
| | %{ | |
| if (!$_.Name) { | |
| return | |
| } | |
| if ($_.Value -and $album.list['']) { | |
| $_.Name + ' is missing from role ' + $name | Out-Host | |
| } | |
| $images[$_.Name] | |
| } ` | |
| | Sort-Object -Property 'date' | |
| } | |
| #check photos for existing in multiple custom albums | |
| #or somehow multiple auto-generated albums | |
| $captions.GetEnumerator() ` | |
| | %{ | |
| $auto = @() | |
| $_.Value = $_.Value ` | |
| | %{ | |
| switch -regex ($_) { | |
| '^Last Import$' {} | |
| '^Photos' {} | |
| '^[A-Z][a-z]{1,2} \d{4} Photo Stream$' {} | |
| '^\d{1,2} [A-Z][a-z]{2} [\d]{4}$' {} | |
| Default { return $_ } | |
| } | |
| $auto += @($_) | |
| } | |
| if ($auto.Count -ne 1) { | |
| $_.Name,($auto -join "`t") -join "`t" | |
| } | |
| if ($_.Value.Count -gt 1) { | |
| $_.Name,($_.Value -join "`t") -join "`t" | |
| } | |
| } ` | |
| | Sort-Object -Property { $_ = $_ -split "`t"; [array]::Reverse($_); $_ -join "`t" } ` | |
| | Out-Host | |
| $albums | |
| } | |
| #logically place images into album groups | |
| #this will be the final grouping, but not the final filenames | |
| Function Albums { Param($data) | |
| $from = @{} | |
| $in = @{} | |
| Filter Output { | |
| $_.date.ToString('yyyy-MM-dd_HHmmss'),$_.name -join "`t" | |
| } | |
| $data.Keys ` | |
| | %{ | |
| if (!$data[$_].list.Count) {} | |
| #these ones seem to have photos from a specific date ONWARDS | |
| elseif ($_ -match '^\d{1,2} [A-Z][a-z]{2} \d{4}$') { | |
| $name = [datetime]::ParseExact( | |
| $_, | |
| 'd MMM yyyy', | |
| [cultureinfo]::CurrentCulture | |
| ).ToString('yyyy-MM-dd') | |
| $_ = $data[$_] | |
| if ( | |
| $name -ne $_.date.ToString('yyyy-MM-dd') -or | |
| $name -gt $_.list[0].date.ToString('yyyy-MM-dd') | |
| ) { | |
| throw $name | |
| } | |
| $from[$name] = $_.list | |
| } | |
| #these have photos only WITHIN their general month | |
| elseif ($_ -match '^[A-Z][a-z]{1,2} \d{4} Photo Stream$') { | |
| $name = [datetime]::ParseExact( | |
| ($_ -replace ' Photo Stream$',''), | |
| 'MMM yyyy', | |
| [cultureinfo]::CurrentCulture | |
| ).ToString('yyyy-MM') | |
| $_ = $data[$_] | |
| if ( | |
| $name -ne $_.date.ToString('yyyy-MM') -or | |
| $name -ne $_.list[0].date.ToString('yyyy-MM') -or | |
| $name -ne $_.list[-1].date.ToString('yyyy-MM') | |
| ) { | |
| throw $name | |
| } | |
| $in[$name] = $_.list | |
| } | |
| else { | |
| $_ | |
| } | |
| } ` | |
| | Sort-Object ` | |
| | %{ | |
| "`t$_" | |
| $data[$_].list | Output | |
| '' | |
| } | |
| $in.Keys ` | |
| | Sort-Object ` | |
| | %{ | |
| "`t$_" | |
| $in[$_] | Output | |
| } | |
| '' | |
| $last = $NULL | |
| $from.Keys ` | |
| | Sort-Object ` | |
| | %{ | |
| if (!$last) {} | |
| elseif ($last -lt $from[$_][0].date) { | |
| '' | |
| } | |
| else { | |
| "`t$(($last - $from[$_][0].date).TotalDays)day OVERLAP" -replace '(?<=\.\d\d)\d+','' | |
| } | |
| $last = $from[$_][-1].date | |
| $from[$_] | Output | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #get all iPhotos images from disk | |
| Function Images { | |
| $shell = New-Object -ComObject Shell.Application | |
| $dir = $shell.NameSpace((Resolve-Path '.').Path) | |
| $dates = @{ | |
| 'Date taken' = $NULL | |
| 'Media created' = $NULL | |
| } | |
| for ($index = 1; $NULL -in $dates.Values; ++$index) { | |
| $name = $dir.GetDetailsOf($NULL, $index) | |
| if ($name -in $dates.Keys) { | |
| $dates[$name] = $index | |
| } | |
| } | |
| $out = @{} | |
| 'Masters' ` | |
| | gci -recurse -file ` | |
| | %{ | |
| if ( | |
| $out[$_.BaseName] -and | |
| $_.BaseName -notin $duplicates | |
| ) { | |
| throw $_.BaseName | |
| } | |
| $dir = $shell.NameSpace($_.DirectoryName) | |
| $date = $dir.ParseName($_.Name) | |
| $date = $dates.Values | %{ $dir.GetDetailsOf($date, $_) } | ?{ $_ } | |
| if (!$date) {} | |
| elseif ($date.Count -gt 1) { | |
| throw $_.BaseName | |
| } | |
| elseif ($date) { | |
| $date = [datetime]::ParseExact( | |
| ($date -replace ([char]0x200E,[char]0x200F -join '|'),''), | |
| 'd/MM/yyyy h:mm tt', | |
| [cultureinfo]::CurrentCulture | |
| ) | |
| } | |
| $out[$_.BaseName] = $_.DirectoryName,$_.Extension,$date | |
| } | |
| $out | |
| } | |
| #where we have iPhoto AND file metadata, crossreference them | |
| Filter Reconcile { Param([ref]$date) | |
| if (!$_) { | |
| return | |
| } | |
| $check = $_.ToString('yyyy-MM-dd_HHmm') | |
| if ($date.Value.SubString(0, 'yyyy-MM-dd_HHmm'.Length) -eq $check) { | |
| return | |
| } | |
| $diff = [math]::Abs( | |
| ($_ - [datetime]::ParseExact( | |
| $date.Value.SubString(0, 'yyyy-MM-dd_HHmm'.Length), | |
| 'yyyy-MM-dd_HHmm', | |
| [cultureinfo]::CurrentCulture | |
| )).TotalHours | |
| ) | |
| if ( | |
| #if the difference is <6min | |
| $diff -lt 0.4 -or | |
| #or a whole-hour under a half-day | |
| ($diff -lt 12 -and !($diff % 1)) | |
| ) { | |
| #accept the correction | |
| $date.Value = $check + $date.Value.SubString('yyyy-MM-dd_HHmm'.Length) | |
| } | |
| #14hr difference is incorrect in my dataset | |
| elseif ($diff -eq 14) {} | |
| #review these manually; I saw none | |
| else { | |
| "`t$name" | |
| $date.Value | |
| $check | |
| "$($diff)hrs" | |
| } | |
| } | |
| #perform the actual file relocation! | |
| #run without `-WET` for the MANDATORY last-chance error checking dry run! | |
| Function Refactor { Param($images, $albums, [switch]$WET) | |
| $ErrorActionPreference = 'Stop' | |
| $dupes = @{} | |
| $album = $NULL | |
| $albums ` | |
| | %{ | |
| if ($_ -match '^(\t[.\d]+day OVERLAP)?$') {} | |
| elseif ($_ -match '^\t[^\t]+$') { | |
| $album = $_ -replace '\t','' | |
| if ($WET) { | |
| New-Item -Type Directory -Name $album | |
| } | |
| } | |
| elseif ($_ -notmatch '^(\d{4}(?:-\d{2}){2}_\d{6})\t([^\t]+)$' -or !$album) { | |
| throw $_ | |
| } | |
| else { | |
| $date = $Matches[1] | |
| $name = $Matches[2] | |
| $_ = $images[$name] | |
| if (!$_) { | |
| throw $name | |
| } | |
| $dir,$ext,$prop = $_ | |
| $file = $name + $ext | |
| if ($file -in $duplicates) { | |
| return | |
| } | |
| if ($dupes[$file]) { | |
| $dupes[$file] += @($album) | |
| return | |
| } | |
| if ($name -match '^Photo on ([\d-]+) at ([\d.]+( [aApP][mM]|))( #\d+|)$') { | |
| $check = [datetime]::ParseExact( | |
| ($Matches[1],$Matches[2] -join ' '), | |
| (('d-M-yy h.m tt','yyyy-MM-dd HH.mm')[$Matches[1].Length -eq 'yyyy-MM-dd'.Length]), | |
| [cultureinfo]::CurrentCulture | |
| ) | |
| $check | Reconcile ([ref]$date) | |
| $date = 'Photo@' + $date | |
| } | |
| else { | |
| $prop | Reconcile ([ref]$date) | |
| if ($name -match '^IMG_\d{4}$') { | |
| $date = switch -regex ($ext) { | |
| '^\.(mov|mp4)$' { 'VID_' + $date } | |
| '^\.jpg$' { $name -split '_' -join "_$date " } | |
| '^\.png$' { $date } | |
| Default { throw $_ } | |
| } | |
| } | |
| elseif ($name -notmatch '^[A-Z]{4}\d{4}$') { | |
| $name | |
| '' | |
| } | |
| } | |
| $dupes[$file] = @("$album/$date$ext") | |
| if ($WET -and !$dupes[$name]) { | |
| Move-Item ` | |
| -LiteralPath ($dir,$file -join [System.IO.Path]::DirectorySeparatorChar) ` | |
| -Destination $album | |
| Rename-Item ` | |
| -LiteralPath "$album/$name$ext" ` | |
| -NewName "$date$ext" | |
| } | |
| } | |
| } | |
| if (!$WET) { | |
| #check that no filenames are identical | |
| $dupes.Keys ` | |
| | Group-Object -Property { $dupes[$_][0] } ` | |
| | &{ | |
| Begin { $bad = $False } | |
| Process { | |
| if ($_.Count -gt 1) { | |
| $bad = $True | |
| "`t" + $_.Name | |
| $_.Group -join "`t" | |
| '' | |
| } | |
| } | |
| End { if ($bad) { throw $bad } } | |
| } | |
| #check that the new names maintain the old names' sort order | |
| $dupes.Keys ` | |
| | ?{ $_ -match '^IMG_(\d+)\..{3}$' } ` | |
| | Sort-Object ` | |
| | &{ | |
| Begin { $last = '' } | |
| Process { | |
| if ($dupes[$_][0] -notmatch '[_/](\d{4}-\d{2}-\d{2}_\d{6})[ .][^/]+$' ) { | |
| throw ($_,$dupes[$_][0] -join "`t") | |
| } | |
| if ($Matches[1] -lt $last) { | |
| throw $_ | |
| } | |
| $last = $Matches[1] | |
| } | |
| } | |
| #output the intended relocations for review | |
| $dupes.GetEnumerator() ` | |
| | Sort-Object -Property { $_.Value[0] } ` | |
| | %{ | |
| $_.Value[0],$_.Name -join "`t" | |
| if ($_.Value.Count -gt 1) { | |
| "`t" + ($_.Value[1..$_.Value.Length] -join "`t") | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| $epoch = Get-Date ` | |
| -Year 2001 ` | |
| -Month 1 ` | |
| -Day 1 ` | |
| -Hour 0 ` | |
| -Minute 0 ` | |
| -Second 0 | |
| $depth = $NULL | |
| #convert apple's plist xml into a walkable object | |
| Function Node { Param($value, $key, [switch]$debug) | |
| if (!$debug) {} | |
| elseif ($key -eq $NULL) { | |
| $depth = 0 | |
| } | |
| else { | |
| ("`t" * ++$depth),$value.Name | Write-Host -NoNewline | |
| } | |
| switch -exact ($value.Name) { | |
| '#text' { | |
| if ($debug) { | |
| "`t",$value.InnerText | Write-Host -NoNewline | |
| } | |
| } | |
| 'key' { | |
| if ($debug) { | |
| "`t",$value.InnerText | Write-Host -NoNewline | |
| } | |
| if ($key) { | |
| throw 'Double key' | |
| } | |
| $value.InnerText | |
| } | |
| 'integer' { | |
| [long]$value.InnerText | |
| } | |
| 'string' { | |
| $value.InnerText | |
| } | |
| 'real' { | |
| $_ = [double]$value.InnerText | |
| if ($key -match 'AsTimerInterval(?=GMT|$)') { | |
| $_ = $epoch.AddMilliseconds($_ * 1000) | |
| } | |
| $_ | |
| } | |
| 'true' { | |
| $True | |
| } | |
| 'false' { | |
| $False | |
| } | |
| 'array' { | |
| if ($debug) { | |
| '' | Write-Host | |
| } | |
| $value.ChildNodes | %{ Node $_ '' -debug:$debug } | |
| } | |
| 'dict' { | |
| if ($debug) { | |
| '' | Write-Host | |
| } | |
| $out = [PSCustomObject]@{} | |
| $key = '' | |
| $value.ChildNodes ` | |
| | %{ | |
| $value = Node $_ $key -debug:$debug | |
| if ($value -is [datetime]) { | |
| $key = $key -replace 'AsTimerInterval(?=GMT|$)','' | |
| } | |
| if ($_.Name -eq 'key') { | |
| $key = $value | |
| } | |
| elseif (!$key) { | |
| throw 'No key' | |
| } | |
| elseif (Get-Member -InputObject $out -Name $key) { | |
| throw 'Key exists' | |
| } | |
| else { | |
| $out ` | |
| | Add-Member ` | |
| -Type NoteProperty ` | |
| -Name $key ` | |
| -Value $value | |
| $key = '' | |
| } | |
| } | |
| $out | |
| } | |
| Default { | |
| throw $_ | |
| } | |
| } | |
| if ($debug) { | |
| '' | Write-Host | |
| --$depth | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Running each of the steps interactively will tease out any niggles with your particular photo library
Do note that this was developed/tested/ran on windows, but I imagine you could get exif using 'nix commands there, although I don't know how helpful that will be for
movs