Added support for projects, implements #559

This commit is contained in:
Rhet Turnbull
2021-12-31 07:30:20 -08:00
parent 690d981f31
commit 44594a8e43
231 changed files with 1239 additions and 5545 deletions

View File

@@ -123,12 +123,20 @@ _XMP_TEMPLATE_NAME_BETA = "xmp_sidecar_beta.mako"
# Constants used for processing folders and albums
_PHOTOS_5_ALBUM_KIND = 2 # normal user album
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
_PHOTOS_5_PROJECT_ALBUM_KIND = 1508 # My Projects (e.g. Calendar, Card, Slideshow)
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND = 1506 # import session
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
_PHOTOS_4_ALBUM_TYPE_ALBUM = 1 # RKAlbum.albumType
_PHOTOS_4_ALBUM_TYPE_PROJECT = 9 # RKAlbum.albumType
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW = 8 # RKAlbum.albumType
_PHOTOS_4_TOP_LEVEL_ALBUMS = [
"TopLevelAlbums",
"TopLevelKeepsakes",
"TopLevelSlideshows",
]
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
# EXIF related constants

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.43.9"
__version__ = "0.44.0"

View File

@@ -14,7 +14,7 @@ from datetime import datetime, timedelta, timezone
from ._constants import (
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_4_TOP_LEVEL_ALBUMS,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
@@ -161,7 +161,6 @@ class AlbumInfoBaseClass:
class AlbumInfo(AlbumInfoBaseClass):
"""
Base class for AlbumInfo, ImportInfo
Info about a specific Album, contains all the details about the album
including folders, photos, etc.
"""
@@ -231,7 +230,7 @@ class AlbumInfo(AlbumInfoBaseClass):
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS
else None
)
else:
@@ -266,18 +265,17 @@ class AlbumInfo(AlbumInfoBaseClass):
def photo_index(self, photo):
"""return index of photo in album (based on album sort order)"""
index = 0
for p in self.photos:
for index, p in enumerate(self.photos):
if p.uuid == photo.uuid:
return index
index += 1
else:
raise ValueError(
f"Photo with uuid {photo.uuid} does not appear to be in this album"
)
raise ValueError(
f"Photo with uuid {photo.uuid} does not appear to be in this album"
)
class ImportInfo(AlbumInfoBaseClass):
"""Information about import sessions"""
@property
def photos(self):
"""return list of photos contained in import session"""
@@ -296,6 +294,15 @@ class ImportInfo(AlbumInfoBaseClass):
return self._photos
class ProjectInfo(AlbumInfo):
"""
ProjectInfo with info about projects
Projects are cards, calendars, slideshows, etc.
"""
...
class FolderInfo:
"""
Info about a specific folder, contains all the details about the folder
@@ -357,7 +364,7 @@ class FolderInfo:
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS
else None
)
else:

View File

@@ -4159,6 +4159,7 @@ def _spotlight_photo(photo: PhotoInfo):
)
def repl(ctx, cli_obj, db, emacs):
"""Run interactive osxphotos REPL shell (useful for debugging, prototyping, and inspecting your Photos library)"""
import logging
from objexplore import explore
from photoscript import Album, Photo, PhotosLibrary
@@ -4169,6 +4170,9 @@ def repl(ctx, cli_obj, db, emacs):
from osxphotos.placeinfo import PlaceInfo
from osxphotos.queryoptions import QueryOptions
logger = logging.getLogger()
logger.disabled = True
pretty.install()
print(f"python version: {sys.version}")
print(f"osxphotos version: {osxphotos._version.__version__}")

View File

@@ -20,10 +20,14 @@ from .._constants import (
_MOVIE_TYPE,
_PHOTO_TYPE,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ALBUM_TYPE_ALBUM,
_PHOTOS_4_ALBUM_TYPE_PROJECT,
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
_PHOTOS_5_PROJECT_ALBUM_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
@@ -34,7 +38,7 @@ from .._constants import (
TEXT_DETECTION_CONFIDENCE_THRESHOLD,
)
from ..adjustmentsinfo import AdjustmentsInfo
from ..albuminfo import AlbumInfo, ImportInfo
from ..albuminfo import AlbumInfo, ImportInfo, ProjectInfo
from ..momentinfo import MomentInfo
from ..personinfo import FaceInfo, PersonInfo
from ..phototemplate import PhotoTemplate, RenderOptions
@@ -570,6 +574,18 @@ class PhotoInfo:
)
return self._import_info
@property
def project_info(self):
"""list of AlbumInfo objects representing projects for the photo or None if no projects"""
try:
return self._project_info
except AttributeError:
project_uuids = self._get_album_uuids(project=True)
self._project_info = [
ProjectInfo(db=self._db, uuid=album) for album in project_uuids
]
return self._project_info
@property
def keywords(self):
"""list of keywords for picture"""
@@ -1197,34 +1213,48 @@ class PhotoInfo:
"""Returns latitude, in degrees"""
return self._info["latitude"]
def _get_album_uuids(self):
def _get_album_uuids(self, project=False):
"""Return list of album UUIDs this photo is found in
Filters out albums in the trash and any special album types
if project is True, returns special "My Project" albums (e.g. cards, calendars, slideshows)
Returns: list of album UUIDs
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
version4 = True
album_kind = [_PHOTOS_4_ALBUM_KIND]
else:
version4 = False
album_kind = [_PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND]
album_type = (
[_PHOTOS_4_ALBUM_TYPE_PROJECT, _PHOTOS_4_ALBUM_TYPE_SLIDESHOW]
if project
else [_PHOTOS_4_ALBUM_TYPE_ALBUM]
)
album_list = []
for album in self._info["albums"]:
detail = self._db._dbalbum_details[album]
if (
detail["kind"] in album_kind
and detail["albumType"] in album_type
and not detail["intrash"]
and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER
# in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
):
album_list.append(album)
return album_list
# Photos 5+
album_kind = (
[_PHOTOS_5_PROJECT_ALBUM_KIND]
if project
else [_PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND]
)
album_list = []
for album in self._info["albums"]:
detail = self._db._dbalbum_details[album]
if (
detail["kind"] in album_kind
and not detail["intrash"]
and (
not version4
# in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
)
):
if detail["kind"] in album_kind and not detail["intrash"]:
album_list.append(album)
return album_list

