diff --git a/photoscript/__init__.py b/photoscript/__init__.py index 9a7c57c..1c87ec1 100644 --- a/photoscript/__init__.py +++ b/photoscript/__init__.py @@ -1,5 +1,7 @@ """ Provides PhotosLibrary, Photo, Album classes to interact with Photos App """ +from __future__ import annotations + import datetime import glob import os @@ -14,6 +16,9 @@ from photoscript.utils import ditto, findfiles from .script_loader import run_script +from .utils import get_os_version + +MACOS_VERSION = get_os_version() """ In Catalina / Photos 5+, UUIDs in AppleScript have suffix that doesn't appear in actual database value. These need to be dropped to be compatible @@ -23,6 +28,29 @@ UUID_SUFFIX_FOLDER = "/L0/020" +def uuid_to_id(uuid: str, suffix: str) -> tuple[str, str]: + """Converts UUID betweens formats used by osxphotos and Photos app + + Args: + uuid: UUID as string + suffix: suffix to add to UUID if needed to form id + + Returns: + tuple of (uuid, id) + """ + id_ = uuid + if MACOS_VERSION >= (10, 15, 0): + # In Photos 5+ (Catalina/10.15), UUIDs in AppleScript have suffix that doesn't + # appear in actual database value. Suffix needs to be added to be compatible + # with AppleScript (id_) and dropped for osxphotos (uuid) + if len(uuid.split("/")) == 1: + # osxphotos style UUID without the suffix + id_ = f"{uuid}{suffix}" + else: + uuid = uuid.split("/")[0] + return uuid, id_ + + class AppleScriptError(Exception): def __init__(self, *message): super().__init__(*message) @@ -553,17 +581,9 @@ def _export_photo( class Album: def __init__(self, uuid): - id_ = uuid # check to see if we need to add UUID suffix - if float(PhotosLibrary().version) >= 5.0: - if len(uuid.split("/")) == 1: - # osxphotos style UUID without the suffix - id_ = f"{uuid}{UUID_SUFFIX_ALBUM}" - else: - uuid = uuid.split("/")[0] - - valuuidalbum = run_script("albumExists", id_) - if valuuidalbum: + uuid, id_ = uuid_to_id(uuid, UUID_SUFFIX_ALBUM) + if valuuidalbum := run_script("albumExists", id_): self.id = id_ self._uuid = uuid else: @@ -761,39 +781,76 @@ def __len__(self): class Folder: - def __init__(self, uuid): - id_ = uuid - # check to see if we need to add UUID suffix - if float(PhotosLibrary().version) >= 5.0: - if len(uuid.split("/")) == 1: - # osxphotos style UUID without the suffix - id_ = f"{uuid}{UUID_SUFFIX_FOLDER}" - else: - uuid = uuid.split("/")[0] + def __init__( + self, + path: list[str] | None = None, + uuid: str | None = None, + idstring: str | None = None, + ): + """Create a Folder object; only one of path, uuid, or idstring should be specified - valid_folder = run_script("folderExists", id_) - if valid_folder: - self.id = id_ - self._uuid = uuid + Args: + path: list of folder names in descending order from parent to child: ["Folder", "SubFolder"] + uuid: uuid of folder: "E0CD4B6C-CB43-46A6-B8A3-67D1FB4D0F3D/L0/020" or "E0CD4B6C-CB43-46A6-B8A3-67D1FB4D0F3D" + idstring: idstring of folder: + "folder id(\"E0CD4B6C-CB43-46A6-B8A3-67D1FB4D0F3D/L0/020\") of folder id(\"CB051A4C-2CB7-4B90-B59B-08CC4D0C2823/L0/020\")" + """ + + if sum(bool(x) for x in (path, uuid, idstring)) != 1: + raise ValueError( + "One (and only one) of path, uuid, or idstring must be specified" + ) + + if uuid is not None: + uuid, _id = uuid_to_id(uuid, UUID_SUFFIX_FOLDER) else: - raise ValueError(f"Invalid folder id: {uuid}") + _id = None + + self._path, self._uuid, self._id, self._idstring = path, uuid, _id, idstring + + # if initialized with path or uuid, need to initialize idstring + if self._path is not None: + self._idstring = run_script("folderGetIDStringFromPath", self._path) + elif self._id is not None: + # if uuid was passed, _id will have been initialized above + self._idstring = run_script("photosLibraryGetFolderIDStringForID", self._id) + + @property + def idstring(self) -> str: + """idstring of folder""" + return self._idstring @property def uuid(self): """UUID of folder""" + if self._uuid is not None: + return self._uuid + self._uuid, self._id = uuid_to_id( + run_script("folderUUID", self._idstring), UUID_SUFFIX_FOLDER + ) return self._uuid + @property + def id(self): + """ID of folder""" + if self._id is not None: + return self._id + self._uuid, self._id = uuid_to_id( + run_script("folderUUID", self._idstring), UUID_SUFFIX_FOLDER + ) + return self._id + @property def name(self): """name of folder (read/write)""" - name = run_script("folderName", self.id) + name = run_script("folderName", self._idstring) return name if name != kMissingValue else "" @name.setter def name(self, name): """set name of photo""" name = "" if name is None else name - return run_script("folderSetName", self.id, name) + return run_script("folderSetName", self._idstring, name) @property def title(self): @@ -804,17 +861,18 @@ def title(self): def title(self, title): """set title of folder (alias for name)""" name = "" if title is None else title - return run_script("folderSetName", self.id, name) + return run_script("folderSetName", self._idstring, name) @property def parent_id(self): """parent container id""" - return run_script("folderParent", self.id) + return run_script("folderParent", self._idstring) # TODO: if no parent should return a "My Albums" object that contains all top-level folders/albums? @property def parent(self): """Return parent Folder object""" + # ZZZ parent_id = self.parent_id if parent_id != 0: return Folder(parent_id) @@ -909,16 +967,9 @@ def __len__(self): class Photo: def __init__(self, uuid): - id_ = uuid # check to see if we need to add UUID suffix - if float(PhotosLibrary().version) >= 5.0: - if len(uuid.split("/")) == 1: - # osxphotos style UUID without the suffix - id_ = f"{uuid}{UUID_SUFFIX_PHOTO}" - else: - uuid = uuid.split("/")[0] - valid = run_script("photoExists", uuid) - if valid: + uuid, id_ = uuid_to_id(uuid, UUID_SUFFIX_PHOTO) + if valid := run_script("photoExists", uuid): self.id = id_ self._uuid = uuid else: diff --git a/photoscript/photoscript.applescript b/photoscript/photoscript.applescript index 9a1d6cb..76aaec2 100644 --- a/photoscript/photoscript.applescript +++ b/photoscript/photoscript.applescript @@ -275,7 +275,7 @@ on _walkFoldersLookingForID(theFolderID, theFolder, folderString) tell application "Photos" set subFolders to theFolder's folders repeat with aFolder in subFolders - set folderString to "folder (\"" & (id of aFolder as text) & "\") of " & folderString + set folderString to "folder id(\"" & (id of aFolder as text) & "\") of " & folderString if id of aFolder is equal to theFolderID then return folderString end if @@ -291,7 +291,7 @@ on photosLibraryGetFolderIDStringForID(theFolderID) tell application "Photos" set theFolders to folders repeat with aFolder in theFolders - set folderString to "folder (\"" & (id of aFolder as text) & "\")" + set folderString to "folder id(\"" & (id of aFolder as text) & "\")" if id of aFolder is equal to theFolderID then return folderString end if @@ -326,7 +326,7 @@ on _walkFoldersLookingForName(theFolderName, theFolder, folderString) tell application "Photos" set subFolders to theFolder's folders repeat with aFolder in subFolders - set folderString to "folder (\"" & (id of aFolder as text) & "\") of " & folderString + set folderString to "folder id(\"" & (id of aFolder as text) & "\") of " & folderString if aFolder's name is equal to theFolderName then return folderString end if @@ -342,7 +342,7 @@ on photosLibraryGetFolderIDStringForName(theFolderName) tell application "Photos" set theFolders to folders repeat with aFolder in theFolders - set folderString to "folder (\"" & (id of aFolder as text) & "\")" + set folderString to "folder id(\"" & (id of aFolder as text) & "\")" if aFolder's name is equal to theFolderName then return folderString end if diff --git a/photoscript/utils.py b/photoscript/utils.py index 1a32840..647a16d 100644 --- a/photoscript/utils.py +++ b/photoscript/utils.py @@ -1,21 +1,24 @@ """ Utility functions for photoscript """ +from __future__ import annotations + import fnmatch import os +import platform import re import subprocess def ditto(src, dest, norsrc=False): - """ Copies a file or directory tree from src path to dest path - src: source path as string - dest: destination path as string - norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy - resource fork or extended attributes. May be useful on volumes that - don't work with extended attributes (likely only certain SMB mounts) - default is False - Uses ditto to perform copy; will silently overwrite dest if it exists - Raises exception if copy fails or either path is None """ + """Copies a file or directory tree from src path to dest path + src: source path as string + dest: destination path as string + norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy + resource fork or extended attributes. May be useful on volumes that + don't work with extended attributes (likely only certain SMB mounts) + default is False + Uses ditto to perform copy; will silently overwrite dest if it exists + Raises exception if copy fails or either path is None""" if src is None or dest is None: raise ValueError("src and dest must not be None", src, dest) @@ -33,11 +36,29 @@ def ditto(src, dest, norsrc=False): def findfiles(pattern, path_): """Returns list of filenames from path_ matched by pattern - shell pattern. Matching is case-insensitive. - If 'path_' is invalid/doesn't exist, returns [].""" + shell pattern. Matching is case-insensitive. + If 'path_' is invalid/doesn't exist, returns [].""" if not os.path.isdir(path_): return [] # See: https://gist.github.com/techtonik/5694830 rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE) return [name for name in os.listdir(path_) if rule.match(name)] + + +def get_os_version() -> tuple[int, int, int]: + # returns tuple of str containing OS version + # e.g. 10.13.6 = ("10", "13", "6") + version = platform.mac_ver()[0].split(".") + if len(version) == 2: + (ver, major) = version + minor = 0 + elif len(version) == 3: + (ver, major, minor) = version + else: + raise ( + ValueError( + f"Could not parse version string: {platform.mac_ver()} {version}" + ) + ) + return tuple(map(int, (ver, major, minor)))