Fixed search for edited photo in path_edited
162
osxphotos/_applescript/__init__.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
|
||||||
|
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
|
||||||
|
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
|
||||||
|
|
||||||
|
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
|
||||||
|
from . import kae
|
||||||
|
|
||||||
|
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
|
||||||
|
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
|
||||||
|
class AppleScript:
|
||||||
|
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
_codecs = Codecs()
|
||||||
|
|
||||||
|
def __init__(self, source=None, path=None):
|
||||||
|
"""
|
||||||
|
source : str | None -- AppleScript source code
|
||||||
|
path : str | None -- full path to .scpt/.applescript file
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Either the path or the source argument must be provided.
|
||||||
|
|
||||||
|
- If the script cannot be read/compiled, a ScriptError is raised.
|
||||||
|
"""
|
||||||
|
if path:
|
||||||
|
url = NSURL.fileURLWithPath_(path)
|
||||||
|
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
|
||||||
|
if errorinfo:
|
||||||
|
raise ScriptError(errorinfo)
|
||||||
|
elif source:
|
||||||
|
self._script = NSAppleScript.alloc().initWithSource_(source)
|
||||||
|
else:
|
||||||
|
raise ValueError("Missing source or path argument.")
|
||||||
|
if not self._script.isCompiled():
|
||||||
|
errorinfo = self._script.compileAndReturnError_(None)[1]
|
||||||
|
if errorinfo:
|
||||||
|
raise ScriptError(errorinfo)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
s = self.source
|
||||||
|
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
def _newevent(self, suite, code, args):
|
||||||
|
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
|
||||||
|
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
|
||||||
|
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
|
||||||
|
return evt
|
||||||
|
|
||||||
|
def _unpackresult(self, result, errorinfo):
|
||||||
|
if not result:
|
||||||
|
raise ScriptError(errorinfo)
|
||||||
|
return self._codecs.unpack(result)
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
|
||||||
|
|
||||||
|
def run(self, *args):
|
||||||
|
""" Run the script, optionally passing arguments to its run handler.
|
||||||
|
|
||||||
|
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
|
||||||
|
Result : anything | None -- the script's return value, if any
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The run handler must be explicitly declared in order to pass arguments.
|
||||||
|
|
||||||
|
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||||
|
|
||||||
|
- If execution fails, a ScriptError is raised.
|
||||||
|
"""
|
||||||
|
if args:
|
||||||
|
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
|
||||||
|
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||||
|
else:
|
||||||
|
return self._unpackresult(*self._script.executeAndReturnError_(None))
|
||||||
|
|
||||||
|
def call(self, name, *args):
|
||||||
|
""" Call the specified user-defined handler.
|
||||||
|
|
||||||
|
name : str -- the handler's name (case-sensitive)
|
||||||
|
args : anything -- arguments to pass to script, if any; see documentation for supported types
|
||||||
|
Result : anything | None -- the script's return value, if any
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
|
||||||
|
|
||||||
|
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||||
|
|
||||||
|
- If execution fails, a ScriptError is raised.
|
||||||
|
"""
|
||||||
|
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
|
||||||
|
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
|
||||||
|
fourcharcode(kae.keyASSubroutineName))
|
||||||
|
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptError(Exception):
|
||||||
|
""" Indicates an AppleScript compilation/execution error. """
|
||||||
|
|
||||||
|
def __init__(self, errorinfo):
|
||||||
|
self._errorinfo = dict(errorinfo)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'ScriptError({})'.format(self._errorinfo)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message(self):
|
||||||
|
""" str -- the error message """
|
||||||
|
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
|
||||||
|
if not msg:
|
||||||
|
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
|
||||||
|
return msg
|
||||||
|
|
||||||
|
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
|
||||||
|
doc="int | None -- the error number, if given")
|
||||||
|
|
||||||
|
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
|
||||||
|
doc="str | None -- the name of the application that reported the error, where relevant")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def range(self):
|
||||||
|
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
|
||||||
|
range = self._errorinfo.get(NSAppleScriptErrorRange)
|
||||||
|
if range:
|
||||||
|
start = range.rangeValue().location
|
||||||
|
end = start + range.rangeValue().length
|
||||||
|
return (start, end)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
msg = self.message
|
||||||
|
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
|
||||||
|
if v is not None:
|
||||||
|
msg += s.format(v)
|
||||||
|
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
kMissingValue = AEType(kae.cMissingValue) # convenience constant
|
||||||
|
|
||||||
269
osxphotos/_applescript/aecodecs.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
|
||||||
|
|
||||||
|
import datetime, struct, sys
|
||||||
|
|
||||||
|
from Foundation import NSAppleEventDescriptor, NSURL
|
||||||
|
|
||||||
|
from . import kae
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['Codecs', 'AEType', 'AEEnum']
|
||||||
|
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
|
||||||
|
def fourcharcode(code):
|
||||||
|
""" Convert four-char code for use in NSAppleEventDescriptor methods.
|
||||||
|
|
||||||
|
code : bytes -- four-char code, e.g. b'utxt'
|
||||||
|
Result : int -- OSType, e.g. 1970567284
|
||||||
|
"""
|
||||||
|
return struct.unpack('>I', code)[0]
|
||||||
|
|
||||||
|
|
||||||
|
#######
|
||||||
|
|
||||||
|
|
||||||
|
class Codecs:
|
||||||
|
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
|
||||||
|
|
||||||
|
kMacEpoch = datetime.datetime(1904, 1, 1)
|
||||||
|
kUSRF = fourcharcode(kae.keyASUserRecordFields)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Clients may add/remove/replace encoder and decoder items:
|
||||||
|
self.encoders = {
|
||||||
|
NSAppleEventDescriptor.class__(): self.packdesc,
|
||||||
|
type(None): self.packnone,
|
||||||
|
bool: self.packbool,
|
||||||
|
int: self.packint,
|
||||||
|
float: self.packfloat,
|
||||||
|
bytes: self.packbytes,
|
||||||
|
str: self.packstr,
|
||||||
|
list: self.packlist,
|
||||||
|
tuple: self.packlist,
|
||||||
|
dict: self.packdict,
|
||||||
|
datetime.datetime: self.packdatetime,
|
||||||
|
AEType: self.packtype,
|
||||||
|
AEEnum: self.packenum,
|
||||||
|
}
|
||||||
|
if sys.version_info.major < 3: # 2.7 compatibility
|
||||||
|
self.encoders[unicode] = self.packstr
|
||||||
|
|
||||||
|
self.decoders = {fourcharcode(k): v for k, v in {
|
||||||
|
kae.typeNull: self.unpacknull,
|
||||||
|
kae.typeBoolean: self.unpackboolean,
|
||||||
|
kae.typeFalse: self.unpackboolean,
|
||||||
|
kae.typeTrue: self.unpackboolean,
|
||||||
|
kae.typeSInt32: self.unpacksint32,
|
||||||
|
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
|
||||||
|
kae.typeUTF8Text: self.unpackunicodetext,
|
||||||
|
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
|
||||||
|
kae.typeUnicodeText: self.unpackunicodetext,
|
||||||
|
kae.typeLongDateTime: self.unpacklongdatetime,
|
||||||
|
kae.typeAEList: self.unpackaelist,
|
||||||
|
kae.typeAERecord: self.unpackaerecord,
|
||||||
|
kae.typeAlias: self.unpackfile,
|
||||||
|
kae.typeFSS: self.unpackfile,
|
||||||
|
kae.typeFSRef: self.unpackfile,
|
||||||
|
kae.typeFileURL: self.unpackfile,
|
||||||
|
kae.typeType: self.unpacktype,
|
||||||
|
kae.typeEnumeration: self.unpackenumeration,
|
||||||
|
}.items()}
|
||||||
|
|
||||||
|
def pack(self, data):
|
||||||
|
"""Pack Python data.
|
||||||
|
data : anything -- a Python value
|
||||||
|
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.encoders[data.__class__](data) # quick lookup by type/class
|
||||||
|
except (KeyError, AttributeError) as e:
|
||||||
|
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
|
||||||
|
if isinstance(data, type):
|
||||||
|
return encoder(data)
|
||||||
|
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
|
||||||
|
|
||||||
|
def unpack(self, desc):
|
||||||
|
"""Unpack an Apple event descriptor.
|
||||||
|
desc : NSAppleEventDescriptor
|
||||||
|
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
|
||||||
|
"""
|
||||||
|
decoder = self.decoders.get(desc.descriptorType())
|
||||||
|
# unpack known type
|
||||||
|
if decoder:
|
||||||
|
return decoder(desc)
|
||||||
|
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
|
||||||
|
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
|
||||||
|
if rec:
|
||||||
|
rec = self.unpackaerecord(rec)
|
||||||
|
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
|
||||||
|
return rec
|
||||||
|
# return as-is
|
||||||
|
return desc
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
def _packbytes(self, desctype, data):
|
||||||
|
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
|
||||||
|
fourcharcode(desctype), data, len(data))
|
||||||
|
|
||||||
|
def packdesc(self, val):
|
||||||
|
return val
|
||||||
|
|
||||||
|
def packnone(self, val):
|
||||||
|
return NSAppleEventDescriptor.nullDescriptor()
|
||||||
|
|
||||||
|
def packbool(self, val):
|
||||||
|
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
|
||||||
|
|
||||||
|
def packint(self, val):
|
||||||
|
if (-2**31) <= val < (2**31):
|
||||||
|
return NSAppleEventDescriptor.descriptorWithInt32_(val)
|
||||||
|
else:
|
||||||
|
return self.pack(float(val))
|
||||||
|
|
||||||
|
def packfloat(self, val):
|
||||||
|
return self._packbytes(kae.typeFloat, struct.pack('d', val))
|
||||||
|
|
||||||
|
def packbytes(self, val):
|
||||||
|
return self._packbytes(kae.typeData, val)
|
||||||
|
|
||||||
|
def packstr(self, val):
|
||||||
|
return NSAppleEventDescriptor.descriptorWithString_(val)
|
||||||
|
|
||||||
|
def packdatetime(self, val):
|
||||||
|
delta = val - self.kMacEpoch
|
||||||
|
sec = delta.days * 3600 * 24 + delta.seconds
|
||||||
|
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
|
||||||
|
|
||||||
|
def packlist(self, val):
|
||||||
|
lst = NSAppleEventDescriptor.listDescriptor()
|
||||||
|
for item in val:
|
||||||
|
lst.insertDescriptor_atIndex_(self.pack(item), 0)
|
||||||
|
return lst
|
||||||
|
|
||||||
|
def packdict(self, val):
|
||||||
|
record = NSAppleEventDescriptor.recordDescriptor()
|
||||||
|
usrf = desctype = None
|
||||||
|
for key, value in val.items():
|
||||||
|
if isinstance(key, AEType):
|
||||||
|
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
|
||||||
|
desctype = value
|
||||||
|
else:
|
||||||
|
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
|
||||||
|
else:
|
||||||
|
if not usrf:
|
||||||
|
usrf = NSAppleEventDescriptor.listDescriptor()
|
||||||
|
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
|
||||||
|
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
|
||||||
|
if usrf:
|
||||||
|
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
|
||||||
|
if desctype:
|
||||||
|
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
|
||||||
|
if newrecord:
|
||||||
|
record = newrecord
|
||||||
|
else: # coercion failed for some reason, so pack as normal key-value pair
|
||||||
|
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
|
||||||
|
return record
|
||||||
|
|
||||||
|
def packtype(self, val):
|
||||||
|
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
|
||||||
|
|
||||||
|
def packenum(self, val):
|
||||||
|
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
|
||||||
|
|
||||||
|
#######
|
||||||
|
|
||||||
|
def unpacknull(self, desc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def unpackboolean(self, desc):
|
||||||
|
return desc.booleanValue()
|
||||||
|
|
||||||
|
def unpacksint32(self, desc):
|
||||||
|
return desc.int32Value()
|
||||||
|
|
||||||
|
def unpackfloat64(self, desc):
|
||||||
|
return struct.unpack('d', bytes(desc.data()))[0]
|
||||||
|
|
||||||
|
def unpackunicodetext(self, desc):
|
||||||
|
return desc.stringValue()
|
||||||
|
|
||||||
|
def unpacklongdatetime(self, desc):
|
||||||
|
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
|
||||||
|
|
||||||
|
def unpackaelist(self, desc):
|
||||||
|
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
|
||||||
|
|
||||||
|
def unpackaerecord(self, desc):
|
||||||
|
dct = {}
|
||||||
|
for i in range(desc.numberOfItems()):
|
||||||
|
key = desc.keywordForDescriptorAtIndex_(i + 1)
|
||||||
|
value = desc.descriptorForKeyword_(key)
|
||||||
|
if key == self.kUSRF:
|
||||||
|
lst = self.unpackaelist(value)
|
||||||
|
for i in range(0, len(lst), 2):
|
||||||
|
dct[lst[i]] = lst[i+1]
|
||||||
|
else:
|
||||||
|
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
|
||||||
|
return dct
|
||||||
|
|
||||||
|
def unpacktype(self, desc):
|
||||||
|
return AEType(struct.pack('>I', desc.typeCodeValue()))
|
||||||
|
|
||||||
|
def unpackenumeration(self, desc):
|
||||||
|
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
|
||||||
|
|
||||||
|
def unpackfile(self, desc):
|
||||||
|
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
|
||||||
|
return NSURL.URLWithString_(url).path()
|
||||||
|
|
||||||
|
|
||||||
|
#######
|
||||||
|
|
||||||
|
|
||||||
|
class AETypeBase:
|
||||||
|
""" Base class for AEType and AEEnum.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code):
|
||||||
|
"""
|
||||||
|
code : bytes -- four-char code, e.g. b'utxt'
|
||||||
|
"""
|
||||||
|
if not isinstance(code, bytes):
|
||||||
|
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
|
||||||
|
elif len(code) != 4:
|
||||||
|
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
|
||||||
|
self._code = code
|
||||||
|
|
||||||
|
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self._code)
|
||||||
|
|
||||||
|
def __eq__(self, val):
|
||||||
|
return val.__class__ == self.__class__ and val.code == self._code
|
||||||
|
|
||||||
|
def __ne__(self, val):
|
||||||
|
return not self == val
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "{}({!r})".format(self.__class__.__name__, self._code)
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
class AEType(AETypeBase):
|
||||||
|
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
|
||||||
|
|
||||||
|
|
||||||
|
class AEEnum(AETypeBase):
|
||||||
|
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""
|
||||||
|
|
||||||
1720
osxphotos/_applescript/kae.py
Normal file
@@ -16,13 +16,9 @@ from pprint import pformat
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from ._constants import (
|
from ._constants import (_MOVIE_TYPE, _PHOTO_TYPE, _PHOTOS_5_SHARED_PHOTO_PATH,
|
||||||
_MOVIE_TYPE,
|
_PHOTOS_5_VERSION)
|
||||||
_PHOTO_TYPE,
|
from .utils import _copy_file, _get_resource_loc, dd_to_dms_str
|
||||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
|
||||||
_PHOTOS_5_VERSION,
|
|
||||||
)
|
|
||||||
from .utils import _get_resource_loc, dd_to_dms_str, _copy_file
|
|
||||||
|
|
||||||
# TODO: check pylint output
|
# TODO: check pylint output
|
||||||
|
|
||||||
@@ -140,6 +136,9 @@ class PhotoInfo:
|
|||||||
logging.debug(f"WARNING: unknown type {self._info['type']}")
|
logging.debug(f"WARNING: unknown type {self._info['type']}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# photopath appears to usually be in "00" subfolder but
|
||||||
|
# could be elsewhere--I haven't figured out this logic yet
|
||||||
|
# first see if it's in 00
|
||||||
photopath = os.path.join(
|
photopath = os.path.join(
|
||||||
library,
|
library,
|
||||||
"resources",
|
"resources",
|
||||||
@@ -149,6 +148,21 @@ class PhotoInfo:
|
|||||||
"00",
|
"00",
|
||||||
filename,
|
filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not os.path.isfile(photopath):
|
||||||
|
rootdir = os.path.join(
|
||||||
|
library,
|
||||||
|
"resources",
|
||||||
|
"media",
|
||||||
|
"version",
|
||||||
|
folder_id)
|
||||||
|
|
||||||
|
for dirname, _, filelist in os.walk(rootdir):
|
||||||
|
if filename in filelist:
|
||||||
|
photopath = os.path.join(dirname, filename)
|
||||||
|
break
|
||||||
|
|
||||||
|
# check again to see if we found a valid file
|
||||||
if not os.path.isfile(photopath):
|
if not os.path.isfile(photopath):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||||
@@ -424,17 +438,22 @@ class PhotoInfo:
|
|||||||
overwrite=False,
|
overwrite=False,
|
||||||
increment=True,
|
increment=True,
|
||||||
sidecar=False,
|
sidecar=False,
|
||||||
|
use_photos_export=False,
|
||||||
|
timeout=120,
|
||||||
):
|
):
|
||||||
""" export photo """
|
""" export photo
|
||||||
""" first argument must be valid destination path (or exception raised) """
|
dest: must be valid destination path (or exception raised)
|
||||||
""" second argument (optional): name of picture; if not provided, will use current filename """
|
filename: (optional): name of picture; if not provided, will use current filename
|
||||||
""" if edited=True (default=False), will export the edited version of the photo (or raise exception if no edited version) """
|
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||||
""" if overwrite=True (default=False), will overwrite files if they alreay exist """
|
(or raise exception if no edited version)
|
||||||
""" if increment=True (default=True), will increment file name until a non-existant name is found """
|
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||||
""" if overwrite=False and increment=False, export will fail if destination file already exists """
|
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||||
""" if sidecar=True, will also write a json sidecar with EXIF data in format readable by exiftool """
|
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||||
""" sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) """
|
sidecar: (boolean, default = False); if True will also write a json sidecar with EXIF data in format readable by exiftool
|
||||||
""" returns the full path to the exported file """
|
sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg)
|
||||||
|
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||||
|
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||||
|
returns the full path to the exported file """
|
||||||
|
|
||||||
# TODO: add this docs:
|
# TODO: add this docs:
|
||||||
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
|
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import os.path
|
import os.path
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from plistlib import load as plistload
|
from plistlib import load as plistload
|
||||||
@@ -11,6 +12,8 @@ import CoreFoundation
|
|||||||
import objc
|
import objc
|
||||||
from Foundation import *
|
from Foundation import *
|
||||||
|
|
||||||
|
from osxphotos._applescript import AppleScript
|
||||||
|
|
||||||
_DEBUG = False
|
_DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
@@ -284,3 +287,70 @@ def create_path_by_date(dest, dt):
|
|||||||
if not os.path.isdir(new_dest):
|
if not os.path.isdir(new_dest):
|
||||||
os.makedirs(new_dest)
|
os.makedirs(new_dest)
|
||||||
return new_dest
|
return new_dest
|
||||||
|
|
||||||
|
|
||||||
|
def _export_photo_uuid_applescript(
|
||||||
|
uuid, dest, original=True, edited=False, timeout=120
|
||||||
|
):
|
||||||
|
""" Export photo to dest path using applescript to control Photos
|
||||||
|
uuid: UUID of photo to export
|
||||||
|
dest: destination path to export to; may be either a directory or a filename
|
||||||
|
if filename provided and file exists, exiting file will be overwritten
|
||||||
|
original: (boolean) if True, export original image; default = True
|
||||||
|
edited: (boolean) if True, export edited photo; default = False
|
||||||
|
will produce an error if image does not have edits/adjustments
|
||||||
|
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||||
|
"""
|
||||||
|
|
||||||
|
# setup the applescript to do the export
|
||||||
|
export_scpt = AppleScript(
|
||||||
|
"""
|
||||||
|
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
|
||||||
|
tell application "Photos"
|
||||||
|
activate
|
||||||
|
set thePath to thePath
|
||||||
|
set theItem to media item id theUUID
|
||||||
|
set theFilename to filename of theItem
|
||||||
|
set itemList to {theItem}
|
||||||
|
|
||||||
|
if original then
|
||||||
|
with timeout of theTimeOut seconds
|
||||||
|
export itemList to POSIX file thePath with using originals
|
||||||
|
end timeout
|
||||||
|
end if
|
||||||
|
|
||||||
|
if edited then
|
||||||
|
with timeout of theTimeOut seconds
|
||||||
|
export itemList to POSIX file thePath
|
||||||
|
end timeout
|
||||||
|
end if
|
||||||
|
|
||||||
|
return theFilename
|
||||||
|
end tell
|
||||||
|
|
||||||
|
end export_by_uuid
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
|
|
||||||
|
# export original
|
||||||
|
filename = None
|
||||||
|
try:
|
||||||
|
filename = export_scpt.call(
|
||||||
|
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning("Error exporting uuid %s: %s" % (uuid, str(e)))
|
||||||
|
return None
|
||||||
|
|
||||||
|
if filename is not None:
|
||||||
|
path = os.path.join(tmpdir.name, filename)
|
||||||
|
_copy_file(path, dest)
|
||||||
|
if os.path.isdir(dest):
|
||||||
|
new_path = os.path.join(dest, filename)
|
||||||
|
else:
|
||||||
|
new_path = dest
|
||||||
|
return new_path
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|||||||
|
After Width: | Height: | Size: 545 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 453 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>DatabaseMinorVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>DatabaseVersion</key>
|
||||||
|
<integer>112</integer>
|
||||||
|
<key>LastOpenMode</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
<key>LibrarySchemaVersion</key>
|
||||||
|
<integer>4025</integer>
|
||||||
|
<key>MetaSchemaVersion</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
<key>createDate</key>
|
||||||
|
<date>2019-07-27T13:16:43Z</date>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
tests/Test-10.14.6-path_edited.photoslibrary/database/photos.db
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Photos</key>
|
||||||
|
<dict>
|
||||||
|
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||||
|
<array/>
|
||||||
|
<key>ExpandedSidebarItemIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>TopLevelAlbums</string>
|
||||||
|
<string>TopLevelSlideshows</string>
|
||||||
|
</array>
|
||||||
|
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||||
|
<dict>
|
||||||
|
<key>kZoomLevelIdentifierAlbums</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>kZoomLevelIdentifierVersions</key>
|
||||||
|
<integer>7</integer>
|
||||||
|
</dict>
|
||||||
|
<key>lastAddToDestination</key>
|
||||||
|
<dict>
|
||||||
|
<key>key</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>lastKnownDisplayName</key>
|
||||||
|
<string>Test Album (1)</string>
|
||||||
|
<key>type</key>
|
||||||
|
<string>album</string>
|
||||||
|
<key>uuid</key>
|
||||||
|
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
|
||||||
|
</dict>
|
||||||
|
<key>lastKnownItemCounts</key>
|
||||||
|
<dict>
|
||||||
|
<key>other</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>photos</key>
|
||||||
|
<integer>6</integer>
|
||||||
|
<key>videos</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||||
|
<date>2020-01-11T16:41:00Z</date>
|
||||||
|
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||||
|
<date>2020-01-12T06:02:45Z</date>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>ProcessedInQuiescentState</key>
|
||||||
|
<true/>
|
||||||
|
<key>SuggestedMeIdentifier</key>
|
||||||
|
<string></string>
|
||||||
|
<key>Version</key>
|
||||||
|
<integer>3</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PVClustererBringUpState</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IncrementalPersonProcessingStage</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>PersonBuilderLastMinimumFaceGroupSizeForCreatingMergeCandidates</key>
|
||||||
|
<integer>15</integer>
|
||||||
|
<key>PersonBuilderMergeCandidatesEnabled</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>LithiumMessageTracer</key>
|
||||||
|
<dict>
|
||||||
|
<key>LastReportedDate</key>
|
||||||
|
<date>2020-01-04T18:29:59Z</date>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 524 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PLLanguageAndLocaleKey</key>
|
||||||
|
<string>en-US:en_US</string>
|
||||||
|
<key>PLLastGeoProviderIdKey</key>
|
||||||
|
<string>7618</string>
|
||||||
|
<key>PLLastLocationInfoFormatVer</key>
|
||||||
|
<integer>12</integer>
|
||||||
|
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||||
|
<date>2020-01-11T16:40:56Z</date>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>LastHistoryRowId</key>
|
||||||
|
<integer>575</integer>
|
||||||
|
<key>LibraryBuildTag</key>
|
||||||
|
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||||
|
<key>LibrarySchemaVersion</key>
|
||||||
|
<integer>4025</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>FileVersion</key>
|
||||||
|
<integer>11</integer>
|
||||||
|
<key>Source</key>
|
||||||
|
<dict>
|
||||||
|
<key>35230</key>
|
||||||
|
<dict>
|
||||||
|
<key>CountryMinVersions</key>
|
||||||
|
<dict>
|
||||||
|
<key>OTHER</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>CurrentVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NoResultErrorIsSuccess</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>57879</key>
|
||||||
|
<dict>
|
||||||
|
<key>CountryMinVersions</key>
|
||||||
|
<dict>
|
||||||
|
<key>OTHER</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>CurrentVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NoResultErrorIsSuccess</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>7618</key>
|
||||||
|
<dict>
|
||||||
|
<key>AddCountyIfNeeded</key>
|
||||||
|
<true/>
|
||||||
|
<key>CountryMinVersions</key>
|
||||||
|
<dict>
|
||||||
|
<key>OTHER</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
</dict>
|
||||||
|
<key>CurrentVersion</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 329 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>DatabaseMinorVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>DatabaseVersion</key>
|
||||||
|
<integer>112</integer>
|
||||||
|
<key>HistoricalMarker</key>
|
||||||
|
<dict>
|
||||||
|
<key>LastHistoryRowId</key>
|
||||||
|
<integer>575</integer>
|
||||||
|
<key>LibraryBuildTag</key>
|
||||||
|
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||||
|
<key>LibrarySchemaVersion</key>
|
||||||
|
<integer>4025</integer>
|
||||||
|
</dict>
|
||||||
|
<key>LibrarySchemaVersion</key>
|
||||||
|
<integer>4025</integer>
|
||||||
|
<key>MetaSchemaVersion</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
<key>SnapshotComplete</key>
|
||||||
|
<true/>
|
||||||
|
<key>SnapshotCompletedDate</key>
|
||||||
|
<date>2019-07-27T13:16:43Z</date>
|
||||||
|
<key>SnapshotLastValidated</key>
|
||||||
|
<date>2020-01-12T06:04:41Z</date>
|
||||||
|
<key>SnapshotTables</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
40
tests/test_mojave_10_14_6_path_edited.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
# test ability to search for edited files
|
||||||
|
|
||||||
|
PHOTOS_DB = "./tests/Test-10.14.6-path_edited.photoslibrary/database/photos.db"
|
||||||
|
PHOTOS_DB_PATH = "/Test-10.14.6-path_edited.photoslibrary/database/photos.db"
|
||||||
|
PHOTOS_LIBRARY_PATH = "/Test-10.14.6-path_edited.photoslibrary"
|
||||||
|
|
||||||
|
UUID_DICT = {
|
||||||
|
"non_00_path": "6bxcNnzRQKGnK4uPrCJ9UQ",
|
||||||
|
"standard_00_path": "3Jn73XpSQQCluzRBMWRsMA",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_edited1():
|
||||||
|
# test a valid edited path
|
||||||
|
import os.path
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["standard_00_path"]])
|
||||||
|
assert len(photos) == 1
|
||||||
|
p = photos[0]
|
||||||
|
path = p.path_edited
|
||||||
|
assert path.endswith("resources/media/version/00/00/fullsizeoutput_d.jpeg")
|
||||||
|
assert os.path.exists(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_edited2():
|
||||||
|
# test a non-standard (not 00) edited path
|
||||||
|
import os.path
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["non_00_path"]])
|
||||||
|
assert len(photos) == 1
|
||||||
|
p = photos[0]
|
||||||
|
path = p.path_edited
|
||||||
|
assert path.endswith("resources/media/version/00/02/fullsizeoutput_9.jpeg")
|
||||||
|
assert os.path.exists(path)
|
||||||