View File

@@ -28,11 +28,15 @@ from .._constants import (
_PHOTOS_3_VERSION,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_4_TOP_LEVEL_ALBUMS,
_PHOTOS_4_ALBUM_TYPE_ALBUM,
_PHOTOS_4_ALBUM_TYPE_PROJECT,
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
_PHOTOS_5_PROJECT_ALBUM_KIND,
_PHOTOS_5_ROOT_FOLDER_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_TESTED_OS_VERSIONS,
@@ -42,7 +46,7 @@ from .._constants import (
TIME_DELTA,
)
from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo, ProjectInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..fileutil import FileUtil
from ..personinfo import PersonInfo
@@ -429,7 +433,7 @@ class PhotosDB:
for folder, detail in self._dbfolder_details.items()
if not detail["intrash"]
and not detail["isMagic"]
and detail["parentFolderUuid"] == _PHOTOS_4_TOP_LEVEL_ALBUM
and detail["parentFolderUuid"] in _PHOTOS_4_TOP_LEVEL_ALBUMS
]
else:
folders = [
@@ -450,7 +454,7 @@ class PhotosDB:
for folder in self._dbfolder_details.values()
if not folder["intrash"]
and not folder["isMagic"]
and folder["parentFolderUuid"] == _PHOTOS_4_TOP_LEVEL_ALBUM
and folder["parentFolderUuid"] in _PHOTOS_4_TOP_LEVEL_ALBUMS
]
else:
folder_names = [
@@ -529,6 +533,18 @@ class PhotosDB:
]
return self._import_info
@property
def project_info(self):
"""return list of AlbumInfo projects for each project in the database"""
try:
return self._project_info
except AttributeError:
self._project_info = [
ProjectInfo(db=self, uuid=album)
for album in self._get_album_uuids(project=True)
]
return self._project_info
@property
def db_version(self):
"""return the database version as stored in LiGlobals table"""
@@ -848,11 +864,10 @@ class PhotosDB:
# build folder hierarchy
for album, details in self._dbalbum_details.items():
parent_folder = details["folderUuid"]
if details[
"albumSubclass"
] == _PHOTOS_4_ALBUM_KIND and parent_folder not in [
_PHOTOS_4_TOP_LEVEL_ALBUM
]:
if (
details["albumSubclass"] == _PHOTOS_4_ALBUM_KIND
and parent_folder not in _PHOTOS_4_TOP_LEVEL_ALBUMS
):
folder_hierarchy = self._build_album_folder_hierarchy_4(parent_folder)
self._dbalbum_folders[album] = folder_hierarchy
else:
@@ -1582,7 +1597,7 @@ class PhotosDB:
if parent_uuid is None:
return folders
if parent_uuid == _PHOTOS_4_TOP_LEVEL_ALBUM:
if parent_uuid in _PHOTOS_4_TOP_LEVEL_ALBUMS:
if not folders:
# this is a top-level folder with no sub-folders
folders = {uuid: None}
@@ -2825,7 +2840,7 @@ class PhotosDB:
hierarchy = _recurse_folder_hierarchy(folders)
return hierarchy
def _get_album_uuids(self, shared=False, import_session=False):
def _get_album_uuids(self, shared=False, import_session=False, project=False):
"""Return list of album UUIDs found in photos database
Filters out albums in the trash and any special album types
@@ -2833,20 +2848,21 @@ class PhotosDB:
Args:
shared: boolean; if True, returns shared albums, else normal albums
import_session: boolean, if True, returns import session albums, else normal or shared albums
project: boolean, if True, returns albums that are part of My Projects
Note: flags (shared, import_session) are mutually exclusive
Raises:
ValueError: raised if mutually exclusive flags passed
Returns: list of album UUIDs
"""
if shared and import_session:
if sum(bool(x) for x in [shared, import_session, project]) > 1:
raise ValueError(
"flags are mutually exclusive: pass zero or one of shared, import_session"
"flags are mutually exclusive: pass zero or one of shared, import_session, projects"
)
if self._db_version <= _PHOTOS_4_VERSION:
version4 = True
if shared:
logging.warning(
f"Shared albums not implemented for Photos library version {self._db_version}"
@@ -2857,16 +2873,44 @@ class PhotosDB:
f"Import sessions not implemented for Photos library version {self._db_version}"
)
return [] # not implemented for _PHOTOS_4_VERSION
else:
elif project:
album_type = [
_PHOTOS_4_ALBUM_TYPE_PROJECT,
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW,
]
album_kind = _PHOTOS_4_ALBUM_KIND
else:
version4 = False
if shared:
album_kind = _PHOTOS_5_SHARED_ALBUM_KIND
elif import_session:
album_kind = _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND
else:
album_kind = _PHOTOS_5_ALBUM_KIND
album_type = [_PHOTOS_4_ALBUM_TYPE_ALBUM]
album_kind = _PHOTOS_4_ALBUM_KIND
album_list = []
# look through _dbalbum_details because _dbalbums_album won't have empty albums it
for album, detail in self._dbalbum_details.items():
if (
detail["kind"] == album_kind
and detail["albumType"] in album_type
and not detail["intrash"]
and (
(shared and detail["cloudownerhashedpersonid"] is not None)
or (not shared and detail["cloudownerhashedpersonid"] is None)
)
and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER
# in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
):
album_list.append(album)
return album_list
# Photos version 5+
if shared:
album_kind = _PHOTOS_5_SHARED_ALBUM_KIND
elif import_session:
album_kind = _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND
elif project:
album_kind = _PHOTOS_5_PROJECT_ALBUM_KIND
else:
album_kind = _PHOTOS_5_ALBUM_KIND
album_list = []
# look through _dbalbum_details because _dbalbums_album won't have empty albums it
@@ -2878,13 +2922,6 @@ class PhotosDB:
(shared and detail["cloudownerhashedpersonid"] is not None)
or (not shared and detail["cloudownerhashedpersonid"] is None)
)
and (
not version4
# in Photos 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
)
):
album_list.append(album)
return album_list

