Skip to content

Instantly share code, notes, and snippets.

@Hashbrown777
Last active May 23, 2026 07:18
Show Gist options
  • Select an option

  • Save Hashbrown777/579e3ad2ed1597451db5712b4ce0565e to your computer and use it in GitHub Desktop.

Select an option

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.
#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
}
}
#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")
}
}
}
}
$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
}
}
@Hashbrown777
Copy link
Copy Markdown
Author

Hashbrown777 commented May 22, 2026

Running each of the steps interactively will tease out any niggles with your particular photo library

. plist.ps1
$root       = Node ([xml](gc -Encoding UTF8 'AlbumData.xml')).plist.dict

. albums.ps1
$duplicates = '4564','4566'
$albumData  = AlbumData $root
$albums     = Albums $albumData

. imagefiles.ps1
$duplicates = 'IMG_1282','IMG_1284'
$images     = Images
$duplicates = 'JMGC8989.JPG'
Refactor $images $albums

#after running with -WET
'Masters' | gci -Recurse -File #shows images left behind

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment