Compare commits

..

38 Commits

Author SHA1 Message Date
Rhet Turnbull
146d54197f README.md update 2020-01-12 19:45:01 -08:00
Rhet Turnbull
f3674ef58b Updated README 2020-01-12 08:47:42 -08:00
Rhet Turnbull
a2dd648c89 Added download-missing option to CLI export 2020-01-12 08:46:57 -08:00
Rhet Turnbull
66cabf1af2 Added cloudasset/incloud options to CLI query 2020-01-12 07:42:30 -08:00
Rhet Turnbull
11bc008a91 Test library updates 2020-01-12 07:08:14 -08:00
Rhet Turnbull
1015ca34b6 Update README.md 2020-01-12 00:15:45 -08:00
Rhet Turnbull
af52d8710c Test library updates 2020-01-12 00:06:30 -08:00
Rhet Turnbull
58b76493ed Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-12 00:05:24 -08:00
Rhet Turnbull
edb31f796a Fixed search for edited photo in path_edited 2020-01-12 00:02:24 -08:00
Rhet Turnbull
e5747094e5 Update README.md 2020-01-11 11:11:41 -08:00
Rhet Turnbull
e089d135d3 Added incloud and iscloudasset for Photos 4 2020-01-11 11:09:20 -08:00
Rhet Turnbull
ff96448dee Updated README 2020-01-11 08:26:01 -08:00
Rhet Turnbull
78b5f1a19d Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-11 08:23:10 -08:00
Rhet Turnbull
f0712a7c06 version bump 2020-01-11 08:23:04 -08:00
Rhet Turnbull
53ac45af50 Removed python 3.8 2020-01-11 08:19:54 -08:00
Rhet Turnbull
9fd2e6d6d5 Added python 3.8 2020-01-11 08:16:07 -08:00
Rhet Turnbull
51a3adf169 Updated workflow to run on macos-latest 2020-01-11 08:14:17 -08:00
Rhet Turnbull
c5e1208c1c Updated pythonpackage.yml to run on Catalina 2020-01-11 08:13:05 -08:00
Rhet Turnbull
24b43b5e4d Added incloud and iscloudasset to PhotoInfo (Photos 5) 2020-01-11 08:10:28 -08:00
Rhet Turnbull
5473f3b3fd Added tests for live photos 2020-01-11 07:28:48 -08:00
Rhet Turnbull
9b5a1a64b0 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-11 06:37:53 -08:00
Rhet Turnbull
e39936c2dc Test library / database updates 2020-01-11 06:37:41 -08:00
Rhet Turnbull
2860ccf7d5 Update README.md 2020-01-10 20:50:59 -08:00
Rhet Turnbull
fcc0e1d083 Added text version of applescripts 2020-01-09 21:54:24 -08:00
Rhet Turnbull
0dac64409b Fixed bug in CLI export_photo -- live photo not exported if verbose wasn't set 2020-01-05 18:36:58 -08:00
Rhet Turnbull
f484737940 Initial code in place to determine selfies 2020-01-05 08:46:26 -08:00
Rhet Turnbull
eacd2ab12c Changed naming for exported live photos to improve re-import to Photos 2020-01-04 20:36:07 -08:00
Rhet Turnbull
1b7823e826 Updated CHANGELOG.md 2020-01-04 10:24:02 -08:00
Rhet Turnbull
6f6d37ceac Added live-photo option to CLI query and export 2020-01-04 10:15:56 -08:00
Rhet Turnbull
5099fd7715 Check that path exists in tests 2020-01-04 09:08:33 -08:00
Rhet Turnbull
d5eaff02f2 Added live photo support for both Photos 4 & 5 2020-01-04 09:07:23 -08:00
Rhet Turnbull
9fb05e4dd1 Added get_photo_info applescript for testing 2020-01-02 18:10:21 -08:00
Rhet Turnbull
1a89a18a01 Initial support for live photos (Photos 5 only) 2020-01-02 09:26:44 -08:00
Rhet Turnbull
996b8285cf Test database updates 2019-12-31 22:25:17 -08:00
Rhet Turnbull
00ecb7fea8 Added debug scripts 2019-12-31 21:32:08 -08:00
Rhet Turnbull
962052bc33 Updated CHANGELOG.md 2019-12-31 21:26:18 -08:00
Rhet Turnbull
2772bbff74 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-12-31 21:23:31 -08:00
Rhet Turnbull
593983a099 Added support for burst photos; added export-bursts to CLI 2019-12-31 21:18:16 -08:00
433 changed files with 4773 additions and 160 deletions

View File

@@ -5,7 +5,7 @@ on: [push]
jobs:
build:
runs-on: macOS-10.14
runs-on: macOS-latest
strategy:
max-parallel: 4
matrix:

View File

@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.21.0](https://github.com/RhetTbull/osxphotos/compare/v0.20.0...v0.21.0)
> 4 January 2020
- Added live photo support for both Photos 4 & 5 [`d5eaff0`](https://github.com/RhetTbull/osxphotos/commit/d5eaff02f2a29a9d105ab72e9a9aeffbc9a3425b)
- Added support for burst photos; added export-bursts to CLI [`593983a`](https://github.com/RhetTbull/osxphotos/commit/593983a09940e67fb9347bf345cfd7289465fa0a)
- Added live-photo option to CLI query and export [`6f6d37c`](https://github.com/RhetTbull/osxphotos/commit/6f6d37ceacf71a52a2c0216f0ad75afee244946a)
#### [v0.20.0](https://github.com/RhetTbull/osxphotos/compare/v0.19.0...v0.20.0)
> 1 January 2020
- Added support for filtering only movies or photos to CLI; added search for UTI to CLI [`9cd5363`](https://github.com/RhetTbull/osxphotos/commit/9cd5363a800dd85f333219788c661745b2ce88ad)
- Added support for bust photos; added export-bursts to CLI [`1136f84`](https://github.com/RhetTbull/osxphotos/commit/1136f84d9b5ea454115ba3d2720625722671e63b)
- Temporary fix to filter out unselected burst photos [`a550ba0`](https://github.com/RhetTbull/osxphotos/commit/a550ba00d6ff43a819cb18446e532f10ded81834)
#### [v0.19.0](https://github.com/RhetTbull/osxphotos/compare/v0.18.0...v0.19.0)
> 29 December 2019

View File

@@ -47,11 +47,15 @@
- [`shared`](#shared)
- [`isphoto`](#isphoto)
- [`ismovie`](#ismovie)
- [`iscloudasset`](#iscloudasset)
- [`incloud`](#incloud)
- [`uti`](#uti)
- [`burst`](#burst)
- [`burst_photos`](#burst_photos)
- [`live_photo`](#live_photo)
- [`path_live_photo`](#path_live_photo)
- [`json()`](#json)
- [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse)
- [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False, use_photos_export=False, timeout=120)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse-use_photos_exportfalse-timeout120)
+ [Utility Functions](#utility-functions)
- [```get_system_library_path()```](#get_system_library_path)
- [```get_last_library_path()```](#get_last_library_path)
@@ -64,7 +68,6 @@
* [Implementation Notes](#implementation-notes)
* [Dependencies](#dependencies)
* [Acknowledgements](#acknowledgements)
## What is osxphotos?
OSXPhotos provides the ability to interact with and query Apple's Photos.app library database on MacOS. Using this module you can query the Photos database for information about the photos stored in a Photos library on your Mac--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
@@ -117,14 +120,16 @@ Commands:
To get help on a specific command, use `osxphotos help <command_name>`
Example: `osxphotos help query`
Example: `osxphotos help export`
```
Usage: osxphotos help [OPTIONS]
Usage: osxphotos help [OPTIONS] DEST
Query the Photos database using 1 or more search options; if more than
one option is provided, they are treated as "AND" (e.g. search for photos
matching all options).
Export photos from the Photos database. Export path DEST is required.
Optionally, query the Photos database using 1 or more search options; if
more than one option is provided, they are treated as "AND" (e.g. search
for photos matching all options). If no query options are provided, all
photos will be exported.
Options:
--keyword TEXT Search for keyword(s).
@@ -135,6 +140,8 @@ Options:
--no-title Search for photos with no title.
--description TEXT Search for TEXT in description of photo.
--no-description Search for photos with no description.
--uti TEXT Search for photos whose uniform type identifier (UTI)
matches TEXT
-i, --ignore-case Case insensitive search for title or description. Does
not apply to keyword, person, or album.
--edited Search for photos that have been edited.
@@ -143,9 +150,49 @@ Options:
--not-favorite Search for photos not marked favorite.
--hidden Search for photos marked hidden.
--not-hidden Search for photos not marked hidden.
--missing Search for photos missing from disk.
--not-missing Search for photos present on disk (e.g. not missing).
--json Print output in JSON format
--burst Search for photos that were taken in a burst.
--not-burst Search for photos that are not part of a burst.
--live Search for Apple live photos
--not-live Search for photos that are not Apple live photos
--shared Search for photos in shared iCloud album (Photos 5
only).
--not-shared Search for photos not in shared iCloud album (Photos 5
only).
-V, --verbose Print verbose output.
--overwrite Overwrite existing files. Default behavior is to add
(1), (2), etc to filename if file already exists. Use
this with caution as it may create name collisions on
export. (e.g. if two files happen to have the same name)
--export-by-date Automatically create output folders to organize photos
by date created (e.g. DEST/2019/12/20/photoname.jpg).
--export-edited Also export edited version of photo if an edited version
exists. Edited photo will be named in form of
"photoname_edited.ext"
--export-bursts If a photo is a burst photo export all associated burst
images in the library.
--export-live If a photo is a live photo export the associated live
video component. Live video will have same name as
photo but with .mov extension.
--original-name Use photo's original filename instead of current
filename for export.
--sidecar Create json sidecar for each photo exported in format
useable by exiftool (https://exiftool.org/) The sidecar
file can be used to apply metadata to the file with
exiftool, for example: "exiftool -j=photoname.jpg.json
photoname.jpg" The sidecar file is named in format
photoname.ext.json where ext is extension of the photo
(e.g. jpg).
--only-movies Search only for movies (default searches both images and
movies).
--only-photos Search only for photos/images (default searches both
images and movies).
--download-missing Attempt to download missing photos from iCloud. The
current implementation uses Applescript to interact with
Photos to export the photo which will force Photos to
download from iCloud if the photo does not exist on
disk. This will be slow and will require internet
connection. This obviously only works if the Photos
library is synched to iCloud.
-h, --help Show this message and exit.
```
@@ -522,13 +569,15 @@ Returns a list of albums the photo is contained in
Returns a list of the names of the persons in the photo
#### `path`
Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see [hasadjustments](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#ismissing))
Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see [hasadjustments](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#ismissing)).
#### `path_edited`
Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also [path](#path) and [hasadjustments](#hasadjustments).
**Note**: will also return None if the edited photo is missing on disk.
#### `ismissing`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted. **Note**: this status is set by Photos and osxphotos does not verify that the file path returned by `path` actually exists. It merely reports what Photos has stored in the library database.
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted and user hasn't enabled "Copy items to the Photos library" in Photos preferences. **Note**: this status is computed based on data in the Photos library and `ismissing` does not verify if the photo is actually missing. See also [path](#path).
#### `hasadjustments`
Returns `True` if the picture has been edited, otherwise `False`
@@ -556,6 +605,14 @@ Returns True if type is photo/still image, otherwise False
#### `ismovie`
Returns True if type is movie/video, otherwise False
#### `iscloudasset`
Returns True if photo is a cloud asset, that is, it is in a library synched to iCloud. See also [incloud](#incloud)
#### `incloud`
Returns True if photo is a [cloud asset](#iscloudasset) and is synched to iCloud otherwise False if photo is a cloud asset and not yet synched to iCloud. Returns None if photo is not a cloud asset.
**Note**: Applies to master (original) photo only. It's possible for the master to be in iCloud but a local edited version is not yet synched to iCloud. `incloud` provides status of only the master photo. osxphotos does not yet provide a means to determine if the edited version is in iCloud. If you need this feature, please open an [issue](https://github.com/RhetTbull/osxphotos/issues).
#### `uti`
Returns Uniform Type Identifier (UTI) for the image, for example: 'public.jpeg' or 'com.apple.quicktime-movie'
@@ -586,10 +643,18 @@ IMG_9854.JPG
IMG_9855.JPG
```
#### `live_photo`
Returns True if photo is an Apple live photo (ie. it has an associated "live" video component), otherwise returns False. See [path_live_photo](#path_live_photo).
#### `path_live_photo`
Returns the path to the live video component of a [live photo](#live_photo). If photo is not a live photo, returns None.
**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud.
#### `json()`
Returns a JSON representation of all photo info
#### `export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`
#### `export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False, use_photos_export=False, timeout=120)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).
@@ -597,7 +662,9 @@ Export photo from the Photos library to another destination on disk.
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
- increment: boolean; if True (default=True), will increment file name until a non-existant name is found
- sidecar: boolean; if True (default=False) will also write a json sidecar file with EXIF data in format readable by [exiftool](https://exiftool.org/); filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg)
- sidecar: boolean; if True (default=False) will also write a json sidecar file with EXIF data in format readable by [exiftool](https://exiftool.org/); filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg). Note: this is not an XMP sidecar.
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using.
- timeout: (int, default=120) timeout in seconds used with use_photos_export
The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example:
@@ -727,5 +794,7 @@ Apple does provide a framework ([PhotoKit](https://developer.apple.com/documenta
- [Click](https://pypi.org/project/click/)
## Acknowledgements
This project was originally inspired by photo-export by Patrick Fältström see: (https://github.com/patrikhson/photo-export) Copyright (c) 2015 Patrik Fältström paf@frobbit.se
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this module, I included the entire module (which is published as public domain code) in a private module to prevent ambiguity with other applescript modules on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based modules.

View File

@@ -13,7 +13,7 @@ import osxphotos
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION
from ._version import __version__
from .utils import create_path_by_date
from .utils import create_path_by_date, _copy_file
# TODO: add "--any" to search any field (e.g. keyword, description, title contains "wedding") (add case insensitive option)
# TODO: add search for filename
@@ -256,8 +256,36 @@ def list_libraries(cli_obj):
is_flag=True,
help="Search for photos not in shared iCloud album (Photos 5 only).",
)
@click.option("--burst", is_flag=True, help="Search for photos that were taken in a burst.")
@click.option("--not-burst", is_flag=True, help="Search for photos that are not part of a burst.")
@click.option(
"--burst", is_flag=True, help="Search for photos that were taken in a burst."
)
@click.option(
"--not-burst", is_flag=True, help="Search for photos that are not part of a burst."
)
@click.option("--live", is_flag=True, help="Search for Apple live photos")
@click.option(
"--not-live", is_flag=True, help="Search for photos that are not Apple live photos"
)
@click.option(
"--cloudasset",
is_flag=True,
help="Search for photos that are part of an iCloud library",
)
@click.option(
"--not-cloudasset",
is_flag=True,
help="Search for photos that are not part of an iCloud library",
)
@click.option(
"--incloud",
is_flag=True,
help="Search for photos that are in iCloud (have been synched)",
)
@click.option(
"--not-incloud",
is_flag=True,
help="Search for photos that are not in iCloud (have not been synched)",
)
@click.option(
"--only-movies",
is_flag=True,
@@ -305,6 +333,12 @@ def query(
uti,
burst,
not_burst,
live,
not_live,
cloudasset,
not_cloudasset,
incloud,
not_incloud,
):
""" Query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND"
@@ -337,6 +371,12 @@ def query(
uti,
burst,
not_burst,
live,
not_live,
cloudasset,
not_cloudasset,
incloud,
not_incloud,
]
):
click.echo(cli.commands["query"].get_help(ctx))
@@ -369,9 +409,21 @@ def query(
# can't search for both burst and not_burst
click.echo(cli.commands["query"].get_help(ctx))
return
elif live and not_live:
# can't search for both live and not_live
click.echo(cli.commands["query"].get_help(ctx))
return
elif cloudasset and not_cloudasset:
# can't search for both live and not_live
click.echo(cli.commands["query"].get_help(ctx))
return
elif incloud and not_incloud:
# can't search for both live and not_live
click.echo(cli.commands["query"].get_help(ctx))
return
# actually have something to query
isphoto = ismovie = True # default searches for everything
isphoto = ismovie = True # default searches for everything
if only_movies:
isphoto = False
if only_photos:
@@ -403,7 +455,13 @@ def query(
ismovie,
uti,
burst,
not_burst
not_burst,
live,
not_live,
cloudasset,
not_cloudasset,
incloud,
not_incloud,
)
print_photo_info(photos, cli_obj.json or json)
@@ -448,8 +506,16 @@ def query(
)
@click.option("--hidden", is_flag=True, help="Search for photos marked hidden.")
@click.option("--not-hidden", is_flag=True, help="Search for photos not marked hidden.")
@click.option("--burst", is_flag=True, help="Search for photos that were taken in a burst.")
@click.option("--not-burst", is_flag=True, help="Search for photos that are not part of a burst.")
@click.option(
"--burst", is_flag=True, help="Search for photos that were taken in a burst."
)
@click.option(
"--not-burst", is_flag=True, help="Search for photos that are not part of a burst."
)
@click.option("--live", is_flag=True, help="Search for Apple live photos")
@click.option(
"--not-live", is_flag=True, help="Search for photos that are not Apple live photos"
)
@click.option(
"--shared",
is_flag=True,
@@ -460,7 +526,7 @@ def query(
is_flag=True,
help="Search for photos not in shared iCloud album (Photos 5 only).",
)
@click.option("--verbose","-V", is_flag=True, help="Print verbose output.")
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
@click.option(
"--overwrite",
is_flag=True,
@@ -478,13 +544,19 @@ def query(
@click.option(
"--export-edited",
is_flag=True,
help="Also export edited version of photo "
'if an edited version exists. Edited photo will be named in form of "photoname_edited.ext"',
help="Also export edited version of photo if an edited version exists. "
'Edited photo will be named in form of "photoname_edited.ext"',
)
@click.option(
"--export-bursts",
is_flag=True,
help="If a photo is a burst photo export all associated burst images in the library."
help="If a photo is a burst photo export all associated burst images in the library.",
)
@click.option(
"--export-live",
is_flag=True,
help="If a photo is a live photo export the associated live video component."
" Live video will have same name as photo but with .mov extension. ",
)
@click.option(
"--original-name",
@@ -494,11 +566,12 @@ def query(
@click.option(
"--sidecar",
is_flag=True,
help="Create json sidecar for each photo exported "
help="Create JSON sidecar for each photo exported "
f"in format useable by exiftool ({_EXIF_TOOL_URL}) "
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
'"exiftool -j=photoname.jpg.json photoname.jpg" '
"The sidecar file is named in format photoname.ext.json where ext is extension of the photo (e.g. jpg).",
"The sidecar file is named in format photoname.ext.json where ext is extension of the photo (e.g. jpg). "
"Note: this does not create an XMP sidecar as used by Lightroom, etc.",
)
@click.option(
"--only-movies",
@@ -510,6 +583,14 @@ def query(
is_flag=True,
help="Search only for photos/images (default searches both images and movies).",
)
@click.option(
"--download-missing",
is_flag=True,
help="Attempt to download missing photos from iCloud. The current implementation uses Applescript "
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
"the photo does not exist on disk. This will be slow and will require internet connection. "
"This obviously only works if the Photos library is synched to iCloud.",
)
@click.argument("dest", nargs=1)
@click.pass_obj
@click.pass_context
@@ -539,12 +620,16 @@ def export(
export_by_date,
export_edited,
export_bursts,
export_live,
original_name,
sidecar,
only_photos,
only_movies,
burst,
not_burst,
live,
not_live,
download_missing,
dest,
):
""" Export photos from the Photos database.
@@ -555,8 +640,6 @@ def export(
If no query options are provided, all photos will be exported.
"""
# TODO: --export-edited, --export-original
if not os.path.isdir(dest):
sys.exit("DEST must be valid path")
@@ -585,8 +668,12 @@ def export(
# can't search for both burst and not_burst
click.echo(cli.commands["export"].get_help(ctx))
return
elif live and not_live:
# can't search for both live and not_live
click.echo(cli.commands["export"].get_help(ctx))
return
isphoto = ismovie = True # default searches for everything
isphoto = ismovie = True # default searches for everything
if only_movies:
isphoto = False
if only_photos:
@@ -619,6 +706,12 @@ def export(
uti,
burst,
not_burst,
live,
not_live,
False, # cloudasset
False, # not_cloudasset
False, # incloud
False # not_incloud
)
if photos:
@@ -645,6 +738,8 @@ def export(
overwrite,
export_edited,
original_name,
export_live,
download_missing
)
else:
for p in photos:
@@ -657,6 +752,8 @@ def export(
overwrite,
export_edited,
original_name,
export_live,
download_missing
)
if export_path:
click.echo(f"Exported {p.filename} to {export_path}")
@@ -715,6 +812,10 @@ def print_photo_info(photos, json=False):
"ismovie",
"uti",
"burst",
"live_photo",
"path_live_photo",
"iscloudasset",
"incloud",
]
)
for p in photos:
@@ -743,6 +844,10 @@ def print_photo_info(photos, json=False):
p.ismovie,
p.uti,
p.burst,
p.live_photo,
p.path_live_photo,
p.iscloudasset,
p.incloud,
]
)
for row in dump:
@@ -776,12 +881,20 @@ def _query(
uti,
burst,
not_burst,
live,
not_live,
cloudasset,
not_cloudasset,
incloud,
not_incloud,
):
""" run a query against PhotosDB to extract the photos based on user supply criteria """
""" used by query and export commands """
""" arguments must be passed in same order as query and export """
""" if either is modified, need to ensure all three functions are updated """
# TODO: this is getting too hairy -- need to change to named args
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
photos = photosdb.photos(
keywords=keyword,
@@ -861,6 +974,21 @@ def _query(
elif not_burst:
photos = [p for p in photos if not p.burst]
if live:
photos = [p for p in photos if p.live_photo]
elif not_live:
photos = [p for p in photos if not p.live_photo]
if cloudasset:
photos = [p for p in photos if p.iscloudasset]
elif not_cloudasset:
photos = [p for p in photos if not p.iscloudasset]
if incloud:
photos = [p for p in photos if p.incloud]
elif not_incloud:
photos = [p for p in photos if not p.incloud]
return photos
@@ -873,6 +1001,8 @@ def export_photo(
overwrite,
export_edited,
original_name,
export_live,
download_missing,
):
""" Helper function for export that does the actual export
photo: PhotoInfo object
@@ -882,19 +1012,26 @@ def export_photo(
sidecar: boolean; create json sidecar file with export
overwrite: boolean; overwrite dest file if it already exists
original_name: boolean; use original filename instead of current filename
export_live: boolean; also export live video component if photo is a live photo
live video will have same name as photo but with .mov extension
download_missing: attempt download of missing iCloud photos
returns destination path of exported photo or None if photo was missing
"""
if photo.ismissing:
space = " " if not verbose else ""
click.echo(f"{space}Skipping missing photo {photo.filename}")
return None
elif not os.path.exists(photo.path):
space = " " if not verbose else ""
click.echo(
f"{space}WARNING: file {photo.path} is missing but ismissing=False, "
f"skipping {photo.filename}"
)
if not download_missing:
if photo.ismissing:
space = " " if not verbose else ""
click.echo(f"{space}Skipping missing photo {photo.filename}")
return None
elif not os.path.exists(photo.path):
space = " " if not verbose else ""
click.echo(
f"{space}WARNING: file {photo.path} is missing but ismissing=False, "
f"skipping {photo.filename}"
)
return None
elif photo.ismissing and not photo.iscloudasset or not photo.incloud:
click.echo(f"Skipping missing {photo.filename}: not iCloud asset or missing from cloud")
return None
filename = None
@@ -910,18 +1047,49 @@ def export_photo(
date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created)
photo_path = photo.export(dest, filename, sidecar=sidecar, overwrite=overwrite)
photo_path = photo.export(
dest,
filename,
sidecar=sidecar,
overwrite=overwrite,
use_photos_export=download_missing,
)
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments and photo.path_edited is not None:
edited_name = pathlib.Path(filename)
edited_name = f"{edited_name.stem}_edited{edited_name.suffix}"
if verbose:
click.echo(f"Exporting edited version of {filename} as {edited_name}")
photo.export(
dest, edited_name, sidecar=sidecar, overwrite=overwrite, edited=True
)
if export_edited and photo.hasadjustments:
if download_missing or photo.path_edited is not None:
edited_name = pathlib.Path(filename)
edited_name = f"{edited_name.stem}_edited{edited_name.suffix}"
if verbose:
click.echo(f"Exporting edited version of {filename} as {edited_name}")
photo.export(
dest,
edited_name,
sidecar=sidecar,
overwrite=overwrite,
edited=True,
use_photos_export=download_missing,
)
else:
click.echo(f"Skipping missing edited photo for {filename}")
if export_live and photo.live_photo and photo.path_live_photo is not None:
# if destination exists, will be overwritten regardless of overwrite
# so that name matches name of live photo
live_name = pathlib.Path(photo_path)
live_name = f"{live_name.stem}.mov"
src_live = photo.path_live_photo
dest_live = pathlib.Path(photo_path).parent / pathlib.Path(live_name)
if src_live is not None:
if verbose:
click.echo(f"Exporting live photo video of {filename} as {live_name}")
_copy_file(src_live, str(dest_live))
else:
click.echo(f"Skipping missing live movie for {filename}")
return photo_path

View 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

View 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'."""

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.20.00"
__version__ = "0.21.5"

View File

@@ -22,7 +22,12 @@ from ._constants import (
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
)
from .utils import _get_resource_loc, dd_to_dms_str
from .utils import (
_copy_file,
_get_resource_loc,
dd_to_dms_str,
_export_photo_uuid_applescript,
)
# TODO: check pylint output
@@ -114,7 +119,11 @@ class PhotoInfo:
def path_edited(self):
""" absolute path on disk of the edited picture """
""" None if photo has not been edited """
photopath = ""
# TODO: break this code into a _path_edited_4 and _path_edited_5
# version to simplify the big if/then; same for path_live_photo
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self._info["hasAdjustments"]:
@@ -136,6 +145,9 @@ class PhotoInfo:
logging.debug(f"WARNING: unknown type {self._info['type']}")
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(
library,
"resources",
@@ -145,15 +157,28 @@ class PhotoInfo:
"00",
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):
logging.warning(
f"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"
)
photopath = None
else:
logging.warning(
f"{self.uuid} hasAdjustments but edit_resource_id is None"
)
photopath = None
else:
photopath = None
@@ -230,7 +255,6 @@ class PhotoInfo:
@property
def title(self):
""" name / title of picture """
# TODO: Update documentation and tests to use title
return self._info["name"]
@property
@@ -308,6 +332,29 @@ class PhotoInfo:
"""
return True if self._info["type"] == _PHOTO_TYPE else False
@property
def incloud(self):
""" Returns True if photo is cloud asset and is synched to cloud
False if photo is cloud asset and not yet synched to cloud
None if photo is not cloud asset
"""
return self._info["incloud"]
@property
def iscloudasset(self):
""" Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""
if self._db._db_version < _PHOTOS_5_VERSION:
return (
True
if self._info["cloudLibraryState"] is not None
and self._info["cloudLibraryState"] != 0
else False
)
else:
return True if self._info["cloudAssetGUID"] is not None else False
@property
def burst(self):
""" Returns True if photo is part of a Burst photo set, otherwise False """
@@ -329,6 +376,66 @@ class PhotoInfo:
else:
return []
@property
def live_photo(self):
""" Returns True if photo is a live photo, otherwise False """
return self._info["live_photo"]
@property
def path_live_photo(self):
""" Returns path to the associated video file for a live photo
If photo is not a live photo, returns None
If photo is missing, returns None """
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self.live_photo and not self.ismissing:
live_model_id = self._info["live_model_id"]
if live_model_id == None:
logging.debug(f"missing live_model_id: {self._uuid}")
photopath = None
else:
folder_id, file_id = _get_resource_loc(live_model_id)
library_path = self._db.library_path
photopath = os.path.join(
library_path,
"resources",
"media",
"master",
folder_id,
"00",
f"jpegvideocomplement_{file_id}.mov",
)
if not os.path.isfile(photopath):
# In testing, I've seen occasional missing movie for live photo
# These appear to be valid -- e.g. live component hasn't been downloaded from iCloud
# photos 4 has "isOnDisk" column we could check
# or could do the actual check with "isfile"
# TODO: should this be a warning or debug?
logging.debug(
f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
else:
# Photos 5
if self.live_photo and not self.ismissing:
filename = pathlib.Path(self.path)
photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
if not os.path.isfile(photopath):
# In testing, I've seen occasional missing movie for live photo
# these appear to be valid -- e.g. video component not yet downloaded from iCloud
# TODO: should this be a warning or debug?
logging.debug(
f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
return photopath
def export(
self,
dest,
@@ -337,17 +444,22 @@ class PhotoInfo:
overwrite=False,
increment=True,
sidecar=False,
use_photos_export=False,
timeout=120,
):
""" export photo """
""" first argument must be valid destination path (or exception raised) """
""" second argument (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) """
""" if overwrite=True (default=False), will overwrite files if they alreay exist """
""" if increment=True (default=True), will increment file name until a non-existant name is found """
""" if overwrite=False and increment=False, export will fail if destination file already exists """
""" if sidecar=True, will also write a json sidecar with EXIF data in format readable by exiftool """
""" sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) """
""" returns the full path to the exported file """
""" export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of picture; if not provided, will use current filename
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar: (boolean, default = False); if True will also write a json sidecar with EXIF data in format readable by exiftool
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:
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
@@ -386,37 +498,7 @@ class PhotoInfo:
else:
filename = self.filename
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if not self.hasadjustments:
logging.warning(
"Attempting to export edited photo but hasadjustments=False"
)
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is None:
logging.warning(
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
)
raise FileNotFoundError("Cannot export photo if path is None")
else:
src = self.path
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
# check destination path
dest = pathlib.Path(dest)
filename = pathlib.Path(filename)
dest = dest / filename
@@ -430,26 +512,67 @@ class PhotoInfo:
count += 1
dest = dest_new
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, incremetn={increment}, dest exists: {dest.exists()}"
)
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
# if error on copy, subprocess will raise CalledProcessError
try:
subprocess.run(
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
if not use_photos_export:
# find the source file on disk and export
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if not self.hasadjustments:
logging.warning(
"Attempting to export edited photo but hasadjustments=False"
)
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is None:
logging.warning(
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
)
raise FileNotFoundError("Cannot export photo if path is None")
else:
src = self.path
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
)
except subprocess.CalledProcessError as e:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
raise e
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
_copy_file(src, dest)
else:
# use_photo_export
exported = None
if edited:
# exported edited version and not original
exported = _export_photo_uuid_applescript(
self.uuid, dest, original=False, edited=True, timeout=timeout
)
else:
# export original version and not edited
exported = _export_photo_uuid_applescript(
self.uuid, dest, original=True, edited=False, timeout=timeout
)
if exported is None:
logging.warning(f"Error exporting photo {photo.uuid} to {dest}")
if sidecar:
logging.debug("writing exiftool_json_sidecar")
@@ -561,6 +684,10 @@ class PhotoInfo:
"ismovie": self.ismovie,
"uti": self.uti,
"burst": self.burst,
"live_photo": self.live_photo,
"path_live_photo": self.path_live_photo,
"iscloudasset": self.iscloudasset,
"incloud": self.incloud,
}
return yaml.dump(info, sort_keys=False)
@@ -590,6 +717,10 @@ class PhotoInfo:
"ismovie": self.ismovie,
"uti": self.uti,
"burst": self.burst,
"live_photo": self.live_photo,
"path_live_photo": self.path_live_photo,
"iscloudasset": self.iscloudasset,
"incloud": self.incloud,
}
return json.dumps(pic)

View File

@@ -484,11 +484,14 @@ class PhotosDB:
RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden,
RKVersion.latitude, RKVersion.longitude,
RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI,
RKVersion.burstUuid, RKVersion.burstPickType
from RKVersion, RKMaster where RKVersion.isInTrash = 0 and
RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf' """
RKVersion.burstUuid, RKVersion.burstPickType,
RKVersion.specialType, RKMaster.modelID
FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND
RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """
)
# TODO: RKVersion.selfPortrait -- only in Photos 3 and up
# order of results
# 0 RKVersion.uuid
# 1 RKVersion.modelId
@@ -515,6 +518,10 @@ class PhotosDB:
# 22 RKMaster.UTI
# 23 RKVersion.burstUuid
# 24 RKVersion.burstPickType
# 25 RKVersion.specialType
# 26 RKMaster.modelID
# 27 RKVersion.selfPortrait -- 1 if selfie (not yet implemented)
for row in c:
uuid = row[0]
@@ -579,26 +586,68 @@ class PhotosDB:
self._dbphotos[uuid]["burst"] = True
burst_uuid = row[23]
if burst_uuid not in self._dbphotos_burst:
self._dbphotos_burst[burst_uuid] = set()
self._dbphotos_burst[burst_uuid] = set()
self._dbphotos_burst[burst_uuid].add(uuid)
if row[24] != 2 and row[24] != 4:
self._dbphotos[uuid]["burst_key"] = True # it's a key photo (selected from the burst)
self._dbphotos[uuid][
"burst_key"
] = True # it's a key photo (selected from the burst)
else:
self._dbphotos[uuid]["burst_key"] = False # it's a burst photo but not one that's selected
self._dbphotos[uuid][
"burst_key"
] = False # it's a burst photo but not one that's selected
else:
# not a burst photo
self._dbphotos[uuid]["burst"] = False
self._dbphotos[uuid]["burst_key"] = None
# get details needed to find path of the edited photos and live photos
# RKVersion.specialType
# 1 == panorama
# 2 == slow-mo movie
# 3 == time-lapse movie
# 4 == HDR
# 5 == live photo
# 6 == screenshot
# 8 == HDR live photo
# 9 = portrait
# get info on special types
self._dbphotos[uuid]["specialType"] = row[25]
self._dbphotos[uuid]["masterModelID"] = row[26]
self._dbphotos[uuid]["panorama"] = True if row[25] == 1 else False
self._dbphotos[uuid]["slow_mo"] = True if row[25] == 2 else False
self._dbphotos[uuid]["time_lapse"] = True if row[25] == 3 else False
self._dbphotos[uuid]["hdr"] = (
True if (row[25] == 4 or row[25] == 8) else False
)
self._dbphotos[uuid]["live_photo"] = (
True if (row[25] == 5 or row[25] == 8) else False
)
self._dbphotos[uuid]["screenshot"] = True if row[25] == 6 else False
self._dbphotos[uuid]["portrait"] = True if row[25] == 9 else False
# TODO: Handle selfies (front facing camera, RKVersion.selfPortrait == 1)
# self._dbphotos[uuid]["selfie"] = True if row[27] == 1 else False
self._dbphotos[uuid]["selfie"] = None
# Init cloud details that will be filled in later if cloud asset
self._dbphotos[uuid]["cloudAssetGUID"] = None # Photos 5
self._dbphotos[uuid]["cloudLocalState"] = None # Photos 5
self._dbphotos[uuid]["cloudLibraryState"] = None
self._dbphotos[uuid]["cloudStatus"] = None
self._dbphotos[uuid]["cloudAvailable"] = None
self._dbphotos[uuid]["incloud"] = None
# get details needed to find path of the edited photos
c.execute(
"SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId, "
"RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType, "
"RKModelResource.attachedModelType, RKModelResource.resourceType "
"FROM RKVersion "
"JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId "
"WHERE RKVersion.isInTrash = 0 "
""" SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId,
RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType,
RKModelResource.attachedModelType, RKModelResource.resourceType
FROM RKVersion
JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId
WHERE RKVersion.isInTrash = 0 """
)
# get info on path of live photo movie
# Order of results:
# 0 RKVersion.uuid
@@ -610,15 +659,10 @@ class PhotosDB:
# 6 RKModelResource.attachedModelType
# 7 RKModelResource.resourceType
# TODO: add live photos
# attachedmodeltype is 2, it's a photo, could be more than one
# attachedmodeltype == 2 could also be movie?
# if 5, it's a facetile
# specialtype = 0 == image, 5 or 8 == live photo movie
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
# get info on adjustments (edits)
if self._dbphotos[uuid]["adjustmentUuid"] == row[3]:
if (
row[1] != "UNADJUSTEDNONRAW"
@@ -654,11 +698,78 @@ class PhotosDB:
if uuid in self._dbphotos:
self._dbphotos[uuid]["adjustmentFormatID"] = row[3]
# init any uuids that had no edits
# get details to find path of live photos
c.execute(
""" SELECT
RKVersion.uuid,
RKModelResource.modelId,
RKModelResource.UTI,
RKVersion.specialType,
RKModelResource.attachedModelType,
RKModelResource.resourceType,
RKModelResource.isOnDisk
FROM RKVersion
INNER JOIN RKMaster on RKVersion.masterUuid = RKMaster.uuid
INNER JOIN RKModelResource on RKMaster.modelId = RKModelResource.attachedModelId
WHERE RKModelResource.UTI = 'com.apple.quicktime-movie'
AND RKMaster.isInTrash = 0
AND RKVersion.isInTrash = 0
"""
)
# Order of results
# 0 RKVersion.uuid,
# 1 RKModelResource.modelId,
# 2 RKModelResource.UTI,
# 3 RKVersion.specialType,
# 4 RKModelResource.attachedModelType,
# 5 RKModelResource.resourceType
# 6 RKModelResource.isOnDisk
# TODO: don't think we need most of these fields, remove from SQL query?
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
self._dbphotos[uuid]["live_model_id"] = row[1]
self._dbphotos[uuid]["modeResourceIsOnDisk"] = (
True if row[6] == 1 else False
)
# init any uuids that had no edits or live photos
for uuid in self._dbphotos:
if "edit_resource_id" not in self._dbphotos[uuid]:
self._dbphotos[uuid]["edit_resource_id"] = None
if "live_model_id" not in self._dbphotos[uuid]:
self._dbphotos[uuid]["live_model_id"] = None
self._dbphotos[uuid]["modeResourceIsOnDisk"] = None
# get cloud details
c.execute(
""" SELECT
RKVersion.uuid,
RKMaster.cloudLibraryState,
RKCloudResource.available,
RKCloudResource.status
FROM RKCloudResource
INNER JOIN RKMaster ON RKMaster.fingerprint = RKCloudResource.fingerprint
INNER JOIN RKVersion ON RKVersion.masterUuid = RKMaster.uuid """
)
# Order of results
# 0 RKMaster.uuid,
# 1 RKMaster.cloudLibraryState,
# 2 RKCloudResource.available,
# 3 RKCloudResource.status
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
self._dbphotos[uuid]["cloudLibraryState"] = row[1]
self._dbphotos[uuid]["cloudAvailable"] = row[2]
self._dbphotos[uuid]["cloudStatus"] = row[3]
self._dbphotos[uuid]["incloud"] = True if row[2] == 1 else False
# done with the database connection
conn.close()
# add faces and keywords to photo data
@@ -851,7 +962,11 @@ class PhotosDB:
ZGENERICASSET.ZKIND,
ZGENERICASSET.ZUNIFORMTYPEIDENTIFIER,
ZGENERICASSET.ZAVALANCHEUUID,
ZGENERICASSET.ZAVALANCHEPICKTYPE
ZGENERICASSET.ZAVALANCHEPICKTYPE,
ZGENERICASSET.ZKINDSUBTYPE,
ZGENERICASSET.ZCUSTOMRENDEREDVALUE,
ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
ZGENERICASSET.ZCLOUDASSETGUID
FROM ZGENERICASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
WHERE ZGENERICASSET.ZTRASHEDSTATE = 0
@@ -879,6 +994,11 @@ class PhotosDB:
# 18 ZUNIFORMTYPEIDENTIFIER -- UTI
# 19 ZGENERICASSET.ZAVALANCHEUUID, -- if not NULL, is burst photo
# 20 ZGENERICASSET.ZAVALANCHEPICKTYPE -- if not 2, is a selected burst photo
# 21 ZGENERICASSET.ZKINDSUBTYPE -- determine if live photos, etc
# 22 ZGENERICASSET.ZCUSTOMRENDEREDVALUE -- determine if HDR photo
# 23 ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE -- 1 if selfie (front facing camera)
# 25 ZGENERICASSET.ZCLOUDASSETGUID -- not null if asset is cloud asset
# (e.g. user has "iCloud Photos" checked in Photos preferences)
for row in c:
uuid = row[0]
@@ -939,24 +1059,64 @@ class PhotosDB:
# handle burst photos
# if burst photo, determine whether or not it's a selected burst photo
# in Photos 5, burstUUID is called avalancheUUID
info["burstUUID"] = row[19] # avalancheUUID
info["burstPickType"] = row[20] #avalanchePickType
info["burstUUID"] = row[19] # avalancheUUID
info["burstPickType"] = row[20] # avalanchePickType
if row[19] is not None:
# it's a burst photo
info["burst"] = True
burst_uuid = row[19]
if burst_uuid not in self._dbphotos_burst:
self._dbphotos_burst[burst_uuid] = set()
self._dbphotos_burst[burst_uuid] = set()
self._dbphotos_burst[burst_uuid].add(uuid)
if row[20] != 2 and row[20] != 4:
info["burst_key"] = True # it's a key photo (selected from the burst)
info[
"burst_key"
] = True # it's a key photo (selected from the burst)
else:
info["burst_key"] = False # it's a burst photo but not one that's selected
info[
"burst_key"
] = False # it's a burst photo but not one that's selected
else:
# not a burst photo
info["burst"] = False
info["burst_key"] = None
# Info on sub-type (live photo, panorama, etc)
# ZGENERICASSET.ZKINDSUBTYPE
# 1 == panorama
# 2 == live photo
# 10 = screenshot
# 100 = shared movie (MP4) ??
# 101 = slow-motion video
# 102 = Time lapse video
info["subtype"] = row[21]
info["panorama"] = True if row[21] == 1 else False
info["live_photo"] = True if row[21] == 2 else False
info["screenshot"] = True if row[21] == 10 else False
info["slow_mo"] = True if row[21] == 101 else False
info["time_lapse"] = True if row[21] == 102 else False
# Handle HDR photos and portraits
# ZGENERICASSET.ZCUSTOMRENDEREDVALUE
# 3 = HDR photo
# 4 = non-HDR version of the photo
# 8 = portrait
info["customRenderedValue"] = row[22]
info["hdr"] = True if row[22] == 3 else False
info["portrait"] = True if row[22] == 8 else False
# Handle selfies (front facing camera, ZCAMERACAPTUREDEVICE=1)
info["selfie"] = True if row[23] == 1 else False
# Determine if photo is part of cloud library (ZGENERICASSET.ZCLOUDASSETGUID not NULL)
# Initialize cloud fields that will filled in later
info["cloudAssetGUID"] = row[24]
info["cloudLocalState"] = None
info["incloud"] = None
info["cloudLibraryState"] = None # Photos 4
info["cloudStatus"] = None # Photos 4
info["cloudAvailable"] = None # Photos 4
self._dbphotos[uuid] = info
# # if row[19] is not None and ((row[20] == 2) or (row[20] == 4)):
@@ -1017,6 +1177,7 @@ class PhotosDB:
# Get info on remote/local availability for photos in shared albums
# Shared photos have a null fingerprint (and some other photos do too)
# TODO: There may be a bug here, perhaps ZDATASTORESUBTYPE should be 1 --> it's the longest ZDATALENGTH (is this the original)
c.execute(
""" SELECT
ZGENERICASSET.ZUUID,
@@ -1026,6 +1187,8 @@ class PhotosDB:
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZINTERNALRESOURCE.ZFINGERPRINT IS NULL AND ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 3 """
)
@@ -1076,8 +1239,19 @@ class PhotosDB:
# f"{uuid} isMissing changed: {old} {self._dbphotos[uuid]['isMissing']}"
# )
if _debug():
logging.debug(pformat(self._dbphotos))
# get information about cloud sync state
c.execute(
""" SELECT
ZGENERICASSET.ZUUID,
ZCLOUDMASTER.ZCLOUDLOCALSTATE
FROM ZCLOUDMASTER, ZGENERICASSET
WHERE ZGENERICASSET.ZMASTER = ZCLOUDMASTER.Z_PK """
)
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
self._dbphotos[uuid]["cloudLocalState"] = row[1]
self._dbphotos[uuid]["incloud"] = True if row[1] == 3 else False
# add faces and keywords to photo data
for uuid in self._dbphotos:
@@ -1107,6 +1281,7 @@ class PhotosDB:
conn.close()
self._cleanup_tmp_files()
# done processing, dump debug data if requested
if _debug():
logging.debug("Faces:")
logging.debug(pformat(self._dbfaces_uuid))
@@ -1135,7 +1310,6 @@ class PhotosDB:
logging.debug("Burst Photos:")
logging.debug(pformat(self._dbphotos_burst))
# TODO: fix default values to None instead of []
def photos(
self,
keywords=None,

View File

@@ -3,6 +3,7 @@ import logging
import os.path
import platform
import subprocess
import tempfile
import urllib.parse
from pathlib import Path
from plistlib import load as plistload
@@ -11,6 +12,8 @@ import CoreFoundation
import objc
from Foundation import *
from osxphotos._applescript import AppleScript
_DEBUG = False
@@ -109,6 +112,31 @@ def _dd_to_dms(dd):
return int(deg_), int(min_), sec_
def _copy_file(src, dest):
""" Copies a file from src path to dest path
src: source path as string
dest: destination path as string
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)
if not os.path.isfile(src):
raise ValueError("src file does not appear to exist", src)
# if error on copy, subprocess will raise CalledProcessError
try:
subprocess.run(
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
)
except subprocess.CalledProcessError as e:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
raise e
def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
""" lat: latitude in degrees """
@@ -259,3 +287,74 @@ def create_path_by_date(dest, dt):
if not os.path.isdir(new_dest):
os.makedirs(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
Returns: path to exported file or None if export failed
"""
# 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:
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
# this assumes only a single file in export folder, which should be true as
# TemporaryDirectory will cleanup on return
path = glob.glob(os.path.join(tmpdir.name, "*"))[0]
_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

View File

@@ -17,5 +17,6 @@ Images used from:
- [Carlos Montesdeoca](https://www.flickr.com/photos/carlosmontesdeocastudio)
- [Rydale Clothing](https://www.flickr.com/photos/rydaleclothing)
- [Marco Verch](https://www.flickr.com/photos/30478819@N08/48228222317/)
- [K M](https://www.flickr.com/photos/153387643@N08/49334338022/)

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-12-30T14:19:49Z</date>
<date>2020-01-11T03:40:24Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-12-30T19:26:32Z</date>
<date>2020-01-12T02:07:27Z</date>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-12-28T22:33:47Z</date>
<date>2020-01-11T03:42:50Z</date>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

View File

@@ -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>

Some files were not shown because too many files have changed in this diff Show More