Last active
February 13, 2023 17:28
-
-
Save cedricvidal/1354bdceb0bbc9c5099e0207f0620fb4 to your computer and use it in GitHub Desktop.
Fix Apple Photos timestamps Applescript script
This file contains 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
use AppleScript version "2.4" | |
use framework "Foundation" | |
use scripting additions | |
# This script fixes timestamps of photos currently selected in OSX Photos application. | |
# It does so by reading a CSV file containing mappings from file names to timestamps. | |
# The user is requested to select the CSV file when running this script. | |
# Author: Cedric Vidal (https://vidal.biz) | |
# Usage: | |
# 1. Select in Apple Photos the photos and videos for which you want to fix the timestamp. | |
# Start with a small test subset to verify that the script is working as expected. | |
# 2. Run the script | |
# 3. Select the CSV file containing the mapping from file name to timestamp | |
# 4. The script will load the mapping, this can take a while if it contains | |
# thousands of entries or more. Be patient. For each photo or video in | |
# the selection, it will try to find a CSV mapping entry with the same | |
# filename (the part before the last "/" is ignored). If there is an entry, | |
# it will parse the timestamp as a date and update the photo's date. Otherwise, | |
# it will simply skip the photo and carry on. | |
# Warning: No validation of the CSV file is done! | |
# The CSV must follow the example: | |
#./Pictures/Instagram/IMG_20191006_160831_485.jpg,2019:10:10 06:53:01-07:00 | |
#./Pictures/Instagram/IMG_20191007_080007_040.jpg,2019:10:10 06:53:01-07:00 | |
#./Pictures/Instagram/IMG_20190919_211953_686.jpg,2019:10:10 06:53:02-07:00 | |
# Only the filename is used for matching, the full path is ignored so it may | |
# set wrong timestamps if two files have the same name but different paths and timestamps. | |
# The method to extract the timestamps can vary and is out of scope of | |
# this script. | |
on run | |
log ("Loading mapping") | |
set mapping to loadMapping() | |
log ("Loading done") | |
tell application "Photos" | |
activate | |
set imageSel to (get selection) # Media Item class = PHAsset class | |
repeat with i from 1 to length of imageSel | |
# Documentation for PhotosKit's Media Item class returned by selection | |
# https://developer.apple.com/documentation/photos/phasset | |
set img to item i of imageSel | |
set img_date to (the date of img) as date | |
set img_key to (the filename of img) as string | |
set img_date_fix to my getDateFromDict(img_key, mapping) | |
if ((missing value ≠ img_date_fix) and ((missing value is img_date) or (img_date_fix < img_date))) then | |
log ("Fixing File " & (img_key) & " -> " & (img_date) & " -> " & img_date_fix) | |
set the date of img to img_date_fix | |
else | |
log ("Skipping File " & (img_key) & " -> " & (img_date) & " -> " & img_date_fix) | |
end if | |
end repeat | |
end tell | |
log ("Done") | |
end run | |
on getDateFromDict(k, dct) | |
set dateStr to getFromDict(k, dct) | |
if missing value = dateStr then | |
missing value | |
else | |
parseDate(dateStr) | |
end if | |
end getDateFromDict | |
on parseDate(dateStr) | |
set the text item delimiters to {"-", ":", " "} | |
set dateArray to items 1 through 3 of text items of dateStr | |
set dateObj to {item 2 of dateArray, item 3 of dateArray, item 1 of dateArray} | |
set dateDate to date ("" & dateObj) | |
set the text item delimiters to space | |
dateDate | |
end parseDate | |
on loadMapping() | |
set DELIM to {","} | |
set Ly to {} | |
set acsv to (choose file) | |
set csvList to read acsv using delimiter linefeed | |
set {TID, AppleScript's text item delimiters} to {AppleScript's text item delimiters, DELIM} | |
repeat with arow in csvList | |
set ritems to every text item of arow | |
set rpath to (item 1 of ritems) | |
set rfilename to getFilename(rpath) | |
set rdate to (item 2 of ritems) | |
set end of Ly to {rfilename, rdate} | |
end repeat | |
set AppleScript's text item delimiters to TID | |
dictFromList(Ly) | |
end loadMapping | |
on getFilename(path) | |
set {TID, text item delimiters} to {AppleScript's text item delimiters, "/"} | |
set pathArray to text items of path | |
set filename to last item of pathArray | |
set text item delimiters to TID | |
filename | |
end getFilename | |
-- REUSABLE GENERAL FUNCTIONS --------------------------------------------------- | |
-- https://github.com/RobTrew/prelude-applescript | |
-- Just :: a -> Maybe a | |
on Just(x) | |
-- Constructor for an inhabited Maybe (option type) value. | |
-- Wrapper containing the result of a computation. | |
{type:"Maybe", Nothing:false, Just:x} | |
end Just | |
-- Nothing :: Maybe a | |
on Nothing() | |
-- Constructor for an empty Maybe (option type) value. | |
-- Empty wrapper returned where a computation is not possible. | |
{type:"Maybe", Nothing:true} | |
end Nothing | |
-- assocs :: Map k a -> [(k, a)] | |
on assocs(m) | |
set c to class of m | |
if list is c then | |
zip(enumFromTo(1, length of m), m) | |
else if record is c then | |
tell current application to set dict to ¬ | |
dictionaryWithDictionary_(m) of its NSDictionary | |
zip((sortedArrayUsingSelector_("compare:") of ¬ | |
allKeys() of dict) as list, ¬ | |
(allValues() of dict) as list) | |
else | |
{} | |
end if | |
end assocs | |
-- deleteKey :: k -> Dict -> Dict | |
on deleteKey(k, rec) | |
tell current application to set nsDct to ¬ | |
dictionaryWithDictionary_(rec) of its NSMutableDictionary | |
removeObjectForKey_(k) of nsDct | |
nsDct as record | |
end deleteKey | |
-- elems :: Map k a -> [a] | |
-- elems :: Set a -> [a] | |
on elems(x) | |
if record is class of x then -- Dict | |
tell current application to allValues() ¬ | |
of dictionaryWithDictionary_(x) ¬ | |
of its NSDictionary as list | |
else -- Set | |
(allObjects() of x) as list | |
end if | |
end elems | |
-- enumFromTo :: Int -> Int -> [Int] | |
on enumFromTo(m, n) | |
if m ≤ n then | |
set lst to {} | |
repeat with i from m to n | |
set end of lst to i | |
end repeat | |
lst | |
else | |
{} | |
end if | |
end enumFromTo | |
-- dictFromList :: [(k, v)] -> Dict | |
on dictFromList(kvs) | |
set tpl to unzip(kvs) | |
script go | |
on |λ|(x) | |
x as string | |
end |λ| | |
end script | |
tell current application | |
(its (NSDictionary's dictionaryWithObjects:(item 2 of tpl) ¬ | |
forKeys:(my map(go, item 1 of tpl)))) as record | |
end tell | |
end dictFromList | |
-- identity :: a -> a | |
on identity(x) | |
-- The argument unchanged. | |
x | |
end identity | |
-- insertDict :: String -> a -> Dict -> Dict | |
on insertDict(k, v, rec) | |
tell current application | |
tell dictionaryWithDictionary_(rec) of its NSMutableDictionary | |
its setValue:v forKey:(k as string) | |
it as record | |
end tell | |
end tell | |
end insertDict | |
-- keys :: Dict -> [String] | |
on keys(rec) | |
tell current application | |
(allKeys() of its (NSDictionary's dictionaryWithDictionary:rec)) as list | |
end tell | |
end keys | |
-- lookupDict :: a -> Dict -> Maybe b | |
on lookUpDict(k, dct) | |
-- Just the value of k in the dictionary, | |
-- or Nothing if k is not found. | |
set ca to current application | |
set v to (ca's NSDictionary's dictionaryWithDictionary:dct)'s objectForKey:k | |
if missing value ≠ v then | |
Just(item 1 of ((ca's NSArray's arrayWithObject:v) as list)) | |
else | |
Nothing() | |
end if | |
end lookUpDict | |
on getFromDict(k, dct) | |
-- Just the value of k in the dictionary, | |
-- or Nothing if k is not found. | |
set ca to current application | |
set v to (ca's NSDictionary's dictionaryWithDictionary:dct)'s objectForKey:k | |
if missing value ≠ v then | |
item 1 of ((ca's NSArray's arrayWithObject:v) as list) | |
else | |
missing value | |
end if | |
end getFromDict | |
-- map :: (a -> b) -> [a] -> [b] | |
on map(f, xs) | |
-- The list obtained by applying f | |
-- to each element of xs. | |
tell mReturn(f) | |
set lng to length of xs | |
set lst to {} | |
repeat with i from 1 to lng | |
set end of lst to |λ|(item i of xs, i, xs) | |
end repeat | |
return lst | |
end tell | |
end map | |
-- maybe :: b -> (a -> b) -> Maybe a -> b | |
on maybe(v, f, mb) | |
-- The 'maybe' function takes a default value, a function, and a 'Maybe' | |
-- value. If the 'Maybe' value is 'Nothing', the function returns the | |
-- default value. Otherwise, it applies the function to the value inside | |
-- the 'Just' and returns the result. | |
if Nothing of mb then | |
v | |
else | |
tell mReturn(f) to |λ|(Just of mb) | |
end if | |
end maybe | |
-- mReturn :: First-class m => (a -> b) -> m (a -> b) | |
on mReturn(f) | |
-- 2nd class handler function lifted into 1st class script wrapper. | |
if script is class of f then | |
f | |
else | |
script | |
property |λ| : f | |
end script | |
end if | |
end mReturn | |
-- min :: Ord a => a -> a -> a | |
on min(x, y) | |
if y < x then | |
y | |
else | |
x | |
end if | |
end min | |
-- unzip :: [(a,b)] -> ([a],[b]) | |
on unzip(xys) | |
set xs to {} | |
set ys to {} | |
repeat with xy in xys | |
set end of xs to item 1 of xy | |
set end of ys to item 2 of xy | |
end repeat | |
return {xs, ys} | |
end unzip | |
-- zip :: [a] -> [b] -> [(a, b)] | |
on zip(xs, ys) | |
set lng to min(length of xs, length of ys) | |
set lst to {} | |
repeat with i from 1 to lng | |
set end of lst to {item i of xs, item i of ys} | |
end repeat | |
return lst | |
end zip |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment