Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146d54197f | ||
|
|
f3674ef58b | ||
|
|
a2dd648c89 | ||
|
|
66cabf1af2 | ||
|
|
11bc008a91 | ||
|
|
1015ca34b6 | ||
|
|
af52d8710c | ||
|
|
58b76493ed | ||
|
|
edb31f796a | ||
|
|
e5747094e5 | ||
|
|
e089d135d3 | ||
|
|
ff96448dee | ||
|
|
78b5f1a19d | ||
|
|
f0712a7c06 | ||
|
|
53ac45af50 | ||
|
|
9fd2e6d6d5 | ||
|
|
51a3adf169 | ||
|
|
c5e1208c1c | ||
|
|
24b43b5e4d | ||
|
|
5473f3b3fd | ||
|
|
9b5a1a64b0 | ||
|
|
e39936c2dc | ||
|
|
2860ccf7d5 | ||
|
|
fcc0e1d083 | ||
|
|
0dac64409b | ||
|
|
f484737940 | ||
|
|
eacd2ab12c | ||
|
|
1b7823e826 | ||
|
|
6f6d37ceac | ||
|
|
5099fd7715 | ||
|
|
d5eaff02f2 | ||
|
|
9fb05e4dd1 | ||
|
|
1a89a18a01 | ||
|
|
996b8285cf | ||
|
|
00ecb7fea8 | ||
|
|
962052bc33 | ||
|
|
2772bbff74 | ||
|
|
593983a099 |
2
.github/workflows/pythonpackage.yml
vendored
@@ -5,7 +5,7 @@ on: [push]
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: macOS-10.14
|
||||
runs-on: macOS-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
|
||||
16
CHANGELOG.md
@@ -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
|
||||
|
||||
99
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
162
osxphotos/_applescript/__init__.py
Normal file
@@ -0,0 +1,162 @@
|
||||
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
|
||||
|
||||
import sys
|
||||
|
||||
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
|
||||
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
|
||||
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
|
||||
|
||||
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
|
||||
from . import kae
|
||||
|
||||
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
class AppleScript:
|
||||
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
_codecs = Codecs()
|
||||
|
||||
def __init__(self, source=None, path=None):
|
||||
"""
|
||||
source : str | None -- AppleScript source code
|
||||
path : str | None -- full path to .scpt/.applescript file
|
||||
|
||||
Notes:
|
||||
|
||||
- Either the path or the source argument must be provided.
|
||||
|
||||
- If the script cannot be read/compiled, a ScriptError is raised.
|
||||
"""
|
||||
if path:
|
||||
url = NSURL.fileURLWithPath_(path)
|
||||
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
elif source:
|
||||
self._script = NSAppleScript.alloc().initWithSource_(source)
|
||||
else:
|
||||
raise ValueError("Missing source or path argument.")
|
||||
if not self._script.isCompiled():
|
||||
errorinfo = self._script.compileAndReturnError_(None)[1]
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
s = self.source
|
||||
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
|
||||
|
||||
##
|
||||
|
||||
def _newevent(self, suite, code, args):
|
||||
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
|
||||
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
|
||||
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
|
||||
return evt
|
||||
|
||||
def _unpackresult(self, result, errorinfo):
|
||||
if not result:
|
||||
raise ScriptError(errorinfo)
|
||||
return self._codecs.unpack(result)
|
||||
|
||||
##
|
||||
|
||||
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
|
||||
|
||||
def run(self, *args):
|
||||
""" Run the script, optionally passing arguments to its run handler.
|
||||
|
||||
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The run handler must be explicitly declared in order to pass arguments.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
if args:
|
||||
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
else:
|
||||
return self._unpackresult(*self._script.executeAndReturnError_(None))
|
||||
|
||||
def call(self, name, *args):
|
||||
""" Call the specified user-defined handler.
|
||||
|
||||
name : str -- the handler's name (case-sensitive)
|
||||
args : anything -- arguments to pass to script, if any; see documentation for supported types
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
|
||||
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
|
||||
fourcharcode(kae.keyASSubroutineName))
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class ScriptError(Exception):
|
||||
""" Indicates an AppleScript compilation/execution error. """
|
||||
|
||||
def __init__(self, errorinfo):
|
||||
self._errorinfo = dict(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
return 'ScriptError({})'.format(self._errorinfo)
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
""" str -- the error message """
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
|
||||
if not msg:
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
|
||||
return msg
|
||||
|
||||
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
|
||||
doc="int | None -- the error number, if given")
|
||||
|
||||
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
|
||||
doc="str | None -- the name of the application that reported the error, where relevant")
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
|
||||
range = self._errorinfo.get(NSAppleScriptErrorRange)
|
||||
if range:
|
||||
start = range.rangeValue().location
|
||||
end = start + range.rangeValue().length
|
||||
return (start, end)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
msg = self.message
|
||||
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
|
||||
if v is not None:
|
||||
msg += s.format(v)
|
||||
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
kMissingValue = AEType(kae.cMissingValue) # convenience constant
|
||||
|
||||
269
osxphotos/_applescript/aecodecs.py
Normal file
@@ -0,0 +1,269 @@
|
||||
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
|
||||
|
||||
import datetime, struct, sys
|
||||
|
||||
from Foundation import NSAppleEventDescriptor, NSURL
|
||||
|
||||
from . import kae
|
||||
|
||||
|
||||
__all__ = ['Codecs', 'AEType', 'AEEnum']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
def fourcharcode(code):
|
||||
""" Convert four-char code for use in NSAppleEventDescriptor methods.
|
||||
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
Result : int -- OSType, e.g. 1970567284
|
||||
"""
|
||||
return struct.unpack('>I', code)[0]
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class Codecs:
|
||||
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
|
||||
|
||||
kMacEpoch = datetime.datetime(1904, 1, 1)
|
||||
kUSRF = fourcharcode(kae.keyASUserRecordFields)
|
||||
|
||||
def __init__(self):
|
||||
# Clients may add/remove/replace encoder and decoder items:
|
||||
self.encoders = {
|
||||
NSAppleEventDescriptor.class__(): self.packdesc,
|
||||
type(None): self.packnone,
|
||||
bool: self.packbool,
|
||||
int: self.packint,
|
||||
float: self.packfloat,
|
||||
bytes: self.packbytes,
|
||||
str: self.packstr,
|
||||
list: self.packlist,
|
||||
tuple: self.packlist,
|
||||
dict: self.packdict,
|
||||
datetime.datetime: self.packdatetime,
|
||||
AEType: self.packtype,
|
||||
AEEnum: self.packenum,
|
||||
}
|
||||
if sys.version_info.major < 3: # 2.7 compatibility
|
||||
self.encoders[unicode] = self.packstr
|
||||
|
||||
self.decoders = {fourcharcode(k): v for k, v in {
|
||||
kae.typeNull: self.unpacknull,
|
||||
kae.typeBoolean: self.unpackboolean,
|
||||
kae.typeFalse: self.unpackboolean,
|
||||
kae.typeTrue: self.unpackboolean,
|
||||
kae.typeSInt32: self.unpacksint32,
|
||||
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
|
||||
kae.typeUTF8Text: self.unpackunicodetext,
|
||||
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
|
||||
kae.typeUnicodeText: self.unpackunicodetext,
|
||||
kae.typeLongDateTime: self.unpacklongdatetime,
|
||||
kae.typeAEList: self.unpackaelist,
|
||||
kae.typeAERecord: self.unpackaerecord,
|
||||
kae.typeAlias: self.unpackfile,
|
||||
kae.typeFSS: self.unpackfile,
|
||||
kae.typeFSRef: self.unpackfile,
|
||||
kae.typeFileURL: self.unpackfile,
|
||||
kae.typeType: self.unpacktype,
|
||||
kae.typeEnumeration: self.unpackenumeration,
|
||||
}.items()}
|
||||
|
||||
def pack(self, data):
|
||||
"""Pack Python data.
|
||||
data : anything -- a Python value
|
||||
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
|
||||
"""
|
||||
try:
|
||||
return self.encoders[data.__class__](data) # quick lookup by type/class
|
||||
except (KeyError, AttributeError) as e:
|
||||
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
|
||||
if isinstance(data, type):
|
||||
return encoder(data)
|
||||
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
|
||||
|
||||
def unpack(self, desc):
|
||||
"""Unpack an Apple event descriptor.
|
||||
desc : NSAppleEventDescriptor
|
||||
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
|
||||
"""
|
||||
decoder = self.decoders.get(desc.descriptorType())
|
||||
# unpack known type
|
||||
if decoder:
|
||||
return decoder(desc)
|
||||
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
|
||||
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
|
||||
if rec:
|
||||
rec = self.unpackaerecord(rec)
|
||||
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
|
||||
return rec
|
||||
# return as-is
|
||||
return desc
|
||||
|
||||
##
|
||||
|
||||
def _packbytes(self, desctype, data):
|
||||
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
|
||||
fourcharcode(desctype), data, len(data))
|
||||
|
||||
def packdesc(self, val):
|
||||
return val
|
||||
|
||||
def packnone(self, val):
|
||||
return NSAppleEventDescriptor.nullDescriptor()
|
||||
|
||||
def packbool(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
|
||||
|
||||
def packint(self, val):
|
||||
if (-2**31) <= val < (2**31):
|
||||
return NSAppleEventDescriptor.descriptorWithInt32_(val)
|
||||
else:
|
||||
return self.pack(float(val))
|
||||
|
||||
def packfloat(self, val):
|
||||
return self._packbytes(kae.typeFloat, struct.pack('d', val))
|
||||
|
||||
def packbytes(self, val):
|
||||
return self._packbytes(kae.typeData, val)
|
||||
|
||||
def packstr(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithString_(val)
|
||||
|
||||
def packdatetime(self, val):
|
||||
delta = val - self.kMacEpoch
|
||||
sec = delta.days * 3600 * 24 + delta.seconds
|
||||
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
|
||||
|
||||
def packlist(self, val):
|
||||
lst = NSAppleEventDescriptor.listDescriptor()
|
||||
for item in val:
|
||||
lst.insertDescriptor_atIndex_(self.pack(item), 0)
|
||||
return lst
|
||||
|
||||
def packdict(self, val):
|
||||
record = NSAppleEventDescriptor.recordDescriptor()
|
||||
usrf = desctype = None
|
||||
for key, value in val.items():
|
||||
if isinstance(key, AEType):
|
||||
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
|
||||
desctype = value
|
||||
else:
|
||||
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
|
||||
else:
|
||||
if not usrf:
|
||||
usrf = NSAppleEventDescriptor.listDescriptor()
|
||||
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
|
||||
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
|
||||
if usrf:
|
||||
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
|
||||
if desctype:
|
||||
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
|
||||
if newrecord:
|
||||
record = newrecord
|
||||
else: # coercion failed for some reason, so pack as normal key-value pair
|
||||
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
|
||||
return record
|
||||
|
||||
def packtype(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
|
||||
|
||||
def packenum(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
|
||||
|
||||
#######
|
||||
|
||||
def unpacknull(self, desc):
|
||||
return None
|
||||
|
||||
def unpackboolean(self, desc):
|
||||
return desc.booleanValue()
|
||||
|
||||
def unpacksint32(self, desc):
|
||||
return desc.int32Value()
|
||||
|
||||
def unpackfloat64(self, desc):
|
||||
return struct.unpack('d', bytes(desc.data()))[0]
|
||||
|
||||
def unpackunicodetext(self, desc):
|
||||
return desc.stringValue()
|
||||
|
||||
def unpacklongdatetime(self, desc):
|
||||
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
|
||||
|
||||
def unpackaelist(self, desc):
|
||||
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
|
||||
|
||||
def unpackaerecord(self, desc):
|
||||
dct = {}
|
||||
for i in range(desc.numberOfItems()):
|
||||
key = desc.keywordForDescriptorAtIndex_(i + 1)
|
||||
value = desc.descriptorForKeyword_(key)
|
||||
if key == self.kUSRF:
|
||||
lst = self.unpackaelist(value)
|
||||
for i in range(0, len(lst), 2):
|
||||
dct[lst[i]] = lst[i+1]
|
||||
else:
|
||||
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
|
||||
return dct
|
||||
|
||||
def unpacktype(self, desc):
|
||||
return AEType(struct.pack('>I', desc.typeCodeValue()))
|
||||
|
||||
def unpackenumeration(self, desc):
|
||||
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
|
||||
|
||||
def unpackfile(self, desc):
|
||||
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
|
||||
return NSURL.URLWithString_(url).path()
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class AETypeBase:
|
||||
""" Base class for AEType and AEEnum.
|
||||
|
||||
Notes:
|
||||
|
||||
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
|
||||
"""
|
||||
|
||||
def __init__(self, code):
|
||||
"""
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
"""
|
||||
if not isinstance(code, bytes):
|
||||
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
|
||||
elif len(code) != 4:
|
||||
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
|
||||
self._code = code
|
||||
|
||||
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._code)
|
||||
|
||||
def __eq__(self, val):
|
||||
return val.__class__ == self.__class__ and val.code == self._code
|
||||
|
||||
def __ne__(self, val):
|
||||
return not self == val
|
||||
|
||||
def __repr__(self):
|
||||
return "{}({!r})".format(self.__class__.__name__, self._code)
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class AEType(AETypeBase):
|
||||
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
|
||||
|
||||
|
||||
class AEEnum(AETypeBase):
|
||||
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""
|
||||
|
||||
1720
osxphotos/_applescript/kae.py
Normal file
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.20.00"
|
||||
__version__ = "0.21.5"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 545 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 453 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DatabaseMinorVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>DatabaseVersion</key>
|
||||
<integer>112</integer>
|
||||
<key>LastOpenMode</key>
|
||||
<integer>2</integer>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>2</integer>
|
||||
<key>createDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.14.6-path_edited.photoslibrary/database/photos.db
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Photos</key>
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
</array>
|
||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||
<dict>
|
||||
<key>kZoomLevelIdentifierAlbums</key>
|
||||
<integer>10</integer>
|
||||
<key>kZoomLevelIdentifierVersions</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
<key>lastAddToDestination</key>
|
||||
<dict>
|
||||
<key>key</key>
|
||||
<integer>1</integer>
|
||||
<key>lastKnownDisplayName</key>
|
||||
<string>Test Album (1)</string>
|
||||
<key>type</key>
|
||||
<string>album</string>
|
||||
<key>uuid</key>
|
||||
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
|
||||
</dict>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
<key>other</key>
|
||||
<integer>0</integer>
|
||||
<key>photos</key>
|
||||
<integer>6</integer>
|
||||
<key>videos</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-01-11T16:41:00Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-01-12T06:02:45Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ProcessedInQuiescentState</key>
|
||||
<true/>
|
||||
<key>SuggestedMeIdentifier</key>
|
||||
<string></string>
|
||||
<key>Version</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PVClustererBringUpState</key>
|
||||
<integer>50</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IncrementalPersonProcessingStage</key>
|
||||
<integer>0</integer>
|
||||
<key>PersonBuilderLastMinimumFaceGroupSizeForCreatingMergeCandidates</key>
|
||||
<integer>15</integer>
|
||||
<key>PersonBuilderMergeCandidatesEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2020-01-04T18:29:59Z</date>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 524 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PLLanguageAndLocaleKey</key>
|
||||
<string>en-US:en_US</string>
|
||||
<key>PLLastGeoProviderIdKey</key>
|
||||
<string>7618</string>
|
||||
<key>PLLastLocationInfoFormatVer</key>
|
||||
<integer>12</integer>
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-01-11T16:40:56Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>575</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FileVersion</key>
|
||||
<integer>11</integer>
|
||||
<key>Source</key>
|
||||
<dict>
|
||||
<key>35230</key>
|
||||
<dict>
|
||||
<key>CountryMinVersions</key>
|
||||
<dict>
|
||||
<key>OTHER</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>CurrentVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>NoResultErrorIsSuccess</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>57879</key>
|
||||
<dict>
|
||||
<key>CountryMinVersions</key>
|
||||
<dict>
|
||||
<key>OTHER</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>CurrentVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>NoResultErrorIsSuccess</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>7618</key>
|
||||
<dict>
|
||||
<key>AddCountyIfNeeded</key>
|
||||
<true/>
|
||||
<key>CountryMinVersions</key>
|
||||
<dict>
|
||||
<key>OTHER</key>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
<key>CurrentVersion</key>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 329 KiB |
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DatabaseMinorVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>DatabaseVersion</key>
|
||||
<integer>112</integer>
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>575</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
</dict>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>2</integer>
|
||||
<key>SnapshotComplete</key>
|
||||
<true/>
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2020-01-12T06:04:41Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||