View File

@@ -181,6 +181,9 @@ TEMPLATE_SUBSTITUTIONS_PATHLIB = {
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{album}": "Album(s) photo is contained in",
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
"{project}": "Project(s) photo is contained in (such as greeting cards, calendars, slideshows)",
"{album_project}": "Album(s) and project(s) photo is contained in; treats projects as regular albums",
"{folder_album_project}": "Folder path + album (includes projects as albums) photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
"{keyword}": "Keyword(s) assigned to photo",
"{person}": "Person(s) / face(s) in a photo",
"{label}": "Image categorization label associated with a photo (Photos 5+ only). "
@@ -1116,6 +1119,11 @@ class PhotoTemplate:
values = []
if field == "album":
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
elif field == "project":
values = [p.title for p in self.photo.project_info]
elif field == "album_project":
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
values += [p.title for p in self.photo.project_info]
elif field == "keyword":
values = self.photo.keywords
elif field == "person":
@@ -1126,13 +1134,15 @@ class PhotoTemplate:
values = self.photo.labels
elif field == "label_normalized":
values = self.photo.labels_normalized
elif field == "folder_album":
elif field in ["folder_album", "folder_album_project"]:
values = []
# photos must be in an album to be in a folder
if self.photo.burst:
album_info = self.photo.burst_album_info
else:
album_info = self.photo.album_info
if field == "folder_album_project":
album_info += self.photo.project_info
for album in album_info:
if album.folder_names:
# album in folder
@@ -1193,7 +1203,7 @@ class PhotoTemplate:
elif isinstance(obj, (str, int, float)):
values = [str(obj)]
else:
values = [val for val in obj]
values = list(obj)
elif field == "detected_text":
values = _get_detected_text(self.photo, self.exportdb, confidence=subfield)
else: