Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bac106eb7 | ||
|
|
47d1c82c03 | ||
|
|
6f281711e2 | ||
|
|
4b30b3b426 | ||
|
|
1fa9583ea6 | ||
|
|
235e1fb1a6 | ||
|
|
36c2821a0f | ||
|
|
ed425724a0 | ||
|
|
55daa31c71 | ||
|
|
b6ac9e1ea3 | ||
|
|
9d151478d6 | ||
|
|
7d55844390 | ||
|
|
f398e9116f | ||
|
|
4fe8190b57 | ||
|
|
7e42ebb240 | ||
|
|
edae116baa | ||
|
|
d542cda17d | ||
|
|
99b5b54c6d | ||
|
|
379feddcda | ||
|
|
24285f5dd2 | ||
|
|
3cb3ebb300 | ||
|
|
16037f10fa | ||
|
|
ebd21491ac | ||
|
|
b7c7b9f066 | ||
|
|
21e7020fec | ||
|
|
952741d488 | ||
|
|
9fef12ed37 | ||
|
|
97362fc0f1 | ||
|
|
e09f0b40f1 | ||
|
|
b749681c6d | ||
|
|
8544667c72 | ||
|
|
d6a22b765a | ||
|
|
96365728c2 | ||
|
|
c01f713f00 | ||
|
|
1aa3838c38 | ||
|
|
cde56e9d13 | ||
|
|
1c9da5ed6f | ||
|
|
d74f7f499b | ||
|
|
c85bb02304 | ||
|
|
3e5062684a | ||
|
|
626e460aab | ||
|
|
1820715849 | ||
|
|
a6ca3f453c | ||
|
|
ddaa66d19e | ||
|
|
6073acc9d3 | ||
|
|
bae0283441 | ||
|
|
507c4a3740 | ||
|
|
6a898886dd | ||
|
|
01cd7fed6d | ||
|
|
e8273c9752 | ||
|
|
fd5e748dca | ||
|
|
c02953ef5f | ||
|
|
daea30f162 |
97
CHANGELOG.md
97
CHANGELOG.md
@@ -4,6 +4,103 @@ 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.28.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.4...v0.28.1)
|
||||
|
||||
> 18 April 2020
|
||||
|
||||
- Initial work on suppport for associated RAW images [`7e42ebb`](https://github.com/RhetTbull/osxphotos/commit/7e42ebb2402d45cd5d20bdd55bddddaa9db4679f)
|
||||
- Initial support for RAW photos in Photos 4 to address issue #101 [`9d15147`](https://github.com/RhetTbull/osxphotos/commit/9d151478d610291b8d482aafae3d445dfd391fca)
|
||||
- replaced CLI option --original-name with --current-name [`36c2821`](https://github.com/RhetTbull/osxphotos/commit/36c2821a0fa62eaaa54cf1edc2d9c6da98155354)
|
||||
|
||||
#### [v0.27.4](https://github.com/RhetTbull/osxphotos/compare/v0.27.3...v0.27.4)
|
||||
|
||||
> 12 April 2020
|
||||
|
||||
- Added {folder_album} to template and --folder to CLI [`b7c7b9f`](https://github.com/RhetTbull/osxphotos/commit/b7c7b9f0664e69c743bdd8a228ad2936cf6b7600)
|
||||
- Test library update [`21e7020`](https://github.com/RhetTbull/osxphotos/commit/21e7020fec406b0f3926d7adc8a1451bfe77e75a)
|
||||
- Updated CHANGELOG.md [`952741d`](https://github.com/RhetTbull/osxphotos/commit/952741d488d2fbbaf8a0c1d3781ad7c4205c068f)
|
||||
|
||||
#### [v0.27.3](https://github.com/RhetTbull/osxphotos/compare/v0.27.1...v0.27.3)
|
||||
|
||||
> 12 April 2020
|
||||
|
||||
- Added additional tests for album_info [`97362fc`](https://github.com/RhetTbull/osxphotos/commit/97362fc0f13b2867abc013f4ba97ae60b0700894)
|
||||
- Fixed bug with handling of deleted albums [`9fef12e`](https://github.com/RhetTbull/osxphotos/commit/9fef12ed37634a7bdb11232976b4b2ddccd1a7cb)
|
||||
|
||||
#### [v0.27.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.0...v0.27.1)
|
||||
|
||||
> 12 April 2020
|
||||
|
||||
- Changed AlbumInfo and FolderInfo interface to maintain backwards compatibility with PhotosDB.albums [`e09f0b4`](https://github.com/RhetTbull/osxphotos/commit/e09f0b40f1671d70ee399cdc519492b04fac8adc)
|
||||
- Updated CHANGELOG.md [`b749681`](https://github.com/RhetTbull/osxphotos/commit/b749681c6d2545eacf653ab1b2a5d1384e3123eb)
|
||||
|
||||
#### [v0.27.0](https://github.com/RhetTbull/osxphotos/compare/v0.26.1...v0.27.0)
|
||||
|
||||
> 11 April 2020
|
||||
|
||||
- Update README.md [`#95`](https://github.com/RhetTbull/osxphotos/pull/95)
|
||||
- Added tests and README for AlbumInfo and FolderInfo [`d6a22b7`](https://github.com/RhetTbull/osxphotos/commit/d6a22b765ab17f6ef1ba8c50b77946f090979968)
|
||||
- Added albuminfo.py for AlbumInfo and FolderInfo classes [`9636572`](https://github.com/RhetTbull/osxphotos/commit/96365728c2ff42abfb6828872ffac53b4c3c8024)
|
||||
- Updated CHANGELOG.md [`cde56e9`](https://github.com/RhetTbull/osxphotos/commit/cde56e9d13baf3098ec85839cf1aaa33b4915ac9)
|
||||
- Update README.md TOC [`8544667`](https://github.com/RhetTbull/osxphotos/commit/8544667c729ea0d7fe39671d909e09cda519e250)
|
||||
|
||||
#### [v0.26.1](https://github.com/RhetTbull/osxphotos/compare/v0.26.0...v0.26.1)
|
||||
|
||||
> 11 April 2020
|
||||
|
||||
- Bug fix for PhotosDB.photos() query [`1c9da5e`](https://github.com/RhetTbull/osxphotos/commit/1c9da5ed6ffa21f0577906b65b7da08951725d1f)
|
||||
- Updated test library [`d74f7f4`](https://github.com/RhetTbull/osxphotos/commit/d74f7f499bf59f37ec81cfa9d49cbbf3aafb5961)
|
||||
- Updated CHANGELOG.md [`c85bb02`](https://github.com/RhetTbull/osxphotos/commit/c85bb023042e072d6688060eb259156c2fa579b9)
|
||||
|
||||
#### [v0.26.0](https://github.com/RhetTbull/osxphotos/compare/v0.25.1...v0.26.0)
|
||||
|
||||
> 11 April 2020
|
||||
|
||||
- Added test for 10.15.4 [`1820715`](https://github.com/RhetTbull/osxphotos/commit/182071584904d001a9b199eef5febfb79e00696e)
|
||||
- Changed PhotosDB albums interface as prep for adding folders [`3e50626`](https://github.com/RhetTbull/osxphotos/commit/3e5062684ab6d706d91d4abeb4e3b0ca47867b70)
|
||||
- Updated CHANGELOG.md [`a6ca3f4`](https://github.com/RhetTbull/osxphotos/commit/a6ca3f453ce0fae4e8d13c7c256ed69a16d2e3f2)
|
||||
|
||||
#### [v0.25.1](https://github.com/RhetTbull/osxphotos/compare/v0.25.0...v0.25.1)
|
||||
|
||||
> 5 April 2020
|
||||
|
||||
- Added --no-extended-attributes option to CLI, closes #85 [`#85`](https://github.com/RhetTbull/osxphotos/issues/85)
|
||||
- Fixed CLI help for invalid topic, closes #76 [`#76`](https://github.com/RhetTbull/osxphotos/issues/76)
|
||||
- Updated test library [`bae0283`](https://github.com/RhetTbull/osxphotos/commit/bae0283441f04d71aa78dbd1cf014f376ef1f91a)
|
||||
|
||||
#### [v0.25.0](https://github.com/RhetTbull/osxphotos/compare/v0.24.2...v0.25.0)
|
||||
|
||||
> 4 April 2020
|
||||
|
||||
- Added places, --place, --no-place to CLI, closes #87, #88 [`#87`](https://github.com/RhetTbull/osxphotos/issues/87)
|
||||
- Updated render_filepath_template to support multiple values [`6a89888`](https://github.com/RhetTbull/osxphotos/commit/6a898886ddadc9d5bc9dbad6ee7365270dd0a26d)
|
||||
- Added {album}, {keyword}, and {person} to template system [`507c4a3`](https://github.com/RhetTbull/osxphotos/commit/507c4a374014f999ca19789bce0df0c14332e021)
|
||||
- Added places command to CLI [`fd5e748`](https://github.com/RhetTbull/osxphotos/commit/fd5e748dca759ea1c3a7329d447f363afe8418b7)
|
||||
- Updated export example [`01cd7fe`](https://github.com/RhetTbull/osxphotos/commit/01cd7fed6d7fc0c61c171a05319c211eb0a9f7c1)
|
||||
- Updated CHANGELOG.md [`daea30f`](https://github.com/RhetTbull/osxphotos/commit/daea30f1626a208209ab6854cbd3b12f4b0a3405)
|
||||
|
||||
#### [v0.24.2](https://github.com/RhetTbull/osxphotos/compare/v0.24.1...v0.24.2)
|
||||
|
||||
> 28 March 2020
|
||||
|
||||
- added {place.country_code} to template system [`be2e167`](https://github.com/RhetTbull/osxphotos/commit/be2e16769d5d2c75af6d7792f1311f5a65c3bc67)
|
||||
|
||||
#### [v0.24.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.4...v0.24.1)
|
||||
|
||||
> 28 March 2020
|
||||
|
||||
- Added detailed place data in PlaceInfo.names [`c06dd42`](https://github.com/RhetTbull/osxphotos/commit/c06dd4233f917f068c087f5604013d371b0a826a)
|
||||
- Template system now supports default values [`67a9a9e`](https://github.com/RhetTbull/osxphotos/commit/67a9a9e21bd05d01a3202b0a1279487f5d04c9d9)
|
||||
- Replaced template renderer with regex-based renderer [`427c4c0`](https://github.com/RhetTbull/osxphotos/commit/427c4c0bc49f671477866d30eee74834c67d7bc5)
|
||||
|
||||
#### [v0.23.4](https://github.com/RhetTbull/osxphotos/compare/v0.23.3...v0.23.4)
|
||||
|
||||
> 22 March 2020
|
||||
|
||||
- Added export_by_album.py to examples [`908fead`](https://github.com/RhetTbull/osxphotos/commit/908fead8a2fbcef3b4a387f34d83d88c507c5939)
|
||||
- Updated CHANGELOG.md [`072e894`](https://github.com/RhetTbull/osxphotos/commit/072e894e56c4dfe5522d073b202933fed0204ef5)
|
||||
- Updated pathvalidate calls [`d066435`](https://github.com/RhetTbull/osxphotos/commit/d066435e3df4062be6a0a3d5fa7308f293e764d5)
|
||||
|
||||
#### [v0.23.3](https://github.com/RhetTbull/osxphotos/compare/v0.23.1...v0.23.3)
|
||||
|
||||
> 22 March 2020
|
||||
|
||||
283
README.md
283
README.md
@@ -13,12 +13,15 @@
|
||||
* [Package Interface](#package-interface)
|
||||
+ [PhotosDB](#photosdb)
|
||||
+ [PhotoInfo](#photoinfo)
|
||||
+ [AlbumInfo](#albuminfo)
|
||||
+ [FolderInfo](#folderinfo)
|
||||
+ [PlaceInfo](#placeinfo)
|
||||
+ [Template Functions](#template-functions)
|
||||
+ [Utility Functions](#utility-functions)
|
||||
* [Examples](#examples)
|
||||
* [Related Projects](#related-projects)
|
||||
* [Contributing](#contributing)
|
||||
* [Known Bugs](#known-bugs)
|
||||
* [Implementation Notes](#implementation-notes)
|
||||
* [Dependencies](#dependencies)
|
||||
* [Acknowledgements](#acknowledgements)
|
||||
@@ -57,7 +60,14 @@ Then you should be able to run `osxphotos` on the command line:
|
||||
Usage: osxphotos [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Options:
|
||||
--db <Photos database path> Specify database file.
|
||||
--db <Photos database path> Specify Photos database path. Path to Photos
|
||||
library/database can be specified using either
|
||||
--db or directly as PHOTOS_LIBRARY positional
|
||||
argument. If neither --db or PHOTOS_LIBRARY
|
||||
provided, will attempt to find the library to
|
||||
use in the following order: 1. last opened
|
||||
library, 2. system library, 3.
|
||||
~/Pictures/Photos Library.photoslibrary
|
||||
--json Print output in JSON format.
|
||||
-v, --version Show the version and exit.
|
||||
-h, --help Show this message and exit.
|
||||
@@ -71,6 +81,7 @@ Commands:
|
||||
keywords Print out keywords found in the Photos library.
|
||||
list Print list of Photos libraries found on the system.
|
||||
persons Print out persons (faces) found in the Photos library.
|
||||
places Print out places found in the Photos library.
|
||||
query Query the Photos database using 1 or more search options; if...
|
||||
```
|
||||
|
||||
@@ -85,7 +96,10 @@ Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
||||
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.
|
||||
photos will be exported. By default, all versions of all photos will be
|
||||
exported including edited versions, live photo movies, burst photos, and
|
||||
associated RAW images. See --skip-edited, --skip-live, --skip-bursts, and
|
||||
--skip-raw options to modify this behavior.
|
||||
|
||||
Options:
|
||||
--db <Photos database path> Specify Photos database path. Path to Photos
|
||||
@@ -97,25 +111,35 @@ Options:
|
||||
order: 1. last opened library, 2. system
|
||||
library, 3. ~/Pictures/Photos
|
||||
Library.photoslibrary
|
||||
--keyword KEYWORD Search for keyword KEYWORD. If more than one
|
||||
keyword, treated as "OR", e.g. find photos
|
||||
match any keyword
|
||||
--person PERSON Search for person PERSON. If more than one
|
||||
person, treated as "OR", e.g. find photos
|
||||
match any person
|
||||
--album ALBUM Search for album ALBUM. If more than one
|
||||
album, treated as "OR", e.g. find photos
|
||||
match any album
|
||||
--uuid UUID Search for UUID(s).
|
||||
-V, --verbose Print verbose output.
|
||||
--keyword KEYWORD Search for photos with keyword KEYWORD. If
|
||||
more than one keyword, treated as "OR", e.g.
|
||||
find photos match any keyword
|
||||
--person PERSON Search for photos with person PERSON. If
|
||||
more than one person, treated as "OR", e.g.
|
||||
find photos match any person
|
||||
--album ALBUM Search for photos in album ALBUM. If more
|
||||
than one album, treated as "OR", e.g. find
|
||||
photos match any album
|
||||
--folder FOLDER Search for photos in an album in folder
|
||||
FOLDER. If more than one folder, treated as
|
||||
"OR", e.g. find photos in any FOLDER. Only
|
||||
searches top level folders (e.g. does not
|
||||
look at subfolders)
|
||||
--uuid UUID Search for photos with UUID(s).
|
||||
--title TITLE Search for TITLE in title of photo.
|
||||
--no-title Search for photos with no title.
|
||||
--description DESC Search for DESC in description of photo.
|
||||
--no-description Search for photos with no description.
|
||||
--place PLACE Search for PLACE in photo's reverse
|
||||
geolocation info
|
||||
--no-place Search for photos with no associated place
|
||||
name info (no reverse geolocation info)
|
||||
--uti UTI Search for photos whose uniform type
|
||||
identifier (UTI) matches UTI
|
||||
-i, --ignore-case Case insensitive search for title or
|
||||
description. Does not apply to keyword,
|
||||
person, or album.
|
||||
-i, --ignore-case Case insensitive search for title,
|
||||
description, or place. Does not apply to
|
||||
keyword, person, or album.
|
||||
--edited Search for photos that have been edited.
|
||||
--external-edit Search for photos edited in external editor.
|
||||
--favorite Search for photos marked favorite.
|
||||
@@ -152,6 +176,8 @@ Options:
|
||||
--not-selfie Search for photos that are not selfies.
|
||||
--panorama Search for panorama photos.
|
||||
--not-panorama Search for photos that are not panoramas.
|
||||
--has-raw Search for photos with both a jpeg and RAW
|
||||
version
|
||||
--only-movies Search only for movies (default searches
|
||||
both images and movies).
|
||||
--only-photos Search only for photos/images (default
|
||||
@@ -164,7 +190,6 @@ Options:
|
||||
Search by end item date, e.g.
|
||||
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601
|
||||
w/o TZ).
|
||||
-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
|
||||
@@ -173,19 +198,23 @@ Options:
|
||||
--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. Not
|
||||
currently compatible with --download-
|
||||
misssing; see note on --download-missing.
|
||||
--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.
|
||||
--skip-edited Do not export edited version of photo if an
|
||||
edited version exists.
|
||||
--skip-bursts Do not export all associated burst images in
|
||||
the library if a photo is a burst photo.
|
||||
--skip-live Do not export the associated live video
|
||||
component of a live photo.
|
||||
--skip-raw Do not export associated RAW images of a
|
||||
RAW/jpeg pair. Note: this does not skip RAW
|
||||
photos if the RAW photo does not have an
|
||||
associated jpeg image (e.g. the RAW file was
|
||||
imported to Photos without a jpeg preview).
|
||||
--current-name Use photo's current filename instead of
|
||||
original filename for export. Note:
|
||||
Starting with Photos 5, all photos are
|
||||
renamed upon import. By default, photos are
|
||||
exported with the the original name they had
|
||||
before import.
|
||||
--sidecar FORMAT Create sidecar for each photo exported;
|
||||
valid FORMAT values: xmp, json; --sidecar
|
||||
json: create JSON sidecar useable by
|
||||
@@ -205,9 +234,9 @@ Options:
|
||||
exist on disk. This will be slow and will
|
||||
require internet connection. This obviously
|
||||
only works if the Photos library is synched
|
||||
to iCloud. Note: --download-missing is not
|
||||
currently compatabile with --export-bursts;
|
||||
only the primary photo will be exported--
|
||||
to iCloud. Note: --download-missing does
|
||||
not currently export all burst images; only
|
||||
the primary photo will be exported--
|
||||
associated burst images will be skipped.
|
||||
--exiftool Use exiftool to write metadata directly to
|
||||
exported photos. To use this option,
|
||||
@@ -218,6 +247,11 @@ Options:
|
||||
output directory in the form
|
||||
'{name,DEFAULT}'. See below for additional
|
||||
details on templating system.
|
||||
--no-extended-attributes Don't copy extended attributes when
|
||||
exporting. You only need this if exporting
|
||||
to a filesystem that doesn't support Mac OS
|
||||
extended attributes. Only use this if you
|
||||
get an error while exporting.
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
**Templating System**
|
||||
@@ -232,13 +266,11 @@ be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
|
||||
|
||||
In the template, valid template substitutions will be replaced by the
|
||||
corresponding value from the table below. Invalid substitutions will result
|
||||
in a warning but will be left unchanged. e.g. if you put '{foo}' in your
|
||||
template, e.g. '{created.year}/{foo}', the resulting output directory would
|
||||
look like '/Users/maria/Pictures/export/2020/{foo}'
|
||||
in a an error and the script will abort.
|
||||
|
||||
If you want the actual text of the template substition to appear in the
|
||||
rendered name, escape the curly braces with \, for example, using
|
||||
'{created.year}/\{name\}' for --directory would result in output of
|
||||
rendered name, use double braces, e.g. '{{' or '}}', thus using
|
||||
'{created.year}/{{name}}' for --directory would result in output of
|
||||
2020/{name}/photoname.jpg
|
||||
|
||||
You may specify an optional default value to use if the substitution does not
|
||||
@@ -288,6 +320,8 @@ Substitution Description
|
||||
padded)
|
||||
{place.name} Place name from the photo's reverse
|
||||
geolocation data, as displayed in Photos
|
||||
{place.country_code} The ISO country code from the photo's
|
||||
reverse geolocation data
|
||||
{place.name.country} Country name from the photo's reverse
|
||||
geolocation data
|
||||
{place.name.state_province} State or province name from the photo's
|
||||
@@ -312,15 +346,30 @@ Substitution Description
|
||||
'United States'
|
||||
{place.address.country_code} ISO country code of the postal address, e.g.
|
||||
'US'
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified
|
||||
for --directory these could result in multiple copies of a photo being being
|
||||
exported, one to each directory. For example: --directory
|
||||
'{created.year}/{album}' could result in the same photo being exported to each
|
||||
of the following directories if the photos were created in 2019 and were in
|
||||
albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family
|
||||
|
||||
Substitution Description
|
||||
{album} Album(s) photo is contained in
|
||||
{folder_album} Folder path + album photo is contained in. e.g.
|
||||
'Folder/Subfolder/Album' or just 'Album' if no enclosing
|
||||
folder
|
||||
{keyword} Keyword(s) assigned to photo
|
||||
{person} Person(s) / face(s) in a photo
|
||||
```
|
||||
|
||||
Example: export all photos to ~/Desktop/export, including edited versions and live photo movies, group in folders by date created
|
||||
Example: export all photos to ~/Desktop/export group in folders by date created
|
||||
|
||||
`osxphotos export --export-edited --export-live --export-by-date ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
|
||||
`osxphotos export --export-by-date ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
|
||||
|
||||
**Note**: Photos library/database path can also be specified using --db option:
|
||||
|
||||
`osxphotos export --export-edited --export-live --export-by-date --db ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
|
||||
`osxphotos export --export-by-date --db ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
|
||||
|
||||
Example: find all photos with keyword "Kids" and output results to json file named results.json:
|
||||
|
||||
@@ -344,7 +393,7 @@ def main():
|
||||
photosdb = osxphotos.PhotosDB(db)
|
||||
print(photosdb.keywords)
|
||||
print(photosdb.persons)
|
||||
print(photosdb.albums)
|
||||
print(photosdb.album_names)
|
||||
|
||||
print(photosdb.keywords_as_dict)
|
||||
print(photosdb.persons_as_dict)
|
||||
@@ -530,22 +579,52 @@ keywords = photosdb.keywords
|
||||
|
||||
Returns a list of the keywords found in the Photos library
|
||||
|
||||
#### `album_info`
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
albums = photosdb.album_info
|
||||
```
|
||||
|
||||
Returns a list of [AlbumInfo](#AlbumInfo) objects representing albums in the database or empty list if there are no albums. See also [albums](#albums).
|
||||
|
||||
#### `albums`
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
albums = photosdb.albums
|
||||
album_names = photosdb.albums
|
||||
```
|
||||
|
||||
Returns a list of the albums found in the Photos library.
|
||||
Returns a list of the album names found in the Photos library.
|
||||
|
||||
**Note**: In Photos 5.0 (MacOS 10.15/Catalina), It is possible to have more than one album with the same name in Photos. Albums with duplicate names are treated as a single album and the photos in each are combined. For example, if you have two albums named "Wedding" and each has 2 photos, osxphotos will treat this as a single album named "Wedding" with 4 photos in it.
|
||||
|
||||
See also [album_info](#album_info.)
|
||||
|
||||
#### `albums_shared`
|
||||
|
||||
Returns list of shared albums found in photos database (e.g. albums shared via iCloud photo sharing)
|
||||
Returns list of shared album names found in photos database (e.g. albums shared via iCloud photo sharing)
|
||||
|
||||
**Note**: *Only valid for Photos 5 / MacOS 10.15*; on Photos <= 4, prints warning and returns empty list.
|
||||
|
||||
#### `folder_info`
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
folders = photosdb.folder_info
|
||||
```
|
||||
|
||||
Returns a list of [FolderInfo](#FolderInfo) objects representing top level folders in the database or empty list if there are no folders. See also [folders](#folders).
|
||||
|
||||
**Note**: Currently folder_info is only implemented for Photos 5 (Catalina); will return empty list and output warning if called on earlier database versions.
|
||||
|
||||
#### `folders`
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
folders = photosdb.folders
|
||||
```
|
||||
|
||||
Returns a list names of top level folder names in the database.
|
||||
|
||||
**Note**: Currently folders is only implemented for Photos 5 (Catalina); will return empty list and output warning if called on earlier database versions.
|
||||
|
||||
#### `persons`
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
@@ -755,7 +834,10 @@ Returns the title of the photo
|
||||
Returns a list of keywords (e.g. tags) applied to the photo
|
||||
|
||||
#### `albums`
|
||||
Returns a list of albums the photo is contained in
|
||||
Returns a list of albums the photo is contained in. See also [album_info](#album_info).
|
||||
|
||||
#### `album_info`
|
||||
Returns a list of [AlbumInfo](#AlbumInfo) objects representing the albums the photo is contained in. See also [albums](#albums).
|
||||
|
||||
#### `persons`
|
||||
Returns a list of the names of the persons in the photo
|
||||
@@ -868,7 +950,7 @@ Returns True if photo is a panorama, otherwise False.
|
||||
#### `json()`
|
||||
Returns a JSON representation of all photo info
|
||||
|
||||
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False)`
|
||||
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)`
|
||||
|
||||
Export photo from the Photos library to another destination on disk.
|
||||
- dest: must be valid destination path as str (or exception raised).
|
||||
@@ -882,6 +964,7 @@ Export photo from the Photos library to another destination on disk.
|
||||
- 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
|
||||
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
|
||||
- no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
||||
|
||||
Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original imaage and the associated .mov file will be exported
|
||||
|
||||
@@ -901,7 +984,83 @@ Then
|
||||
|
||||
If overwrite=False and increment=False, export will fail if destination file already exists
|
||||
|
||||
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses /usr/bin/ditto to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
|
||||
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses `/usr/bin/ditto` to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
|
||||
|
||||
### AlbumInfo
|
||||
PhotosDB.album_info and PhotoInfo.album_info return a list of AlbumInfo objects. Each AlbumInfo object represents a single album in the Photos library.
|
||||
|
||||
#### `uuid`
|
||||
Returns the universally unique identifier (uuid) of the album. This is how Photos keeps track of individual objects within the database.
|
||||
|
||||
#### `title`
|
||||
Returns the title or name of the album.
|
||||
|
||||
#### `photos`
|
||||
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album.
|
||||
|
||||
#### `folder_list`
|
||||
Returns a hierarchical list of [FolderInfo](#FolderInfo) objects representing the folders the album is contained in. For example, if album "AlbumInFolder" is in SubFolder2 of Folder1 as illustrated below, would return a list of `FolderInfo` objects representing ["Folder1", "SubFolder2"]
|
||||
|
||||
```txt
|
||||
Photos Library
|
||||
├── Folder1
|
||||
├── SubFolder1
|
||||
├── SubFolder2
|
||||
└── AlbumInFolder
|
||||
```
|
||||
|
||||
#### `folder_names`
|
||||
Returns a hierarchical list of names of the folders the album is contained in. For example, if album is in SubFolder2 of Folder1 as illustrated below, would return ["Folder1", "SubFolder2"].
|
||||
|
||||
```txt
|
||||
Photos Library
|
||||
├── Folder1
|
||||
├── SubFolder1
|
||||
├── SubFolder2
|
||||
└── AlbumInFolder
|
||||
```
|
||||
|
||||
#### `parent`
|
||||
Returns a [FolderInfo](#FolderInfo) object representing the albums parent folder or `None` if album is not a in a folder.
|
||||
|
||||
|
||||
### FolderInfo
|
||||
PhotosDB.folder_info returns a list of FolderInfo objects representing the top level folders in the library. Each FolderInfo object represents a single folder in the Photos library.
|
||||
|
||||
#### `uuid`
|
||||
Returns the universally unique identifier (uuid) of the folder. This is how Photos keeps track of individual objects within the database.
|
||||
|
||||
#### `title`
|
||||
Returns the title or name of the folder.
|
||||
|
||||
#### `album_info`
|
||||
Returns a list of [AlbumInfo](#AlbumInfo) objects representing each album contained in the folder.
|
||||
|
||||
#### `subfolders`
|
||||
Returns a list of [FolderInfo](#FolderInfo) objects representing the sub-folders of the folder.
|
||||
|
||||
#### `parent`
|
||||
Returns a [FolderInfo](#FolderInfo) object representing the folder's parent folder or `None` if album is not a in a folder.
|
||||
|
||||
**Note**: FolderInfo and AlbumInfo objects effectively work as a linked list. The children of a folder are contained in `subfolders` and `album_info` and the parent object of both `AlbumInfo` and `FolderInfo` is represented by `parent`. For example:
|
||||
|
||||
```python
|
||||
>>> import osxphotos
|
||||
>>> photosdb = osxphotos.PhotosDB()
|
||||
>>> photosdb.folder_info
|
||||
[<osxphotos.albuminfo.FolderInfo object at 0x10fcc0160>]
|
||||
>>> photosdb.folder_info[0].title
|
||||
'Folder1'
|
||||
>>> photosdb.folder_info[0].subfolders[1].title
|
||||
'SubFolder2'
|
||||
>>> photosdb.folder_info[0].subfolders[1].album_info[0].title
|
||||
'AlbumInFolder'
|
||||
>>> photosdb.folder_info[0].subfolders[1].album_info[0].parent.title
|
||||
'SubFolder2'
|
||||
>>> photosdb.folder_info[0].subfolders[1].album_info[0].parent.album_info[0].title
|
||||
'AlbumInFolder'
|
||||
```
|
||||
|
||||
|
||||
### PlaceInfo
|
||||
[PhotoInfo.place](#place) returns a PlaceInfo object if the photo contains valid reverse geolocation information. PlaceInfo has the following properties.
|
||||
@@ -982,9 +1141,16 @@ Render template string for photo. none_str is used if template substitution res
|
||||
- `photo`: a [PhotoInfo](#photoinfo) object
|
||||
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
|
||||
|
||||
Returns a tuple of (rendered, unmatched) where rendered is the rendered template string with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. strings in the form "{foo}".
|
||||
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
|
||||
|
||||
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
|
||||
|
||||
If you want to include "{" or "}" in the output, use "{{" or "}}"
|
||||
|
||||
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
|
||||
|
||||
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
|
||||
|
||||
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `("2020/{foo}",["{foo}"])`
|
||||
|
||||
| Substitution | Description |
|
||||
|--------------|-------------|
|
||||
@@ -1007,7 +1173,7 @@ e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `("2
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the file modification time|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)|
|
||||
|{place.name}|Place name from the photo's reverse geolocation data, as displayed in Photos|
|
||||
|{place.country_code}|The ISO country code from the photo's reverse geolocationo data|
|
||||
|{place.country_code}|The ISO country code from the photo's reverse geolocation data|
|
||||
|{place.name.country}|Country name from the photo's reverse geolocation data|
|
||||
|{place.name.state_province}|State or province name from the photo's reverse geolocation data|
|
||||
|{place.name.city}|City or locality name from the photo's reverse geolocation data|
|
||||
@@ -1019,6 +1185,10 @@ e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `("2
|
||||
|{place.address.postal_code}|Postal code part of the postal address, e.g. '20009'|
|
||||
|{place.address.country}|Country name of the postal address, e.g. 'United States'|
|
||||
|{place.address.country_code}|ISO country code of the postal address, e.g. 'US'|
|
||||
|{album}|Album(s) photo is contained in|
|
||||
|{keyword}|Keyword(s) assigned to photo|
|
||||
|{person}|Person(s) / face(s) in a photo|
|
||||
|
||||
|
||||
#### `DateTimeFormatter(dt)`
|
||||
Class that provides easy access to formatted datetime values.
|
||||
@@ -1077,7 +1247,7 @@ def main():
|
||||
|
||||
print(photosdb.keywords)
|
||||
print(photosdb.persons)
|
||||
print(photosdb.albums)
|
||||
print(photosdb.album_names)
|
||||
|
||||
print(photosdb.keywords_as_dict)
|
||||
print(photosdb.persons_as_dict)
|
||||
@@ -1136,13 +1306,20 @@ If you have an interesting example that shows usage of this package, submit an i
|
||||
|
||||
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
|
||||
|
||||
## Known Bugs
|
||||
|
||||
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 400 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
|
||||
|
||||
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101) Note: Alpha version of fix for this bug is implemented in the current version of osxphotos.
|
||||
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This packge works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. the class photosdb then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc. If your library is large, the database can be hundreds of MB in size and the copy then read can take many 10s of seconds to complete. Once copied, the entire database is processed and an in-memory data structure is created meaning all subsequent accesses of the PhotosDB object occur much more quickly.
|
||||
This package works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. The class PhotosDB then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc. If your library is large, the database can be hundreds of MB in size and the copy read then can take many 10s of seconds to complete. Once copied, the entire database is processed and an in-memory data structure is created meaning all subsequent accesses of the PhotosDB object occur much more quickly.
|
||||
|
||||
If apple changes the database format this will likely break.
|
||||
|
||||
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the funcationality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
|
||||
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the funcationality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons) and Apple's System Integrity Protection (SIP) made the interface unreliable. If you'd like to experiment with the PhotoKit interface, here's some sample [code](https://gist.github.com/RhetTbull/41cc85e5bdeb30f761147ce32fba5c94). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
|
||||
|
||||
## Dependencies
|
||||
- [PyObjC](https://pythonhosted.org/pyobjc/)
|
||||
|
||||
@@ -25,7 +25,14 @@ import osxphotos
|
||||
help="Path to Photos library, default to last used library",
|
||||
default=None,
|
||||
)
|
||||
def export(export_path, default_album, library_path):
|
||||
@click.option(
|
||||
"--edited",
|
||||
help="Also export edited versions of photos (default is originals only)",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
def export(export_path, default_album, library_path, edited):
|
||||
""" Export all photos, organized by album """
|
||||
export_path = os.path.expanduser(export_path)
|
||||
library_path = os.path.expanduser(library_path) if library_path else None
|
||||
|
||||
@@ -42,7 +49,7 @@ def export(export_path, default_album, library_path):
|
||||
if not albums:
|
||||
albums = [default_album]
|
||||
for album in albums:
|
||||
click.echo(f"exporting {p.filename} in album {album}")
|
||||
click.echo(f"exporting {p.original_filename} in album {album}")
|
||||
|
||||
# make sure no invalid characters in destination path (could be in album name)
|
||||
album_name = sanitize_filepath(album, platform="auto")
|
||||
@@ -58,17 +65,21 @@ def export(export_path, default_album, library_path):
|
||||
if not os.path.isdir(dest_dir):
|
||||
os.makedirs(dest_dir)
|
||||
|
||||
# export the photo
|
||||
if p.hasadjustments:
|
||||
filename = p.original_filename
|
||||
# export the photo but only if --edited, photo has adjustments, and
|
||||
# path_edited is not None (can be None if edited photo is missing)
|
||||
if edited and p.hasadjustments and p.path_edited:
|
||||
# export edited version
|
||||
exported = p.export(dest_dir, edited=True)
|
||||
edited_name = pathlib.Path(p.path_edited).name
|
||||
click.echo(f"Exported {edited_name} to {exported}")
|
||||
# use original filename with _edited appended but make sure suffix is
|
||||
# same as edited file
|
||||
edited_filename = f"{pathlib.Path(filename).stem}_edited{pathlib.Path(p.path_edited).suffix}"
|
||||
exported = p.export(dest_dir, edited_filename, edited=True)
|
||||
click.echo(f"Exported {edited_filename} to {exported}")
|
||||
# export unedited version
|
||||
exported = p.export(dest_dir)
|
||||
click.echo(f"Exported {p.filename} to {exported}")
|
||||
exported = p.export(dest_dir, filename)
|
||||
click.echo(f"Exported {filename} to {exported}")
|
||||
else:
|
||||
click.echo(f"Skipping missing photo: {p.filename}")
|
||||
click.echo(f"Skipping missing photo: {p.original_filename} in album {album}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -18,10 +18,14 @@ from pathvalidate import (
|
||||
|
||||
import osxphotos
|
||||
|
||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION
|
||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION, _UNKNOWN_PLACE
|
||||
from ._version import __version__
|
||||
from .exiftool import get_exiftool_path
|
||||
from .template import render_filepath_template, TEMPLATE_SUBSTITUTIONS
|
||||
from .template import (
|
||||
render_filepath_template,
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
)
|
||||
from .utils import _copy_file, create_path_by_date
|
||||
|
||||
|
||||
@@ -91,15 +95,13 @@ class ExportCommand(click.Command):
|
||||
formatter.write_text(
|
||||
"In the template, valid template substitutions will be replaced by "
|
||||
+ "the corresponding value from the table below. Invalid substitutions will result in a "
|
||||
+ "warning but will be left unchanged. e.g. if you put '{foo}' in your template, "
|
||||
+ "e.g. '{created.year}/{foo}', the resulting output directory would look like "
|
||||
+ "'/Users/maria/Pictures/export/2020/{foo}' "
|
||||
+ "an error and the script will abort."
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"If you want the actual text of the template substition to appear "
|
||||
+ "in the rendered name, escape the curly braces with \\, for example, "
|
||||
+ "using '{created.year}/\\{name\\}' for --directory "
|
||||
+ "in the rendered name, use double braces, e.g. '{{' or '}}', thus "
|
||||
+ "using '{created.year}/{{name}}' for --directory "
|
||||
+ "would result in output of 2020/{name}/photoname.jpg"
|
||||
)
|
||||
formatter.write("\n")
|
||||
@@ -126,6 +128,23 @@ class ExportCommand(click.Command):
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"The following substitutions may result in multiple values. Thus "
|
||||
+ "if specified for --directory these could result in multiple copies of a photo being "
|
||||
+ "being exported, one to each directory. For example: "
|
||||
+ "--directory '{created.year}/{album}' could result in the same photo being exported "
|
||||
+ "to each of the following directories if the photos were created in 2019 "
|
||||
+ "and were in albums 'Vacation' and 'Family': "
|
||||
+ "2019/Vacation, 2019/Family"
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend(
|
||||
(k, v) for k, v in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items()
|
||||
)
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
help_text += formatter.getvalue()
|
||||
@@ -168,7 +187,7 @@ def query_options(f):
|
||||
metavar="KEYWORD",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for keyword KEYWORD. "
|
||||
help="Search for photos with keyword KEYWORD. "
|
||||
'If more than one keyword, treated as "OR", e.g. find photos match any keyword',
|
||||
),
|
||||
o(
|
||||
@@ -176,7 +195,7 @@ def query_options(f):
|
||||
metavar="PERSON",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for person PERSON. "
|
||||
help="Search for photos with person PERSON. "
|
||||
'If more than one person, treated as "OR", e.g. find photos match any person',
|
||||
),
|
||||
o(
|
||||
@@ -184,15 +203,24 @@ def query_options(f):
|
||||
metavar="ALBUM",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for album ALBUM. "
|
||||
help="Search for photos in album ALBUM. "
|
||||
'If more than one album, treated as "OR", e.g. find photos match any album',
|
||||
),
|
||||
o(
|
||||
"--folder",
|
||||
metavar="FOLDER",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos in an album in folder FOLDER. "
|
||||
'If more than one folder, treated as "OR", e.g. find photos in any FOLDER. '
|
||||
"Only searches top level folders (e.g. does not look at subfolders)",
|
||||
),
|
||||
o(
|
||||
"--uuid",
|
||||
metavar="UUID",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for UUID(s).",
|
||||
help="Search for photos with UUID(s).",
|
||||
),
|
||||
o(
|
||||
"--title",
|
||||
@@ -214,6 +242,18 @@ def query_options(f):
|
||||
is_flag=True,
|
||||
help="Search for photos with no description.",
|
||||
),
|
||||
o(
|
||||
"--place",
|
||||
metavar="PLACE",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for PLACE in photo's reverse geolocation info",
|
||||
),
|
||||
o(
|
||||
"--no-place",
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated place name info (no reverse geolocation info)",
|
||||
),
|
||||
o(
|
||||
"--uti",
|
||||
metavar="UTI",
|
||||
@@ -225,7 +265,7 @@ def query_options(f):
|
||||
"-i",
|
||||
"--ignore-case",
|
||||
is_flag=True,
|
||||
help="Case insensitive search for title or description. Does not apply to keyword, person, or album.",
|
||||
help="Case insensitive search for title, description, or place. Does not apply to keyword, person, or album.",
|
||||
),
|
||||
o("--edited", is_flag=True, help="Search for photos that have been edited."),
|
||||
o(
|
||||
@@ -305,6 +345,11 @@ def query_options(f):
|
||||
is_flag=True,
|
||||
help="Search for photos that are not panoramas.",
|
||||
),
|
||||
o(
|
||||
"--has-raw",
|
||||
is_flag=True,
|
||||
help="Search for photos with both a jpeg and RAW version",
|
||||
),
|
||||
o(
|
||||
"--only-movies",
|
||||
is_flag=True,
|
||||
@@ -479,6 +524,56 @@ def info(ctx, cli_obj, db, json_, photos_library):
|
||||
click.echo(yaml.dump(info, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def places(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out places found in the Photos library. """
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(cli.commands["places"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
place_names = {}
|
||||
for photo in photosdb.photos(movies=True):
|
||||
if photo.place:
|
||||
try:
|
||||
place_names[photo.place.name] += 1
|
||||
except:
|
||||
place_names[photo.place.name] = 1
|
||||
else:
|
||||
try:
|
||||
place_names[_UNKNOWN_PLACE] += 1
|
||||
except:
|
||||
place_names[_UNKNOWN_PLACE] = 1
|
||||
|
||||
# sort by place count
|
||||
places = {
|
||||
"places": {
|
||||
name: place_names[name]
|
||||
for name in sorted(
|
||||
place_names.keys(), key=lambda key: place_names[key], reverse=True
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_json = cli_obj.json if cli_obj is not None else None
|
||||
if json_ or cli_json:
|
||||
click.echo(json.dumps(places))
|
||||
else:
|
||||
click.echo(yaml.dump(places, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@@ -589,6 +684,7 @@ def query(
|
||||
keyword,
|
||||
person,
|
||||
album,
|
||||
folder,
|
||||
uuid,
|
||||
title,
|
||||
no_title,
|
||||
@@ -633,6 +729,9 @@ def query(
|
||||
not_selfie,
|
||||
panorama,
|
||||
not_panorama,
|
||||
has_raw,
|
||||
place,
|
||||
no_place,
|
||||
):
|
||||
""" Query the Photos database using 1 or more search options;
|
||||
if more than one option is provided, they are treated as "AND"
|
||||
@@ -645,10 +744,12 @@ def query(
|
||||
keyword,
|
||||
person,
|
||||
album,
|
||||
folder,
|
||||
uuid,
|
||||
edited,
|
||||
external_edit,
|
||||
uti,
|
||||
has_raw,
|
||||
from_date,
|
||||
to_date,
|
||||
]
|
||||
@@ -670,6 +771,7 @@ def query(
|
||||
(hdr, not_hdr),
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
(any(place), no_place),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
if not any(nonexclusive + [b ^ n for b, n in exclusive]):
|
||||
@@ -697,6 +799,7 @@ def query(
|
||||
keyword=keyword,
|
||||
person=person,
|
||||
album=album,
|
||||
folder=folder,
|
||||
uuid=uuid,
|
||||
title=title,
|
||||
no_title=no_title,
|
||||
@@ -740,6 +843,9 @@ def query(
|
||||
not_selfie=not_selfie,
|
||||
panorama=panorama,
|
||||
not_panorama=not_panorama,
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
@@ -749,8 +855,8 @@ def query(
|
||||
|
||||
@cli.command(cls=ExportCommand)
|
||||
@DB_OPTION
|
||||
@query_options
|
||||
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
|
||||
@query_options
|
||||
@click.option(
|
||||
"--overwrite",
|
||||
is_flag=True,
|
||||
@@ -766,27 +872,33 @@ def query(
|
||||
"(e.g. DEST/2019/12/20/photoname.jpg).",
|
||||
)
|
||||
@click.option(
|
||||
"--export-edited",
|
||||
"--skip-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="Do not export edited version of photo if an edited version exists.",
|
||||
)
|
||||
@click.option(
|
||||
"--export-bursts",
|
||||
"--skip-bursts",
|
||||
is_flag=True,
|
||||
help="If a photo is a burst photo export all associated burst images in the library. "
|
||||
"Not currently compatible with --download-misssing; see note on --download-missing.",
|
||||
help="Do not export all associated burst images in the library if a photo is a burst photo. ",
|
||||
)
|
||||
@click.option(
|
||||
"--export-live",
|
||||
"--skip-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. ",
|
||||
help="Do not export the associated live video component of a live photo.",
|
||||
)
|
||||
@click.option(
|
||||
"--original-name",
|
||||
"--skip-raw",
|
||||
is_flag=True,
|
||||
help="Use photo's original filename instead of current filename for export.",
|
||||
help="Do not export associated RAW images of a RAW/jpeg pair. "
|
||||
"Note: this does not skip RAW photos if the RAW photo does not have an associated jpeg image "
|
||||
"(e.g. the RAW file was imported to Photos without a jpeg preview).",
|
||||
)
|
||||
@click.option(
|
||||
"--current-name",
|
||||
is_flag=True,
|
||||
help="Use photo's current filename instead of original filename for export. "
|
||||
"Note: Starting with Photos 5, all photos are renamed upon import. By default, "
|
||||
"photos are exported with the the original name they had before import.",
|
||||
)
|
||||
@click.option(
|
||||
"--sidecar",
|
||||
@@ -809,7 +921,7 @@ def query(
|
||||
"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. "
|
||||
"Note: --download-missing is not currently compatabile with --export-bursts; "
|
||||
"Note: --download-missing does not currently export all burst images; "
|
||||
"only the primary photo will be exported--associated burst images will be skipped.",
|
||||
)
|
||||
@click.option(
|
||||
@@ -826,6 +938,14 @@ def query(
|
||||
help="Optional template for specifying name of output directory in the form '{name,DEFAULT}'. "
|
||||
"See below for additional details on templating system.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-extended-attributes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Don't copy extended attributes when exporting. You only need this if exporting "
|
||||
"to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get "
|
||||
"an error while exporting.",
|
||||
)
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -838,6 +958,7 @@ def export(
|
||||
keyword,
|
||||
person,
|
||||
album,
|
||||
folder,
|
||||
uuid,
|
||||
title,
|
||||
no_title,
|
||||
@@ -858,10 +979,11 @@ def export(
|
||||
verbose,
|
||||
overwrite,
|
||||
export_by_date,
|
||||
export_edited,
|
||||
export_bursts,
|
||||
export_live,
|
||||
original_name,
|
||||
skip_edited,
|
||||
skip_bursts,
|
||||
skip_live,
|
||||
skip_raw,
|
||||
current_name,
|
||||
sidecar,
|
||||
only_photos,
|
||||
only_movies,
|
||||
@@ -886,7 +1008,11 @@ def export(
|
||||
not_selfie,
|
||||
panorama,
|
||||
not_panorama,
|
||||
has_raw,
|
||||
directory,
|
||||
place,
|
||||
no_place,
|
||||
no_extended_attributes,
|
||||
):
|
||||
""" Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -894,6 +1020,10 @@ def export(
|
||||
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.
|
||||
By default, all versions of all photos will be exported including edited
|
||||
versions, live photo movies, burst photos, and associated RAW images.
|
||||
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options
|
||||
to modify this behavior.
|
||||
"""
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
@@ -916,11 +1046,23 @@ def export(
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
(export_by_date, directory),
|
||||
(any(place), no_place),
|
||||
]
|
||||
if any([all(bb) for bb in exclusive]):
|
||||
click.echo(cli.commands["export"].get_help(ctx), err=True)
|
||||
return
|
||||
|
||||
# initialize export flags
|
||||
# by default, will export all versions of photos unless skip flag is set
|
||||
(export_edited, export_bursts, export_live, export_raw) = [
|
||||
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
|
||||
]
|
||||
|
||||
# though the command line option is current_name, internally all processing
|
||||
# logic uses original_name which is the boolean inverse of current_name
|
||||
# because the original code used --original-name as an option
|
||||
original_name = not current_name
|
||||
|
||||
# verify exiftool installed an in path
|
||||
if exiftool:
|
||||
try:
|
||||
@@ -953,6 +1095,7 @@ def export(
|
||||
keyword=keyword,
|
||||
person=person,
|
||||
album=album,
|
||||
folder=folder,
|
||||
uuid=uuid,
|
||||
title=title,
|
||||
no_title=no_title,
|
||||
@@ -996,6 +1139,9 @@ def export(
|
||||
not_selfie=not_selfie,
|
||||
panorama=panorama,
|
||||
not_panorama=not_panorama,
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
)
|
||||
|
||||
if photos:
|
||||
@@ -1026,10 +1172,12 @@ def export(
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
no_extended_attributes,
|
||||
export_raw,
|
||||
)
|
||||
else:
|
||||
for p in photos:
|
||||
export_path = export_photo(
|
||||
export_paths = export_photo(
|
||||
p,
|
||||
dest,
|
||||
verbose,
|
||||
@@ -1042,9 +1190,11 @@ def export(
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
no_extended_attributes,
|
||||
export_raw,
|
||||
)
|
||||
if export_path:
|
||||
click.echo(f"Exported {p.filename} to {export_path}")
|
||||
if export_paths:
|
||||
click.echo(f"Exported {p.filename} to {export_paths}")
|
||||
else:
|
||||
click.echo(f"Did not export missing file {p.filename}")
|
||||
else:
|
||||
@@ -1058,9 +1208,12 @@ def help(ctx, topic, **kw):
|
||||
""" Print help; for help on commands: help <command>. """
|
||||
if topic is None:
|
||||
click.echo(ctx.parent.get_help())
|
||||
else:
|
||||
elif topic in cli.commands:
|
||||
ctx.info_name = topic
|
||||
click.echo(cli.commands[topic].get_help(ctx))
|
||||
click.echo_via_pager(cli.commands[topic].get_help(ctx))
|
||||
else:
|
||||
click.echo(f"Invalid command: {topic}", err=True)
|
||||
click.echo(ctx.parent.get_help())
|
||||
|
||||
|
||||
def print_photo_info(photos, json=False):
|
||||
@@ -1113,6 +1266,9 @@ def print_photo_info(photos, json=False):
|
||||
"hdr",
|
||||
"selfie",
|
||||
"panorama",
|
||||
"has_raw",
|
||||
"uti_raw",
|
||||
"path_raw",
|
||||
]
|
||||
)
|
||||
for p in photos:
|
||||
@@ -1154,6 +1310,9 @@ def print_photo_info(photos, json=False):
|
||||
p.hdr,
|
||||
p.selfie,
|
||||
p.panorama,
|
||||
p.has_raw,
|
||||
p.uti_raw,
|
||||
p.path_raw,
|
||||
]
|
||||
)
|
||||
for row in dump:
|
||||
@@ -1165,6 +1324,7 @@ def _query(
|
||||
keyword=None,
|
||||
person=None,
|
||||
album=None,
|
||||
folder=None,
|
||||
uuid=None,
|
||||
title=None,
|
||||
no_title=None,
|
||||
@@ -1208,11 +1368,14 @@ def _query(
|
||||
not_selfie=None,
|
||||
panorama=None,
|
||||
not_panorama=None,
|
||||
has_raw=None,
|
||||
place=None,
|
||||
no_place=None,
|
||||
):
|
||||
""" 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 """
|
||||
""" 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 """
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
photos = photosdb.photos(
|
||||
@@ -1226,6 +1389,21 @@ def _query(
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
if folder:
|
||||
# search for photos in an album in folder
|
||||
# finds photos that have albums whose top level folder matches folder
|
||||
photo_list = []
|
||||
for f in folder:
|
||||
photo_list.extend(
|
||||
[
|
||||
p
|
||||
for p in photos
|
||||
if p.album_info
|
||||
and f in [a.folder_names[0] for a in p.album_info if a.folder_names]
|
||||
]
|
||||
)
|
||||
photos = photo_list
|
||||
|
||||
if title:
|
||||
# search title field for text
|
||||
# if more than one, find photos with all title values in title
|
||||
@@ -1242,7 +1420,7 @@ def _query(
|
||||
|
||||
if description:
|
||||
# search description field for text
|
||||
# if more than one, find photos with all name values in description
|
||||
# if more than one, find photos with all description values in description
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for d in description:
|
||||
@@ -1256,6 +1434,40 @@ def _query(
|
||||
elif no_description:
|
||||
photos = [p for p in photos if not p.description]
|
||||
|
||||
if place:
|
||||
# search place.names for text matching place
|
||||
# if more than one place, find photos with all place values in description
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for place_name in place:
|
||||
place_name = place_name.lower()
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if p.place
|
||||
and any(
|
||||
pname
|
||||
for pname in p.place.names
|
||||
if any(
|
||||
pvalue for pvalue in pname if place_name in pvalue.lower()
|
||||
)
|
||||
)
|
||||
]
|
||||
else:
|
||||
for place_name in place:
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if p.place
|
||||
and any(
|
||||
pname
|
||||
for pname in p.place.names
|
||||
if any(pvalue for pvalue in pname if place_name in pvalue)
|
||||
)
|
||||
]
|
||||
elif no_place:
|
||||
photos = [p for p in photos if not p.place]
|
||||
|
||||
if edited:
|
||||
photos = [p for p in photos if p.hasadjustments]
|
||||
|
||||
@@ -1345,6 +1557,9 @@ def _query(
|
||||
elif not_incloud:
|
||||
photos = [p for p in photos if not p.incloud]
|
||||
|
||||
if has_raw:
|
||||
photos = [p for p in photos if p.has_raw]
|
||||
|
||||
return photos
|
||||
|
||||
|
||||
@@ -1361,6 +1576,8 @@ def export_photo(
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
no_extended_attributes,
|
||||
export_raw,
|
||||
):
|
||||
""" Helper function for export that does the actual export
|
||||
photo: PhotoInfo object
|
||||
@@ -1375,9 +1592,15 @@ def export_photo(
|
||||
download_missing: attempt download of missing iCloud photos
|
||||
exiftool: use exiftool to write EXIF metadata directly to exported photo
|
||||
directory: template used to determine output directory
|
||||
returns destination path of exported photo or None if photo was missing
|
||||
no_extended_attributes: boolean; if True, exports photo without preserving extended attributes
|
||||
export_raw: boolean; if True exports RAW image associate with the photo
|
||||
returns list of path(s) of exported photo or None if photo was missing
|
||||
"""
|
||||
|
||||
# Can export to multiple paths
|
||||
# Start with single path [dest] but direcotry and export_by_date will modify dest_paths
|
||||
dest_paths = [dest]
|
||||
|
||||
if not download_missing:
|
||||
if photo.ismissing:
|
||||
space = " " if not verbose else ""
|
||||
@@ -1407,19 +1630,25 @@ def export_photo(
|
||||
|
||||
if export_by_date:
|
||||
date_created = photo.date.timetuple()
|
||||
dest = create_path_by_date(dest, date_created)
|
||||
dest_path = create_path_by_date(dest, date_created)
|
||||
dest_paths = [dest_path]
|
||||
elif directory:
|
||||
dirname, unmatched = render_filepath_template(directory, photo)
|
||||
# got a directory template, render it and check results are valid
|
||||
dirnames, unmatched = render_filepath_template(directory, photo)
|
||||
if unmatched:
|
||||
click.echo(
|
||||
f"Possible unmatched substitution in template: {unmatched}", err=True
|
||||
raise click.BadOptionUsage(
|
||||
"directory",
|
||||
f"Invalid substitution in template '{directory}': {unmatched}",
|
||||
)
|
||||
dirname = sanitize_filepath(dirname, platform="auto")
|
||||
if not is_valid_filepath(dirname, platform="auto"):
|
||||
raise ValueError(f"Invalid file path: {dirname}")
|
||||
dest = os.path.join(dest, dirname)
|
||||
if not os.path.isdir(dest):
|
||||
os.makedirs(dest)
|
||||
dest_paths = []
|
||||
for dirname in dirnames:
|
||||
dirname = sanitize_filepath(dirname, platform="auto")
|
||||
if not is_valid_filepath(dirname, platform="auto"):
|
||||
raise ValueError(f"Invalid file path: {dirname}")
|
||||
dest_path = os.path.join(dest, dirname)
|
||||
if not os.path.isdir(dest_path):
|
||||
os.makedirs(dest_path)
|
||||
dest_paths.append(dest_path)
|
||||
|
||||
sidecar = [s.lower() for s in sidecar]
|
||||
sidecar_json = sidecar_xmp = False
|
||||
@@ -1433,49 +1662,59 @@ def export_photo(
|
||||
use_photos_export = download_missing and (
|
||||
photo.ismissing or not os.path.exists(photo.path)
|
||||
)
|
||||
photo_path = photo.export(
|
||||
dest,
|
||||
filename,
|
||||
sidecar_json=sidecar_json,
|
||||
sidecar_xmp=sidecar_xmp,
|
||||
live_photo=export_live,
|
||||
overwrite=overwrite,
|
||||
use_photos_export=use_photos_export,
|
||||
exiftool=exiftool,
|
||||
)[0]
|
||||
|
||||
# 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:
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
use_photos_export = download_missing and photo.path_edited is None
|
||||
if not download_missing and photo.path_edited is None:
|
||||
click.echo(f"Skipping missing edited photo for {filename}")
|
||||
else:
|
||||
edited_name = pathlib.Path(filename)
|
||||
# check for correct edited suffix
|
||||
if photo.path_edited is not None:
|
||||
edited_suffix = pathlib.Path(photo.path_edited).suffix
|
||||
# export the photo to each path in dest_paths
|
||||
photo_paths = []
|
||||
for dest_path in dest_paths:
|
||||
photo_path = photo.export(
|
||||
dest_path,
|
||||
filename,
|
||||
sidecar_json=sidecar_json,
|
||||
sidecar_xmp=sidecar_xmp,
|
||||
live_photo=export_live,
|
||||
raw_photo=export_raw,
|
||||
overwrite=overwrite,
|
||||
use_photos_export=use_photos_export,
|
||||
exiftool=exiftool,
|
||||
no_xattr=no_extended_attributes,
|
||||
)[0]
|
||||
photo_paths.append(photo_path)
|
||||
|
||||
# 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:
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
use_photos_export = download_missing and photo.path_edited is None
|
||||
if not download_missing and photo.path_edited is None:
|
||||
click.echo(f"Skipping missing edited photo for {filename}")
|
||||
else:
|
||||
# use filename suffix which might be wrong,
|
||||
# will be corrected by use_photos_export
|
||||
edited_suffix = pathlib.Path(photo.filename).suffix
|
||||
edited_name = f"{edited_name.stem}_edited{edited_suffix}"
|
||||
if verbose:
|
||||
click.echo(f"Exporting edited version of {filename} as {edited_name}")
|
||||
photo.export(
|
||||
dest,
|
||||
edited_name,
|
||||
sidecar_json=sidecar_json,
|
||||
sidecar_xmp=sidecar_xmp,
|
||||
overwrite=overwrite,
|
||||
edited=True,
|
||||
use_photos_export=use_photos_export,
|
||||
exiftool=exiftool,
|
||||
)
|
||||
edited_name = pathlib.Path(filename)
|
||||
# check for correct edited suffix
|
||||
if photo.path_edited is not None:
|
||||
edited_suffix = pathlib.Path(photo.path_edited).suffix
|
||||
else:
|
||||
# use filename suffix which might be wrong,
|
||||
# will be corrected by use_photos_export
|
||||
edited_suffix = pathlib.Path(photo.filename).suffix
|
||||
edited_name = f"{edited_name.stem}_edited{edited_suffix}"
|
||||
if verbose:
|
||||
click.echo(
|
||||
f"Exporting edited version of {filename} as {edited_name}"
|
||||
)
|
||||
photo.export(
|
||||
dest_path,
|
||||
edited_name,
|
||||
sidecar_json=sidecar_json,
|
||||
sidecar_xmp=sidecar_xmp,
|
||||
overwrite=overwrite,
|
||||
edited=True,
|
||||
use_photos_export=use_photos_export,
|
||||
exiftool=exiftool,
|
||||
no_xattr=no_extended_attributes,
|
||||
)
|
||||
|
||||
return photo_path
|
||||
return photo_paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -16,8 +16,9 @@ _TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
# only version 3 - 4 have RKVersion.selfPortrait
|
||||
_PHOTOS_3_VERSION = "3301"
|
||||
|
||||
# versions later than this have a different database structure
|
||||
_PHOTOS_5_VERSION = "6000"
|
||||
# versions 5.0 and later have a different database structure
|
||||
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
|
||||
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.4
|
||||
|
||||
# which major version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
|
||||
@@ -25,6 +26,9 @@ _TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
_UNKNOWN_PERSON = "_UNKNOWN_"
|
||||
|
||||
# photos with no reverse geolocation info (place)
|
||||
_UNKNOWN_PLACE = "_UNKNOWN_"
|
||||
|
||||
_EXIF_TOOL_URL = "https://exiftool.org/"
|
||||
|
||||
# Where are shared iCloud photos located?
|
||||
@@ -37,3 +41,13 @@ _MOVIE_TYPE = 1
|
||||
# Name of XMP template file
|
||||
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
||||
_XMP_TEMPLATE_NAME = "xmp_sidecar.mako"
|
||||
|
||||
# Constants used for processing folders and albums
|
||||
_PHOTOS_5_ALBUM_KIND = 2 # normal user album
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
|
||||
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
|
||||
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
|
||||
|
||||
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
|
||||
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.24.2"
|
||||
__version__ = "0.28.2"
|
||||
|
||||
211
osxphotos/albuminfo.py
Normal file
211
osxphotos/albuminfo.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
AlbumInfo and FolderInfo classes for dealing with albums and folders
|
||||
|
||||
AlbumInfo class
|
||||
Represents a single Album in the Photos library and provides access to the album's attributes
|
||||
PhotosDB.albums() returns a list of AlbumInfo objects
|
||||
|
||||
FolderInfo class
|
||||
Represents a single Folder in the Photos library and provides access to the folders attributes
|
||||
PhotosDB.folders() returns a list of FolderInfo objects
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ._constants import (
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUM,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_FOLDER_KIND,
|
||||
)
|
||||
|
||||
|
||||
class AlbumInfo:
|
||||
"""
|
||||
Info about a specific Album, contains all the details about the album
|
||||
including folders, photos, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, uuid=None):
|
||||
self._uuid = uuid
|
||||
self._db = db
|
||||
self._title = self._db._dbalbum_details[uuid]["title"]
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of album """
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of album """
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in album """
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
uuid = self._db._dbalbums_album[self._uuid]
|
||||
self._photos = self._db.photos(uuid=uuid)
|
||||
return self._photos
|
||||
|
||||
@property
|
||||
def folder_names(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
|
||||
try:
|
||||
return self._folder_names
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
|
||||
else:
|
||||
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
|
||||
return self._folder_names
|
||||
|
||||
@property
|
||||
def folder_list(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
as list of FolderInfo objects in form
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
|
||||
try:
|
||||
return self._folders
|
||||
except AttributeError:
|
||||
self._folders = self._db._album_folder_hierarchy_folderinfo(self._uuid)
|
||||
return self._folders
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=parent_uuid)
|
||||
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
|
||||
else None
|
||||
)
|
||||
else:
|
||||
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
|
||||
if parent_pk != self._db._folder_root_pk
|
||||
else None
|
||||
)
|
||||
return self._parent
|
||||
|
||||
def __len__(self):
|
||||
""" return number of photos contained in album """
|
||||
return len(self.photos)
|
||||
|
||||
|
||||
class FolderInfo:
|
||||
"""
|
||||
Info about a specific folder, contains all the details about the folder
|
||||
including folders, albums, etc
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, uuid=None):
|
||||
self._uuid = uuid
|
||||
self._db = db
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._pk = None
|
||||
self._title = self._db._dbfolder_details[uuid]["name"]
|
||||
else:
|
||||
self._pk = self._db._dbalbum_details[uuid]["pk"]
|
||||
self._title = self._db._dbalbum_details[uuid]["title"]
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of folder"""
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of folder """
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def album_info(self):
|
||||
""" return list of albums (as AlbumInfo objects) contained in the folder """
|
||||
try:
|
||||
return self._albums
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
albums = [
|
||||
AlbumInfo(db=self._db, uuid=album)
|
||||
for album, detail in self._db._dbalbum_details.items()
|
||||
if not detail["intrash"]
|
||||
and detail["albumSubclass"] == _PHOTOS_4_ALBUM_KIND
|
||||
and detail["folderUuid"] == self._uuid
|
||||
]
|
||||
else:
|
||||
albums = [
|
||||
AlbumInfo(db=self._db, uuid=album)
|
||||
for album, detail in self._db._dbalbum_details.items()
|
||||
if not detail["intrash"]
|
||||
and detail["kind"] == _PHOTOS_5_ALBUM_KIND
|
||||
and detail["parentfolder"] == self._pk
|
||||
]
|
||||
self._albums = albums
|
||||
return self._albums
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent or None if no parent (e.g. top-level folder) """
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=parent_uuid)
|
||||
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
|
||||
else None
|
||||
)
|
||||
else:
|
||||
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
|
||||
if parent_pk != self._db._folder_root_pk
|
||||
else None
|
||||
)
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def subfolders(self):
|
||||
""" return list of folders (as FolderInfo objects) contained in the folder """
|
||||
try:
|
||||
return self._folders
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
folders = [
|
||||
FolderInfo(db=self._db, uuid=folder)
|
||||
for folder, detail in self._db._dbfolder_details.items()
|
||||
if not detail["intrash"]
|
||||
and not detail["isMagic"]
|
||||
and detail["parentFolderUuid"] == self._uuid
|
||||
]
|
||||
else:
|
||||
folders = [
|
||||
FolderInfo(db=self._db, uuid=album)
|
||||
for album, detail in self._db._dbalbum_details.items()
|
||||
if not detail["intrash"]
|
||||
and detail["kind"] == _PHOTOS_5_FOLDER_KIND
|
||||
and detail["parentfolder"] == self._pk
|
||||
]
|
||||
self._folders = folders
|
||||
return self._folders
|
||||
|
||||
def __len__(self):
|
||||
""" returns count of folders + albums contained in the folder """
|
||||
return len(self.subfolders) + len(self.album_info)
|
||||
@@ -21,18 +21,21 @@ from mako.template import Template
|
||||
from ._constants import (
|
||||
_MOVIE_TYPE,
|
||||
_PHOTO_TYPE,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
_PHOTOS_5_VERSION,
|
||||
_TEMPLATE_DIR,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
)
|
||||
from .exiftool import ExifTool
|
||||
from .placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from .albuminfo import AlbumInfo
|
||||
from .utils import (
|
||||
_copy_file,
|
||||
_export_photo_uuid_applescript,
|
||||
_get_resource_loc,
|
||||
dd_to_dms_str,
|
||||
findfiles,
|
||||
get_preferred_uti_extension,
|
||||
)
|
||||
|
||||
|
||||
@@ -95,7 +98,7 @@ class PhotoInfo:
|
||||
if self._info["isMissing"] == 1:
|
||||
return photopath # path would be meaningless until downloaded
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
vol = self._info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
|
||||
@@ -134,7 +137,7 @@ class PhotoInfo:
|
||||
|
||||
photopath = None
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self._info["hasAdjustments"]:
|
||||
edit_id = self._info["edit_resource_id"]
|
||||
if edit_id is not None:
|
||||
@@ -238,6 +241,75 @@ class PhotoInfo:
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_raw(self):
|
||||
""" absolute path of associated RAW image or None if there is not one """
|
||||
|
||||
# In Photos 5, raw is in same folder as original but with _4.ext
|
||||
# Unless "Copy Items to the Photos Library" is not checked
|
||||
# then RAW image is not renamed but has same name is jpeg buth with raw extension
|
||||
# Current implementation uses findfiles to find images with the correct raw UTI extension
|
||||
# in same folder as the original and with same stem as original in form: original_stem*.raw_ext
|
||||
# TODO: I don't like this -- would prefer a more deterministic approach but until I have more
|
||||
# data on how Photos stores and retrieves RAW images, this seems to be working
|
||||
|
||||
if self._info["isMissing"] == 1:
|
||||
return None # path would be meaningless until downloaded
|
||||
|
||||
if not self.has_raw:
|
||||
return None # no raw image to get path for
|
||||
|
||||
# if self._info["shared"]:
|
||||
# # shared photo
|
||||
# photopath = os.path.join(
|
||||
# self._db._library_path,
|
||||
# _PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
# self._info["directory"],
|
||||
# self._info["filename"],
|
||||
# )
|
||||
# return photopath
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
vol = self._info["raw_info"]["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
filestem = pathlib.Path(self._info["filename"]).stem
|
||||
raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
|
||||
|
||||
if self._info["directory"].startswith("/"):
|
||||
filepath = self._info["directory"]
|
||||
else:
|
||||
filepath = os.path.join(self._db._masters_path, self._info["directory"])
|
||||
|
||||
glob_str = f"{filestem}*.{raw_ext}"
|
||||
raw_file = findfiles(glob_str, filepath)
|
||||
if len(raw_file) != 1:
|
||||
logging.warning(
|
||||
f"Error getting path to RAW file: {filepath}/{glob_str}"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
photopath = os.path.join(filepath, raw_file[0])
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
""" long / extended description of picture """
|
||||
@@ -253,7 +325,18 @@ class PhotoInfo:
|
||||
""" list of albums picture is contained in """
|
||||
albums = []
|
||||
for album in self._info["albums"]:
|
||||
albums.append(self._db._dbalbum_details[album]["title"])
|
||||
if not self._db._dbalbum_details[album]["intrash"]:
|
||||
albums.append(self._db._dbalbum_details[album]["title"])
|
||||
return albums
|
||||
|
||||
@property
|
||||
def album_info(self):
|
||||
""" list of AlbumInfo objects representing albums the photos is contained in """
|
||||
albums = []
|
||||
for album in self._info["albums"]:
|
||||
if not self._db._dbalbum_details[album]["intrash"]:
|
||||
albums.append(AlbumInfo(db=self._db, uuid=album))
|
||||
|
||||
return albums
|
||||
|
||||
@property
|
||||
@@ -317,7 +400,7 @@ class PhotoInfo:
|
||||
def shared(self):
|
||||
""" returns True if photos is in a shared iCloud album otherwise false
|
||||
Only valid on Photos 5; returns None on older versions """
|
||||
if self._db._db_version >= _PHOTOS_5_VERSION:
|
||||
if self._db._db_version > _PHOTOS_4_VERSION:
|
||||
return self._info["shared"]
|
||||
else:
|
||||
return None
|
||||
@@ -329,6 +412,14 @@ class PhotoInfo:
|
||||
"""
|
||||
return self._info["UTI"]
|
||||
|
||||
@property
|
||||
def uti_raw(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""
|
||||
return self._info["UTI_raw"]
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
""" Returns True if file is a movie, otherwise False
|
||||
@@ -354,7 +445,7 @@ class PhotoInfo:
|
||||
""" Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return (
|
||||
True
|
||||
if self._info["cloudLibraryState"] is not None
|
||||
@@ -397,7 +488,7 @@ class PhotoInfo:
|
||||
If photo is missing, returns None """
|
||||
|
||||
photopath = None
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self.live_photo and not self.ismissing:
|
||||
live_model_id = self._info["live_model_id"]
|
||||
if live_model_id == None:
|
||||
@@ -488,7 +579,7 @@ class PhotoInfo:
|
||||
# implementation note: doesn't create the PlaceInfo object until requested
|
||||
# then memoizes the object in self._place to avoid recreating the object
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
try:
|
||||
return self._place # pylint: disable=access-member-before-definition
|
||||
except AttributeError:
|
||||
@@ -509,12 +600,25 @@ class PhotoInfo:
|
||||
self._place = None
|
||||
return self._place
|
||||
|
||||
@property
|
||||
def has_raw(self):
|
||||
""" returns True if photo has an associated RAW image, otherwise False """
|
||||
return self._info["has_raw"]
|
||||
|
||||
@property
|
||||
def raw_original(self):
|
||||
""" returns True if associated RAW image and the RAW image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False """
|
||||
return True if self._info["original_resource_choice"] == 1 else False
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
*filename,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
raw_photo=False,
|
||||
overwrite=False,
|
||||
increment=True,
|
||||
sidecar_json=False,
|
||||
@@ -522,6 +626,7 @@ class PhotoInfo:
|
||||
use_photos_export=False,
|
||||
timeout=120,
|
||||
exiftool=False,
|
||||
no_xattr=False,
|
||||
):
|
||||
""" export photo
|
||||
dest: must be valid destination path (or exception raised)
|
||||
@@ -535,6 +640,7 @@ class PhotoInfo:
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
(or raise exception if no edited version)
|
||||
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
||||
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
|
||||
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
|
||||
@@ -545,6 +651,7 @@ class PhotoInfo:
|
||||
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
|
||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
||||
returns list of full paths to the exported files """
|
||||
|
||||
# list of all files exported during this call to export
|
||||
@@ -610,7 +717,10 @@ class PhotoInfo:
|
||||
# warn if suffixes don't match but ignore .JPG / .jpeg as
|
||||
# Photo's often converts .JPG to .jpeg
|
||||
suffixes = sorted([x.lower() for x in [dest.suffix, actual_suffix]])
|
||||
if dest.suffix != actual_suffix and suffixes != [".jpeg", ".jpg"]:
|
||||
if dest.suffix.lower() != actual_suffix.lower() and suffixes != [
|
||||
".jpeg",
|
||||
".jpg",
|
||||
]:
|
||||
logging.warning(
|
||||
f"Invalid destination suffix: {dest.suffix}, should be {actual_suffix}"
|
||||
)
|
||||
@@ -667,7 +777,7 @@ class PhotoInfo:
|
||||
)
|
||||
|
||||
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
|
||||
_copy_file(src, dest)
|
||||
_copy_file(src, dest, norsrc=no_xattr)
|
||||
exported_files.append(str(dest))
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
@@ -679,10 +789,24 @@ class PhotoInfo:
|
||||
logging.debug(
|
||||
f"Exporting live photo video of {filename} as {live_name.name}"
|
||||
)
|
||||
_copy_file(src_live, str(live_name))
|
||||
_copy_file(src_live, str(live_name), norsrc=no_xattr)
|
||||
exported_files.append(str(live_name))
|
||||
else:
|
||||
logging.warning(f"Skipping missing live movie for {filename}")
|
||||
|
||||
# copy associated RAW image if requested
|
||||
if raw_photo and self.has_raw:
|
||||
raw_path = pathlib.Path(self.path_raw)
|
||||
raw_ext = raw_path.suffix
|
||||
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
|
||||
if raw_path is not None:
|
||||
logging.debug(
|
||||
f"Exporting RAW photo of {filename} as {raw_name.name}"
|
||||
)
|
||||
_copy_file(str(raw_path), str(raw_name), norsrc=no_xattr)
|
||||
exported_files.append(str(raw_name))
|
||||
else:
|
||||
logging.warning(f"Skipping missing RAW photo for {filename}")
|
||||
else:
|
||||
# use_photo_export
|
||||
exported = None
|
||||
@@ -933,6 +1057,9 @@ class PhotoInfo:
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"has_raw": self.has_raw,
|
||||
"uti_raw": self.uti_raw,
|
||||
"path_raw": self.path_raw,
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
@@ -979,6 +1106,9 @@ class PhotoInfo:
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"has_raw": self.has_raw,
|
||||
"uti_raw": self.uti_raw,
|
||||
"path_raw": self.path_raw,
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,24 @@
|
||||
""" Custom template system for osxphotos """
|
||||
|
||||
# Rolled my own template system because:
|
||||
# 1. Needed to handle multiple values (e.g. album, keyword)
|
||||
# 2. Needed to handle default values if template not found
|
||||
# 3. Didn't want user to need to know python (e.g. by using Mako which is
|
||||
# already used elsewhere in this project)
|
||||
# 4. Couldn't figure out how to do #1 and #2 with str.format()
|
||||
#
|
||||
# This code isn't elegant but it seems to work well. PRs gladly accepted.
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Tuple # pylint: disable=syntax-error
|
||||
from typing import Tuple, List # pylint: disable=syntax-error
|
||||
|
||||
from .photoinfo import PhotoInfo
|
||||
from ._constants import _UNKNOWN_PERSON
|
||||
|
||||
# Permitted substitutions (each of these returns a single value or None)
|
||||
TEMPLATE_SUBSTITUTIONS = {
|
||||
"{name}": "Filename of the photo",
|
||||
"{original_name}": "Photo's original filename when imported to Photos",
|
||||
@@ -25,7 +39,7 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
|
||||
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
|
||||
"{place.country_code}": "The ISO country code from the photo's reverse geolocationo data",
|
||||
"{place.country_code}": "The ISO country code from the photo's reverse geolocation data",
|
||||
"{place.name.country}": "Country name from the photo's reverse geolocation data",
|
||||
"{place.name.state_province}": "State or province name from the photo's reverse geolocation data",
|
||||
"{place.name.city}": "City or locality name from the photo's reverse geolocation data",
|
||||
@@ -39,9 +53,24 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
|
||||
}
|
||||
|
||||
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
"{album}": "Album(s) photo is contained in",
|
||||
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
|
||||
"{keyword}": "Keyword(s) assigned to photo",
|
||||
"{person}": "Person(s) / face(s) in a photo",
|
||||
}
|
||||
|
||||
# Just the multi-valued substitution names without the braces
|
||||
MULTI_VALUE_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "")
|
||||
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
|
||||
]
|
||||
|
||||
|
||||
def get_template_value(lookup, photo):
|
||||
""" lookup: value to find a match for
|
||||
""" lookup template value (single-value template substitutions) for use in make_subst_function
|
||||
lookup: value to find a match for
|
||||
photo: PhotoInfo object whose data will be used for value substitutions
|
||||
returns: either the matching template value (which may be None)
|
||||
raises: KeyError if no rule exists for lookup """
|
||||
@@ -202,29 +231,24 @@ def get_template_value(lookup, photo):
|
||||
raise KeyError(f"No rule for processing {lookup}")
|
||||
|
||||
|
||||
def render_filepath_template(
|
||||
template: str, photo: PhotoInfo, none_str: str = "_"
|
||||
) -> Tuple[str, list]:
|
||||
""" render a filename or directory template """
|
||||
def render_filepath_template(template, photo, none_str="_"):
|
||||
""" render a filename or directory template
|
||||
template: str template
|
||||
photo: PhotoInfo object
|
||||
none_str: str to use default for None values, default is '_' """
|
||||
|
||||
# the rendering happens in two phases:
|
||||
# phase 1: handle all the single-value template substitutions
|
||||
# results in a single string with all the template fields replaced
|
||||
# phase 2: loop through all the multi-value template substitutions
|
||||
# could result in multiple strings
|
||||
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
|
||||
# there would be 6 possible renderings (2 albums x 3 persons)
|
||||
|
||||
# regex to find {template_field,optional_default} in strings
|
||||
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
regex = r"""(?<!\\)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)\}"""
|
||||
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
unmatched_regex = r"(?<!\\)(\{[^\\,}]+\})"
|
||||
|
||||
# Explanation for regex:
|
||||
# (?<!\\) Negative Lookbehind to skip escaped braces
|
||||
# assert regex following does not match "\" preceeding "{"
|
||||
# \{ Match the opening brace
|
||||
# 1st Capturing Group ([^\\,}]+) Don't match "\", ",", or "}"
|
||||
# 2nd Capturing Group (,?(([\w\-. ]+))?)
|
||||
# ,{0,1} optional ","
|
||||
# 3rd Capturing Group (([\w\-. ]+))?
|
||||
# Matches the comma and any word characters after
|
||||
# 4th Capturing Group ([\w\-. ]+)
|
||||
# Matches just the characters after the comma
|
||||
# \} Matches the closing brace
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}"
|
||||
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
@@ -232,14 +256,19 @@ def render_filepath_template(
|
||||
if type(photo) is not PhotoInfo:
|
||||
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
|
||||
|
||||
def make_subst_function(photo, none_str):
|
||||
""" returns: substitution function for use in re.sub """
|
||||
def make_subst_function(photo, none_str, get_func=get_template_value):
|
||||
""" returns: substitution function for use in re.sub
|
||||
photo: a PhotoInfo object
|
||||
none_str: value to use if substitution lookup is None and no default provided
|
||||
get_func: function that gets the substitution value for a given template field
|
||||
default is get_template_value which handles the single-value fields """
|
||||
|
||||
# closure to capture photo, none_str in subst
|
||||
def subst(matchobj):
|
||||
groups = len(matchobj.groups())
|
||||
if groups == 4:
|
||||
try:
|
||||
val = get_template_value(matchobj.group(1), photo)
|
||||
val = get_func(matchobj.group(1), photo)
|
||||
except KeyError:
|
||||
return matchobj.group(0)
|
||||
|
||||
@@ -261,14 +290,104 @@ def render_filepath_template(
|
||||
# do the replacements
|
||||
rendered = re.sub(regex, subst_func, template)
|
||||
|
||||
# find any {words} that weren't replaced
|
||||
unmatched = re.findall(unmatched_regex, rendered)
|
||||
# do multi-valued placements
|
||||
# start with the single string from phase 1 above then loop through all
|
||||
# multi-valued fields and all values for each of those fields
|
||||
# rendered_strings will be updated as each field is processed
|
||||
# for example: if two albums, two keywords, and one person and template is:
|
||||
# "{created.year}/{album}/{keyword}/{person}"
|
||||
# rendered strings would do the following:
|
||||
# start (created.year filled in phase 1)
|
||||
# ['2011/{album}/{keyword}/{person}']
|
||||
# after processing albums:
|
||||
# ['2011/Album1/{keyword}/{person}',
|
||||
# '2011/Album2/{keyword}/{person}',]
|
||||
# after processing keywords:
|
||||
# ['2011/Album1/keyword1/{person}',
|
||||
# '2011/Album1/keyword2/{person}',
|
||||
# '2011/Album2/keyword1/{person}',
|
||||
# '2011/Album2/keyword2/{person}',]
|
||||
# after processing person:
|
||||
# ['2011/Album1/keyword1/person1',
|
||||
# '2011/Album1/keyword2/person1',
|
||||
# '2011/Album2/keyword1/person1',
|
||||
# '2011/Album2/keyword2/person1',]
|
||||
|
||||
rendered_strings = set([rendered])
|
||||
for field in MULTI_VALUE_SUBSTITUTIONS:
|
||||
if field == "album":
|
||||
values = photo.albums
|
||||
elif field == "keyword":
|
||||
values = photo.keywords
|
||||
elif field == "person":
|
||||
values = photo.persons
|
||||
# remove any _UNKNOWN_PERSON values
|
||||
values = [val for val in values if val != _UNKNOWN_PERSON]
|
||||
elif field == "folder_album":
|
||||
values = []
|
||||
# photos must be in an album to be in a folder
|
||||
for album in photo.album_info:
|
||||
if album.folder_names:
|
||||
# album in folder
|
||||
folder = os.path.sep.join(album.folder_names)
|
||||
folder += os.path.sep + album.title
|
||||
values.append(folder)
|
||||
else:
|
||||
# album not in folder
|
||||
values.append(album.title)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unhandleded template value: {field}")
|
||||
|
||||
# If no values, insert None so code below will substite none_str for None
|
||||
values = values or [None]
|
||||
|
||||
# Build a regex that matches only the field being processed
|
||||
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
|
||||
regex_multi = re.compile(re_str)
|
||||
|
||||
# holds each of the new rendered_strings, set() to avoid duplicates
|
||||
new_strings = set()
|
||||
|
||||
for str_template in rendered_strings:
|
||||
for val in values:
|
||||
|
||||
def get_template_value_multi(lookup_value, photo):
|
||||
""" Closure passed to make_subst_function get_func
|
||||
Capture val and field in the closure
|
||||
Allows make_subst_function to be re-used w/o modification """
|
||||
if lookup_value == field:
|
||||
return val
|
||||
else:
|
||||
raise KeyError(f"Unexpected value: {lookup_value}")
|
||||
|
||||
subst = make_subst_function(
|
||||
photo, none_str, get_func=get_template_value_multi
|
||||
)
|
||||
new_string = regex_multi.sub(subst, str_template)
|
||||
new_strings.add(new_string)
|
||||
|
||||
# update rendered_strings for the next field to process
|
||||
rendered_strings = new_strings
|
||||
|
||||
# find any {fields} that weren't replaced
|
||||
unmatched = []
|
||||
for rendered_str in rendered_strings:
|
||||
unmatched.extend(
|
||||
[
|
||||
no_match[0]
|
||||
for no_match in re.findall(regex, rendered_str)
|
||||
if no_match[0] not in unmatched
|
||||
]
|
||||
)
|
||||
|
||||
# fix any escaped curly braces
|
||||
rendered = re.sub(r"\\{", "{", rendered)
|
||||
rendered = re.sub(r"\\}", "}", rendered)
|
||||
rendered_strings = [
|
||||
rendered_str.replace("{{", "{").replace("}}", "}")
|
||||
for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
return rendered, unmatched
|
||||
return rendered_strings, unmatched
|
||||
|
||||
|
||||
class DateTimeFormatter:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import fnmatch
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -11,6 +14,7 @@ import urllib.parse
|
||||
from plistlib import load as plistload
|
||||
|
||||
import CoreFoundation
|
||||
import CoreServices
|
||||
import objc
|
||||
from Foundation import *
|
||||
|
||||
@@ -114,10 +118,14 @@ def _dd_to_dms(dd):
|
||||
return int(deg_), int(min_), sec_
|
||||
|
||||
|
||||
def _copy_file(src, dest):
|
||||
def _copy_file(src, dest, norsrc=False):
|
||||
""" Copies a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
|
||||
resource fork or extended attributes. May be useful on volumes that
|
||||
don't work with extended attributes (likely only certain SMB mounts)
|
||||
default is False
|
||||
Uses ditto to perform copy; will silently overwrite dest if it exists
|
||||
Raises exception if copy fails or either path is None """
|
||||
|
||||
@@ -125,19 +133,24 @@ def _copy_file(src, dest):
|
||||
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)
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
if norsrc:
|
||||
command = ["/usr/bin/ditto", "--norsrc", src, dest]
|
||||
else:
|
||||
command = ["/usr/bin/ditto", src, dest]
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
subprocess.run(
|
||||
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
|
||||
)
|
||||
result = subprocess.run(command, 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
|
||||
|
||||
return result.returncode
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
@@ -293,6 +306,28 @@ def create_path_by_date(dest, dt):
|
||||
return new_dest
|
||||
|
||||
|
||||
def get_preferred_uti_extension(uti):
|
||||
""" get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str """
|
||||
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
|
||||
ext = CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
uti, CoreServices.kUTTagClassFilenameExtension
|
||||
)
|
||||
return ext
|
||||
|
||||
|
||||
def findfiles(pattern, path_):
|
||||
"""Returns list of filenames from path_ matched by pattern
|
||||
shell pattern. Matching is case-insensitive."""
|
||||
# See: https://gist.github.com/techtonik/5694830
|
||||
|
||||
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
|
||||
return [name for name in os.listdir(path_) if rule.match(name)]
|
||||
|
||||
|
||||
# TODO: this doesn't always work, still looking for a way to
|
||||
# force Photos to open the library being operated on
|
||||
# def _open_photos_library_applescript(library_path):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2019-12-08T16:44:38Z</date>
|
||||
<date>2020-04-17T18:39:50Z</date>
|
||||
</dict>
|
||||
<key>PXPeopleScreenUnlocked</key>
|
||||
<true/>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-01-22T02:10:26Z</date>
|
||||
<date>2020-04-17T18:40:46Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-01-22T02:10:27Z</date>
|
||||
<date>2020-04-17T18:39:51Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-01-19T17:29:28Z</date>
|
||||
<date>2020-04-17T18:39:52Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>414</integer>
|
||||
<integer>502</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>414</integer>
|
||||
<integer>502</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,12 +9,14 @@
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>QtSnVvTkQ%i2z3hB834M1A</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
<string>N7eQ4VhfTfeHFp9PPHaJDw</string>
|
||||
</array>
|
||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||
<dict>
|
||||
<key>kZoomLevelIdentifierAlbums</key>
|
||||
<integer>10</integer>
|
||||
<integer>5</integer>
|
||||
<key>kZoomLevelIdentifierVersions</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
@@ -23,11 +25,11 @@
|
||||
<key>key</key>
|
||||
<integer>1</integer>
|
||||
<key>lastKnownDisplayName</key>
|
||||
<string>Test Album (1)</string>
|
||||
<string>Pumpkin Farm (1)</string>
|
||||
<key>type</key>
|
||||
<string>album</string>
|
||||
<key>uuid</key>
|
||||
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
|
||||
<string>xJ8ya3NBRWC24gKhcwwNeQ</string>
|
||||
</dict>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-03-27T04:00:09Z</date>
|
||||
<date>2020-04-18T18:01:02Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-03-27T04:00:10Z</date>
|
||||
<date>2020-04-18T17:22:55Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2020-03-15T20:19:24Z</date>
|
||||
<date>2020-04-17T17:51:16Z</date>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-03-27T03:59:54Z</date>
|
||||
<date>2020-04-17T17:49:52Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>575</integer>
|
||||
<integer>606</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>575</integer>
|
||||
<integer>606</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
@@ -24,7 +24,7 @@
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2020-03-27T04:02:59Z</date>
|
||||
<date>2020-04-17T17:51:16Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>436</integer>
|
||||
<integer>900</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,24 +3,24 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2020-03-28T16:32:26Z</date>
|
||||
<date>2020-04-17T14:33:32Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-03-28T16:32:25Z</date>
|
||||
<date>2020-04-17T14:33:32Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-03-28T16:32:26Z</date>
|
||||
<date>2020-04-17T14:33:33Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-03-28T16:32:26Z</date>
|
||||
<date>2020-04-17T14:33:33Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-03-28T16:32:25Z</date>
|
||||
<date>2020-04-17T14:33:31Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-03-28T07:30:06Z</date>
|
||||
<date>2020-04-17T07:32:04Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-03-28T16:35:58Z</date>
|
||||
<date>2020-04-17T14:33:37Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-03-28T07:30:06Z</date>
|
||||
<date>2020-04-17T07:32:00Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-03-28T16:32:26Z</date>
|
||||
<date>2020-04-17T14:33:34Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-03-28T07:30:06Z</date>
|
||||
<date>2020-04-17T07:32:04Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FaceIDModelLastGenerationKey</key>
|
||||
<date>2020-03-28T07:30:06Z</date>
|
||||
<date>2020-04-17T07:32:07Z</date>
|
||||
<key>LastContactClassificationKey</key>
|
||||
<date>2020-03-28T07:30:08Z</date>
|
||||
<date>2020-04-17T07:32:12Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>coalesceDate</key>
|
||||
<date>2019-12-08T18:06:37Z</date>
|
||||
<date>2020-04-11T16:34:16Z</date>
|
||||
<key>coalescePayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>currentPayloadVersion</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>LibrarySchemaVersion</key>
|
||||
<integer>5001</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite
Normal file
BIN
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite
Normal file
Binary file not shown.
BIN
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite-shm
Normal file
BIN
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite-shm
Normal file
Binary file not shown.
BIN
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite-wal
Normal file
BIN
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite-wal
Normal file
Binary file not shown.
16
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite.lock
Normal file
16
tests/Test-10.15.4.photoslibrary/database/Photos.sqlite.lock
Normal 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>hostname</key>
|
||||
<string>Rhets-MacBook-Pro.local</string>
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>3212</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
<integer>501</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.4.photoslibrary/database/metaSchema.db
Normal file
BIN
tests/Test-10.15.4.photoslibrary/database/metaSchema.db
Normal file
Binary file not shown.
BIN
tests/Test-10.15.4.photoslibrary/database/photos.db
Normal file
BIN
tests/Test-10.15.4.photoslibrary/database/photos.db
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user