Compare commits

...

67 Commits

Author SHA1 Message Date
Rhet Turnbull
3bac106eb7 test library update 2020-04-18 12:24:03 -07:00
Rhet Turnbull
47d1c82c03 Added folder support for Photos <= 4, closes #93 2020-04-18 12:21:08 -07:00
Rhet Turnbull
6f281711e2 cleaned up SQL statements in _process_database4 2020-04-18 08:05:43 -07:00
Rhet Turnbull
4b30b3b426 Fixed suffix check on export to be case insensitive 2020-04-18 07:59:04 -07:00
Rhet Turnbull
1fa9583ea6 Updated CHANGELOG.md 2020-04-17 23:33:17 -07:00
Rhet Turnbull
235e1fb1a6 Updated README.md 2020-04-17 23:23:57 -07:00
Rhet Turnbull
36c2821a0f replaced CLI option --original-name with --current-name 2020-04-17 23:20:23 -07:00
Rhet Turnbull
ed425724a0 Changed default CLI behavior to export all photos 2020-04-17 22:52:11 -07:00
Rhet Turnbull
55daa31c71 Update README.md 2020-04-17 12:46:56 -07:00
Rhet Turnbull
b6ac9e1ea3 Updated test library for Sierra 2020-04-17 11:52:55 -07:00
Rhet Turnbull
9d151478d6 Initial support for RAW photos in Photos 4 to address issue #101 2020-04-17 10:44:15 -07:00
Rhet Turnbull
7d55844390 Added --export-raw to CLI export 2020-04-16 23:10:33 -07:00
Rhet Turnbull
f398e9116f Added --has-raw to CLI query and export 2020-04-16 16:46:29 -07:00
Rhet Turnbull
4fe8190b57 Added raw details to PhotoInfo json() and __str__() 2020-04-16 16:11:47 -07:00
Rhet Turnbull
7e42ebb240 Initial work on suppport for associated RAW images 2020-04-16 11:53:48 -07:00
Rhet Turnbull
edae116baa Test library update 2020-04-16 11:53:05 -07:00
Rhet Turnbull
d542cda17d Small fix to database version logic to look into issue #102 2020-04-15 14:09:47 -07:00
Rhet Turnbull
99b5b54c6d Update README.md 2020-04-15 11:38:57 -07:00
Rhet Turnbull
379feddcda Updated README.md to add Known Bugs section 2020-04-15 11:37:38 -07:00
Rhet Turnbull
24285f5dd2 Update README.md 2020-04-13 00:53:50 -07:00
Rhet Turnbull
3cb3ebb300 Updated CHANGELOG.md 2020-04-13 00:46:08 -07:00
Rhet Turnbull
16037f10fa Update README.md 2020-04-12 23:50:04 -07:00
Rhet Turnbull
ebd21491ac Updated examples to work with latest version 2020-04-12 20:57:37 -07:00
Rhet Turnbull
b7c7b9f066 Added {folder_album} to template and --folder to CLI 2020-04-12 14:53:53 -07:00
Rhet Turnbull
21e7020fec Test library update 2020-04-12 14:52:35 -07:00
Rhet Turnbull
952741d488 Updated CHANGELOG.md 2020-04-12 12:27:49 -07:00
Rhet Turnbull
9fef12ed37 Fixed bug with handling of deleted albums 2020-04-12 12:15:38 -07:00
Rhet Turnbull
97362fc0f1 Added additional tests for album_info 2020-04-12 09:22:14 -07:00
Rhet Turnbull
e09f0b40f1 Changed AlbumInfo and FolderInfo interface to maintain backwards compatibility with PhotosDB.albums 2020-04-12 09:01:16 -07:00
Rhet Turnbull
b749681c6d Updated CHANGELOG.md 2020-04-11 14:33:28 -07:00
Rhet Turnbull
8544667c72 Update README.md TOC 2020-04-11 14:20:05 -07:00
Rhet Turnbull
d6a22b765a Added tests and README for AlbumInfo and FolderInfo 2020-04-11 14:07:39 -07:00
Rhet Turnbull
96365728c2 Added albuminfo.py for AlbumInfo and FolderInfo classes 2020-04-11 10:28:50 -07:00
Rhet Turnbull
c01f713f00 Merge pull request #95 from jystervinou/patch-2
Update README.md
2020-04-11 06:53:58 -07:00
Jean-Yves Stervinou
1aa3838c38 Update README.md
just some typos fixes
- packge/package
- the copy then read can take => the copy read then can take
2020-04-11 11:34:17 +02:00
Rhet Turnbull
cde56e9d13 Updated CHANGELOG.md 2020-04-10 18:56:22 -07:00
Rhet Turnbull
1c9da5ed6f Bug fix for PhotosDB.photos() query 2020-04-10 18:50:58 -07:00
Rhet Turnbull
d74f7f499b Updated test library 2020-04-10 17:58:45 -07:00
Rhet Turnbull
c85bb02304 Updated CHANGELOG.md 2020-04-10 17:35:23 -07:00
Rhet Turnbull
3e5062684a Changed PhotosDB albums interface as prep for adding folders 2020-04-10 17:30:37 -07:00
Rhet Turnbull
626e460aab Update README.md 2020-04-06 07:24:20 -07:00
Rhet Turnbull
1820715849 Added test for 10.15.4 2020-04-05 22:57:05 -07:00
Rhet Turnbull
a6ca3f453c Updated CHANGELOG.md 2020-04-05 09:17:27 -07:00
Rhet Turnbull
ddaa66d19e Added --no-extended-attributes option to CLI, closes #85 2020-04-05 09:13:52 -07:00
Rhet Turnbull
6073acc9d3 Fixed CLI help for invalid topic, closes #76 2020-04-05 08:25:30 -07:00
Rhet Turnbull
bae0283441 Updated test library 2020-04-05 07:54:26 -07:00
Rhet Turnbull
507c4a3740 Added {album}, {keyword}, and {person} to template system 2020-04-04 13:58:54 -07:00
Rhet Turnbull
6a898886dd Updated render_filepath_template to support multiple values 2020-04-04 09:53:23 -07:00
Rhet Turnbull
01cd7fed6d Updated export example 2020-04-01 06:47:51 -07:00
Rhet Turnbull
e8273c9752 Added places, --place, --no-place to CLI, closes #87, #88 2020-03-31 20:39:13 -07:00
Rhet Turnbull
fd5e748dca Added places command to CLI 2020-03-29 23:03:12 -07:00
Rhet Turnbull
c02953ef5f Fixed typo in help text 2020-03-28 10:26:10 -07:00
Rhet Turnbull
daea30f162 Updated CHANGELOG.md 2020-03-28 10:21:10 -07:00
Rhet Turnbull
be2e16769d added {place.country_code} to template system 2020-03-28 10:18:58 -07:00
Rhet Turnbull
b0456dc8e6 Update TOC 2020-03-28 10:02:08 -07:00
Rhet Turnbull
c8bd8ea2f3 Test library update 2020-03-28 09:59:05 -07:00
Rhet Turnbull
67a9a9e21b Template system now supports default values 2020-03-28 09:57:48 -07:00
Rhet Turnbull
427c4c0bc4 Replaced template renderer with regex-based renderer 2020-03-28 07:58:50 -07:00
Rhet Turnbull
f0d200435a Fixed comment 2020-03-28 07:58:03 -07:00
Rhet Turnbull
49de3ecd2e test library updates 2020-03-28 07:24:45 -07:00
Rhet Turnbull
c06dd4233f Added detailed place data in PlaceInfo.names 2020-03-28 07:24:17 -07:00
Rhet Turnbull
fd638427d0 added missing import 2020-03-27 20:49:38 -07:00
Rhet Turnbull
6fb8fe8142 test library update 2020-03-25 17:56:59 -07:00
Rhet Turnbull
69cc6ce680 Updated place name processing for Photos 4 2020-03-25 17:56:39 -07:00
Rhet Turnbull
dfc31ff15f Type fix in help text 2020-03-23 19:24:44 -07:00
Rhet Turnbull
707544752e Removed template functions pending re-work of that code 2020-03-23 17:55:33 -07:00
Rhet Turnbull
564a5073f1 Updated README.md to document template system 2020-03-22 14:15:08 -07:00
342 changed files with 5956 additions and 1209 deletions

View File

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

626
README.md
View File

@@ -13,11 +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)
* [Examples](#examples)
* [Related Projects](#related-projects)
* [Contributing](#contributing)
* [Known Bugs](#known-bugs)
* [Implementation Notes](#implementation-notes)
* [Dependencies](#dependencies)
* [Acknowledgements](#acknowledgements)
@@ -56,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.
@@ -70,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...
```
@@ -84,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
@@ -96,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.
@@ -151,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
@@ -163,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
@@ -172,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
@@ -204,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,
@@ -214,89 +244,132 @@ Options:
exiftool may be installed from
https://exiftool.org/
--directory DIRECTORY Optional template for specifying name of
output directory. See below for additional
details on templating system
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**
With the --directory option, you may specify a template for the export
directory. This directory will be appended to the export path specified in
directory. This directory will be appended to the export path specified in
the export DEST argument to export. For example, if template is
'{created.year}/{created.month}', and export desitnation DEST is
'/Users/maria/Pictures/export', the actual export directory for a photo would
be '/Users/maria/Pictures/export/2020/March' if the photo was created in
March 2020.
'/Users/maria/Pictures/export', the actual export directory for a photo would
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
2020.
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
In the current implementation, substitutions which have no value will be
replaced by '_', for example, your template looked like
'{created.year}/{place.address}' but there was no address associated with the
photo, the resulting output would be: '2020/_/photoname.jpg'
You may specify an optional default value to use if the substitution does not
contain a value (e.g. the value is null) by specifying the default value after
a ',' in the template string: for example, if template is
'{created.year}/{place.address,'NO_ADDRESS'}' but there was no address
associated with the photo, the resulting output would be:
'2020/NO_ADDRESS/photoname.jpg'. If specified, the default value may not
contain a brace symbol ('{' or '}').
I plan to add the option to specify the value to be used for missing
subsitutions in a future version. I also plan to extend the templating system
to the exported filename so you can specify the filename using a template.
If you do not specify a default value and the template substitution has no
value, '_' (underscore) will be used as the default value. For example, in the
above example, this would result in '2020/_/photoname.jpg' if address was null
I plan to eventually extend the templating system to the exported filename so
you can specify the filename using a template.
Substitution Description
{name} Filename of the photo
{original_name} Photo's original filename when imported to Photos
{title} Title of the photo
{descr} Description of the photo
{created.date} Photo's creation date in ISO format, e.g. '2020-03-22'
{created.year} 4-digit year of file creation time
{created.yy} 2-digit year of file creation time
{created.mm} 2-digit month of the file creation time (zero padded)
{created.month} Month name in user's locale of the file creation time
{created.mon} Month abbreviation in the user's locale of the file
creation time
{created.doy} 3-digit day of year (e.g Julian day) of file creation
time, starting from 1 (zero padded)
{modified.date} Photo's modification date in ISO format, e.g.
'2020-03-22'
{modified.year} 4-digit year of file modification time
{modified.yy} 2-digit year of file modification time
{modified.mm} 2-digit month of the file modification time (zero
padded)
{modified.month} Month name in user's locale of the file modification
time
{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
{place.names} list of place names from the photo's reverse
geolocation data, joined with '_', for example, '18th
St NW_Washington_DC_United States'
{place.address} Postal address from the photo's reverse geolocation
data, e.g. '2007 18th St NW, Washington, DC 20009,
United States'
{place.street} Street part of the postal address, e.g. '2007 18th St
NW'
{place.city} City part of the postal address, e.g. 'Washington'
{place.state} State part of the postal address, e.g. 'DC'
{place.postal_code} Postal code part of the postal address, e.g. '20009'
{place.country} Country name of the postal code, e.g. 'United States'
{place.country_code} ISO country code of the postal address, e.g. 'US'
Substitution Description
{name} Filename of the photo
{original_name} Photo's original filename when imported to
Photos
{title} Title of the photo
{descr} Description of the photo
{created.date} Photo's creation date in ISO format, e.g.
'2020-03-22'
{created.year} 4-digit year of file creation time
{created.yy} 2-digit year of file creation time
{created.mm} 2-digit month of the file creation time
(zero padded)
{created.month} Month name in user's locale of the file
creation time
{created.mon} Month abbreviation in the user's locale of
the file creation time
{created.doy} 3-digit day of year (e.g Julian day) of file
creation time, starting from 1 (zero padded)
{modified.date} Photo's modification date in ISO format,
e.g. '2020-03-22'
{modified.year} 4-digit year of file modification time
{modified.yy} 2-digit year of file modification time
{modified.mm} 2-digit month of the file modification time
(zero padded)
{modified.month} Month name in user's locale of the file
modification time
{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 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
{place.name.area_of_interest} Area of interest name (e.g. landmark or
public place) from the photo's reverse
geolocation data
{place.address} Postal address from the photo's reverse
geolocation data, e.g. '2007 18th St NW,
Washington, DC 20009, United States'
{place.address.street} Street part of the postal address, e.g.
'2007 18th St NW'
{place.address.city} City part of the postal address, e.g.
'Washington'
{place.address.state_province} State/province part of the postal address,
e.g. 'DC'
{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'
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:
@@ -310,6 +383,7 @@ Example: export photos to file structure based on 4-digit year and full name of
## Example uses of the package
```python
""" Simple usage of the package """
import os.path
import osxphotos
@@ -319,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)
@@ -350,34 +424,81 @@ if __name__ == "__main__":
```
```python
""" Export all photos to ~/Desktop/export
If file has been edited, export the edited version,
otherwise, export the original version """
""" Export all photos to specified directory using album names as folders
If file has been edited, also export the edited version,
otherwise, export the original version
This will result in duplicate photos if photo is in more than album """
import os.path
import pathlib
import sys
import click
from pathvalidate import is_valid_filepath, sanitize_filepath
import osxphotos
def main():
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
photosdb = osxphotos.PhotosDB(db)
photos = photosdb.photos()
@click.command()
@click.argument("export_path", type=click.Path(exists=True))
@click.option(
"--default-album",
help="Default folder for photos with no album. Defaults to 'unfiled'",
default="unfiled",
)
@click.option(
"--library-path",
help="Path to Photos library, default to last used library",
default=None,
)
def export(export_path, default_album, library_path):
export_path = os.path.expanduser(export_path)
library_path = os.path.expanduser(library_path) if library_path else None
export_path = os.path.expanduser("~/Desktop/export")
if library_path is not None:
photosdb = osxphotos.PhotosDB(library_path)
else:
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
for p in photos:
if not p.ismissing:
if p.hasadjustments:
exported = p.export(export_path, edited=True)
else:
exported = p.export(export_path)
print(f"Exported {p.filename} to {exported}")
albums = p.albums
if not albums:
albums = [default_album]
for album in albums:
click.echo(f"exporting {p.filename} in album {album}")
# make sure no invalid characters in destination path (could be in album name)
album_name = sanitize_filepath(album, platform="auto")
# create destination folder, if necessary, based on album name
dest_dir = os.path.join(export_path, album_name)
# verify path is a valid path
if not is_valid_filepath(dest_dir, platform="auto"):
sys.exit(f"Invalid filepath {dest_dir}")
# create destination dir if needed
if not os.path.isdir(dest_dir):
os.makedirs(dest_dir)
# export the photo
if p.hasadjustments:
# 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}")
# export unedited version
exported = p.export(dest_dir)
click.echo(f"Exported {p.filename} to {exported}")
else:
print(f"Skipping missing photo: {p.filename}")
click.echo(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
main()
export() # pylint: disable=no-value-for-parameter
```
## Package Interface
@@ -458,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)
@@ -683,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
@@ -796,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).
@@ -810,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
@@ -829,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.
@@ -840,24 +1071,38 @@ If overwrite=False and increment=False, export will fail if destination file alr
Returns `True` if photo place is user's home address, otherwise `False`.
#### `name`
Returns the name of the local place as str. This may be a street address (e.g. "2038 18th St NW") or a public place (e.g. "St James\'s Park").
Returns the name of the local place as str. This is what Photos displays in the Info window. **Note** Photos 5 uses a different algorithm to determine the name than earlier versions which means the same Photo may have a different place name in Photos 4 and Photos 5. `PhotoInfo.name` will return the name Photos would have shown depending on the version of the library being processed. In Photos 5, the place name is generally more detailed than in earlier versions of Photos.
For example, I have photo in my library that under Photos 4, has place name of "Mayfair Shopping Centre, Victoria, Canada" and under Photos 5 the same photo has place name of "Mayfair, Vancouver Island, Victoria, British Columbia, Canada".
Returns `None` if photo does not contain a name.
#### `names`
Returns a list of place names in ascending order by area, starting with the smallest area (most local) to largest area (least local). For example:
Returns a `PlaceNames` namedtuple with the following fields. Each field is a list with zero or more values, sorted by area in ascending order. E.g. `names.area_of_interest` could be ['Gulf Islands National Seashore', 'Santa Rosa Island'], ["Knott's Berry Farm"], or [] if `area_of_interest` not defined. The value shown in Photos is the first value in the list. With the exception of `body_of_water` each of these field corresponds to an attribute of a [CLPlacemark](https://developer.apple.com/documentation/corelocation/clplacemark) object. **Note** The `PlaceNames` namedtuple contains reserved fields not listed below (see implementation for details), thus it should be referenced only by name (e.g. `names.city`) and not by index.
["2038 18th St NW",
"Adams Morgan",
"Washington",
"Washington",
"Washington",
"District of Columbia",
"United States"]
- `country`; the name of the country associated with the placemark.
- `state_province`; administrativeArea, The state or province associated with the placemark.
- `sub_administrative_area`; additional administrative area information for the placemark.
- `city`; locality; the city associated with the placemark.
- `additional_city_info`; subLocality, Additional city-level information for the placemark.
- `ocean`; the name of the ocean associated with the placemark.
- `area_of_interest`; areasOfInterest, The relevant areas of interest associated with the placemark.
- `inland_water`; the name of the inland water body associated with the placemark.
- `region`; the geographic region associated with the placemark.
- `sub_throughfare`; additional street-level information for the placemark.
- `postal_code`; the postal code associated with the placemark.
- `street_address`; throughfare, The street address associated with the placemark.
- `body_of_water`; in Photos 4, any body of water; in Photos 5 contains the union of ocean and inland_water
`names[0]` will always be the most local (e.g. street address) component and `names[-1]` will always be the least local which is almost always the country name.
**Note**: In Photos <= 4.0, only the following fields are defined; all others are set to empty list:
**Note**: names may contain duplicates as in above. The data is returned exactly as it is stored by Photos.
- `country`
- `state_province`
- `sub_administrative_area`
- `city`
- `additional_city_info`
- `area_of_interest`
- `body_of_water`
#### `country_code`
Returns the country_code of place, for example "GB". Returns `None` if PhotoInfo contains no country code.
@@ -868,15 +1113,15 @@ Returns the full postal address as a string if defined, otherwise `None`.
For example: "2038 18th St NW, Washington, DC 20009, United States"
#### `address`:
Returns a `PostalAddress` tuple with details of the postal address containing the following fields:
- city
- country
- postal_code
- state
- street
- sub_administrative_area
- sub_locality
- iso_country_code
Returns a `PostalAddress` namedtuple with details of the postal address containing the following fields:
- `city`
- `country`
- `postal_code`
- `state`
- `street`
- `sub_administrative_area`
- `sub_locality`
- `iso_country_code`
For example:
```python
@@ -886,36 +1131,110 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
'96753'
```
### Template Functions
There is a simple template system used by the command line client to specify the output directory using a template. The following are available in `osxphotos.template`.
#### `render_filepath_template(template, photo, none_str="_")`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
- `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 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"]`
| Substitution | Description |
|--------------|-------------|
|{name}|Filename of the photo|
|{original_name}|Photo's original filename when imported to Photos|
|{title}|Title of the photo|
|{descr}|Description of the photo|
|{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'|
|{created.year}|4-digit year of file creation time|
|{created.yy}|2-digit year of file creation time|
|{created.mm}|2-digit month of the file creation time (zero padded)|
|{created.month}|Month name in user's locale of the file creation time|
|{created.mon}|Month abbreviation in the user's locale of the file creation time|
|{created.doy}|3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)|
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'|
|{modified.year}|4-digit year of file modification time|
|{modified.yy}|2-digit year of file modification time|
|{modified.mm}|2-digit month of the file modification time (zero padded)|
|{modified.month}|Month name in user's locale of the file modification time|
|{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 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|
|{place.name.area_of_interest}|Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data|
|{place.address}|Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'|
|{place.address.street}|Street part of the postal address, e.g. '2007 18th St NW'|
|{place.address.city}|City part of the postal address, e.g. 'Washington'|
|{place.address.state_province}|State/province part of the postal address, e.g. 'DC'|
|{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.
- `dt`: a datetime.datetime object
Returnes `DateTimeFormater` class.
Has the following properties:
- `date`: Date in ISO format without timezone, e.g. "2020-03-04"
- `year`: 4-digit year
- `yy`: 2-digit year
- `month`: month name in user's locale
- `mon`: month abbreviation in user's locale
- `mm`: 2-digit month
- `doy`: 3-digit day of year (e.g. Julian day)
### Utility Functions
The following functions are located in osxphotos.utils
#### ```get_system_library_path()```
#### `get_system_library_path()`
**MacOS 10.15 Only** Returns path to System Photo Library as string. On MacOS version < 10.15, returns None.
#### ```get_last_library_path()```
#### `get_last_library_path()`
Returns path to last opened Photo Library as string.
#### ```list_photo_libraries()```
#### `list_photo_libraries()`
Returns list of Photos libraries found on the system. **Note**: On MacOS 10.15, this appears to list all libraries. On older systems, it may not find some libraries if they are not located in ~/Pictures. Provided for convenience but do not rely on this to find all libraries on the system.
#### ```dd_to_dms_str(lat, lon)```
#### `dd_to_dms_str(lat, lon)`
Convert latitude, longitude in degrees to degrees, minutes, seconds as string.
lat: latitude in degrees
lon: longitude in degrees
- `lat`: latitude in degrees
- `lon`: longitude in degrees
returns: string tuple in format ("51 deg 30' 12.86\\" N", "0 deg 7' 54.50\\" W")
This is the same format used by exiftool's json format.
#### ```create_path_by_date(dest, dt)```
#### `create_path_by_date(dest, dt)`
Creates a path in dest folder in form dest/YYYY/MM/DD/
dest: valid path as str
dt: datetime.timetuple() object
- `dest`: valid path as str
- `dt`: datetime.timetuple() object
Checks to see if path exists, if it does, do nothing and return path. If path does not exist, creates it and returns path. Useful for exporting photos to a date-based folder structure.
### Examples
## Examples
```python
import osxphotos
@@ -928,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)
@@ -969,7 +1288,7 @@ if __name__ == "__main__":
## Related Projects
- [photosmeta](https://github.com/rhettbull/photosmeta): uses osxphotos and [exiftool](https://exiftool.org/) to apply metadata from Photos as exif data in the photo files. Can also export photos while preserving metadata and also apply Photos keywords as spotlight tags to make it easier to search for photos using spotlight. This is mostly made obsolete by osxphotos. The one feature that photosmeta has that osxphotos does not is ability to update the metadata of the actual photo files in the Photos library without exporting them. (Use with caution!)
- [rhettbull/photosmeta](https://github.com/rhettbull/photosmeta): uses osxphotos and [exiftool](https://exiftool.org/) to apply metadata from Photos as exif data in the photo files. Can also export photos while preserving metadata and also apply Photos keywords as spotlight tags to make it easier to search for photos using spotlight. This is mostly made obsolete by osxphotos. The one feature that photosmeta has that osxphotos does not is ability to update the metadata of the actual photo files in the Photos library without exporting them. (Use with caution!)
- [patrikhson/photo-export](https://github.com/patrikhson/photo-export): Exports older versions of Photos databases. Provided the inspiration for osxphotos.
- [orangeturtle739/photos-export](https://github.com/orangeturtle739/photos-export): Set of scripts to export Photos libraries.
- [ndbroadbent/icloud_photos_downloader](https://github.com/ndbroadbent/icloud_photos_downloader): Download photos from iCloud. Currently unmaintained.
@@ -987,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/)

View File

@@ -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__":

View File

@@ -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_filename_template, TEMPLATE_SUBSTITUTIONS
from .template import (
render_filepath_template,
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
)
from .utils import _copy_file, create_path_by_date
@@ -81,45 +85,66 @@ class ExportCommand(click.Command):
formatter.write_text(
"With the --directory option, you may specify a template for the "
+ "export directory. This directory will be appended to the export path specified "
+ " in the export DEST argument to export. For example, if template is "
+ "in the export DEST argument to export. For example, if template is "
+ "'{created.year}/{created.month}', and export desitnation DEST is "
+ "'/Users/maria/Pictures/export', "
+ " the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' "
+ " if the photo was created in March 2020. "
+ "the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' "
+ "if the photo was created in March 2020. "
)
formatter.write("\n")
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")
formatter.write_text(
"In the current implementation, substitutions which have no value "
+ "will be replaced by '_', "
+ "for example, your template looked like '{created.year}/{place.address}' "
"You may specify an optional default value to use if the substitution does not contain a value "
+ "(e.g. the value is null) "
+ "by specifying the default value after a ',' in the template string: "
+ "for example, if template is '{created.year}/{place.address,'NO_ADDRESS'}' "
+ "but there was no address associated with the photo, the resulting output would be: "
+ "'2020/_/photoname.jpg' "
+ "'2020/NO_ADDRESS/photoname.jpg'. "
+ "If specified, the default value may not contain a brace symbol ('{' or '}')."
)
formatter.write("\n")
formatter.write_text(
"I plan to add the option to specify the value to be used for missing "
+ "subsitutions in a future version. I also plan to extend the templating system "
"If you do not specify a default value and the template substitution "
+ "has no value, '_' (underscore) will be used as the default value. For example, in the "
+ "above example, this would result in '2020/_/photoname.jpg' if address was null"
)
formatter.write_text(
"I plan to eventually extend the templating system "
+ "to the exported filename so you can specify the filename using a template."
)
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()
@@ -162,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(
@@ -170,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(
@@ -178,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",
@@ -208,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",
@@ -219,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(
@@ -299,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,
@@ -473,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
@@ -583,6 +684,7 @@ def query(
keyword,
person,
album,
folder,
uuid,
title,
no_title,
@@ -627,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"
@@ -639,10 +744,12 @@ def query(
keyword,
person,
album,
folder,
uuid,
edited,
external_edit,
uti,
has_raw,
from_date,
to_date,
]
@@ -664,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]):
@@ -691,6 +799,7 @@ def query(
keyword=keyword,
person=person,
album=album,
folder=folder,
uuid=uuid,
title=title,
no_title=no_title,
@@ -734,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
@@ -743,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,
@@ -760,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",
@@ -803,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(
@@ -817,8 +935,16 @@ def query(
"--directory",
metavar="DIRECTORY",
default=None,
help="Optional template for specifying name of output directory. "
"See below for additional details on templating system",
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))
@@ -832,6 +958,7 @@ def export(
keyword,
person,
album,
folder,
uuid,
title,
no_title,
@@ -852,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,
@@ -880,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.
@@ -888,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):
@@ -910,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:
@@ -947,6 +1095,7 @@ def export(
keyword=keyword,
person=person,
album=album,
folder=folder,
uuid=uuid,
title=title,
no_title=no_title,
@@ -990,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:
@@ -1020,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,
@@ -1036,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:
@@ -1052,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):
@@ -1107,6 +1266,9 @@ def print_photo_info(photos, json=False):
"hdr",
"selfie",
"panorama",
"has_raw",
"uti_raw",
"path_raw",
]
)
for p in photos:
@@ -1148,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:
@@ -1159,6 +1324,7 @@ def _query(
keyword=None,
person=None,
album=None,
folder=None,
uuid=None,
title=None,
no_title=None,
@@ -1202,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(
@@ -1220,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
@@ -1236,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:
@@ -1250,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]
@@ -1339,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
@@ -1355,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
@@ -1369,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 ""
@@ -1401,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_filename_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
@@ -1427,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__":

View File

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

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.23.4"
__version__ = "0.28.2"

211
osxphotos/albuminfo.py Normal file
View 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)

View File

@@ -10,7 +10,7 @@ import logging
import os
import subprocess
import sys
from functools import lru_cache
from functools import lru_cache # pylint: disable=syntax-error
from .utils import _debug

View File

@@ -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"])
@@ -116,8 +119,6 @@ class PhotoInfo:
)
return photopath
# if self._info["masterFingerprint"]:
# if masterFingerprint is not null, path appears to be valid
if self._info["directory"].startswith("/"):
photopath = os.path.join(self._info["directory"], self._info["filename"])
else:
@@ -126,13 +127,6 @@ class PhotoInfo:
)
return photopath
# if all else fails, photopath = None
# photopath = None
# logging.debug(
# f"WARNING: photopath None, masterFingerprint null, not shared {pformat(self._info)}"
# )
# return photopath
@property
def path_edited(self):
""" absolute path on disk of the edited picture """
@@ -143,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:
@@ -247,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 """
@@ -262,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
@@ -326,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
@@ -338,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
@@ -363,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
@@ -406,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:
@@ -492,15 +574,15 @@ class PhotoInfo:
@property
def place(self):
""" If Photos version >= 5, returns PlaceInfo object containing reverse geolocation info """
""" Returns PlaceInfo object containing reverse geolocation info """
# 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:
except AttributeError:
if self._info["placeNames"]:
self._place = PlaceInfo4(
self._info["placeNames"], self._info["countryCode"]
@@ -518,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,
@@ -531,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)
@@ -544,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
@@ -554,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
@@ -619,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}"
)
@@ -676,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
@@ -688,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
@@ -942,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)
@@ -988,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

View File

@@ -1,6 +1,9 @@
"""
PlaceInfo class
Provides reverse geolocation info for photos
See https://developer.apple.com/documentation/corelocation/clplacemark
for additional documentation on reverse geolocation data
"""
from abc import ABC, abstractmethod
from collections import namedtuple # pylint: disable=syntax-error
@@ -16,13 +19,45 @@ PostalAddress = namedtuple(
"sub_locality",
"city",
"sub_administrative_area",
"state",
"state_province",
"postal_code",
"country",
"iso_country_code",
],
)
# PlaceNames tuple returned by PlaceInfo.names
# order of fields 0 - 17 is mapped to placeType value in
# PLRevGeoLocationInfo.mapInfo.sortedPlaceInfos
# field 18 is combined bodies of water (ocean + inland_water)
# and maps to Photos <= 4, RKPlace.type == 44
# (Photos <= 4 doesn't have ocean or inland_water types)
# The fields named "field0", etc. appear to be unused
PlaceNames = namedtuple(
"PlaceNames",
[
"field0",
"country", # The name of the country associated with the placemark.
"state_province", # administrativeArea, The state or province associated with the placemark.
"sub_administrative_area", # Additional administrative area information for the placemark.
"city", # locality, The city associated with the placemark.
"field5",
"additional_city_info", # subLocality, Additional city-level information for the placemark.
"ocean", # The name of the ocean associated with the placemark.
"area_of_interest", # areasOfInterest, The relevant areas of interest associated with the placemark.
"inland_water", # The name of the inland water body associated with the placemark.
"field10",
"region", # The geographic region associated with the placemark.
"sub_throughfare", # Additional street-level information for the placemark.
"field13",
"postal_code", # The postal code associated with the placemark.
"field15",
"field16",
"street_address", # throughfare, The street address associated with the placemark.
"body_of_water", # RKPlace.type == 44, appears to be any body of water (ocean or inland)
],
)
# The following classes represent Photo Library Reverse Geolocation Info as stored
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
# These classes are used by bpylist.archiver to unarchive the serialized objects
@@ -323,9 +358,18 @@ class PlaceInfo4(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos <= 4) """
def __init__(self, place_names, country_code):
""" place_names: list of place names in ascending order by area """
""" place_names: list of place name tuples in ascending order by area
tuple fields are: modelID, place name, place type, area, e.g.
[(5, "St James's Park", 45, 0),
(4, 'Westminster', 16, 22097376),
(3, 'London', 4, 1596146816),
(2, 'England', 2, 180406091776),
(1, 'United Kingdom', 1, 414681432064)]
country_code: two letter country code for the country
"""
self._place_names = place_names
self._country_code = country_code
self._process_place_info()
@property
def address_str(self):
@@ -341,11 +385,11 @@ class PlaceInfo4(PlaceInfo):
@property
def name(self):
return self._place_names[0]
return self._name
@property
def names(self):
return self._place_names
return self._names
@property
def address(self):
@@ -360,6 +404,83 @@ class PlaceInfo4(PlaceInfo):
and self._country_code == other._country_code
)
def _process_place_info(self):
""" Process place_names to set self._name and self._names """
places = self._place_names
# build a dictionary where key is placetype
places_dict = {}
for p in places:
# places in format:
# [(5, "St James's Park", 45, 0), ]
# 0: modelID
# 1: name
# 2: type
# 3: area
try:
places_dict[p[2]].append((p[1], p[3]))
except KeyError:
places_dict[p[2]] = [(p[1], p[3])]
# build list to populate PlaceNames tuple
# initialize with empty lists for each field in PlaceNames
place_info = [[]] * 19
# add the place names sorted by area (ascending)
# in Photos <=4, possible place type values are:
# 45: areasOfInterest (The relevant areas of interest associated with the placemark.)
# 44: body of water (includes both inlandWater and ocean)
# 43: subLocality (Additional city-level information for the placemark.
# 16: locality (The city associated with the placemark.)
# 4: subAdministrativeArea (Additional administrative area information for the placemark.)
# 2: administrativeArea (The state or province associated with the placemark.)
# 1: country
# mapping = mapping from PlaceNames to field in places_dict
# PlaceNames fields map to the placeType value in Photos5 (0..17)
# but place type in Photos <=4 has different values
# hence (3, 4) means PlaceNames[3] = places_dict[4] (sub_administrative_area)
mapping = [(1, 1), (2, 2), (3, 4), (4, 16), (18, 44), (8, 45)]
for field5, field4 in mapping:
try:
place_info[field5] = [
p[0]
for p in sorted(places_dict[field4], key=lambda place: place[1])
]
except KeyError:
pass
place_names = PlaceNames(*place_info)
self._names = place_names
# build the name as it appears in Photos
# the length of the name is at most 3 fields and appears to be based on available
# reverse geolocation data in the following order (left to right, joined by ',')
# always has country if available then either area of interest and city OR
# city and state
# e.g. 4, 2, 1 OR 8, 4, 1
# 8 (45): area_of_interest
# 4 (16): locality / city
# 2 (2): administrative area (state/province)
# 1 (1): country
name_list = []
if place_names[8]:
name_list.append(place_names[8][0])
if place_names[4]:
name_list.append(place_names[4][0])
elif place_names[4]:
name_list.append(place_names[4][0])
if place_names[2]:
name_list.append(place_names[2][0])
elif place_names[2]:
name_list.append(place_names[2][0])
# add country
if place_names[1]:
name_list.append(place_names[1][0])
name = ", ".join(name_list)
self._name = name if name != "" else None
def __ne__(self, other):
return not self.__eq__(other)
@@ -382,6 +503,7 @@ class PlaceInfo5(PlaceInfo):
self._bplist = revgeoloc_bplist
# todo: check for None?
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
self._process_place_info()
@property
def address_str(self):
@@ -401,22 +523,12 @@ class PlaceInfo5(PlaceInfo):
@property
def name(self):
""" returns local place name """
name = (
self._plrevgeoloc.mapItem.sortedPlaceInfos[0].name
if self._plrevgeoloc.mapItem.sortedPlaceInfos
else None
)
return name
return self._name
@property
def names(self):
""" returns list of all place names in reverse order by area
e.g. most local is at index 0, least local (usually country) is at index -1 """
names = []
# todo: strip duplicates
for name in self._plrevgeoloc.mapItem.sortedPlaceInfos:
names.append(name.name)
return names
""" returns PlaceNames tuple with detailed reverse geolocation place names """
return self._names
@property
def address(self):
@@ -426,13 +538,72 @@ class PlaceInfo5(PlaceInfo):
sub_locality=addr._subLocality,
city=addr._city,
sub_administrative_area=addr._subAdministrativeArea,
state=addr._state,
state_province=addr._state,
postal_code=addr._postalCode,
country=addr._country,
iso_country_code=addr._ISOCountryCode,
)
return address
def _process_place_info(self):
""" Process sortedPlaceInfos to set self._name and self._names """
places = self._plrevgeoloc.mapItem.sortedPlaceInfos
# build a dictionary where key is placetype
places_dict = {}
for p in places:
try:
places_dict[p.placeType].append((p.name, p.area))
except KeyError:
places_dict[p.placeType] = [(p.name, p.area)]
# build list to populate PlaceNames tuple
place_info = []
for field in range(18):
try:
# add the place names sorted by area (ascending)
place_info.append(
[
p[0]
for p in sorted(places_dict[field], key=lambda place: place[1])
]
)
except:
place_info.append([])
# fill in body_of_water for compatibility with Photos <= 4
place_info.append(place_info[7] + place_info[9])
place_names = PlaceNames(*place_info)
self._names = place_names
# build the name as it appears in Photos
# the length of the name is variable and appears to be based on available
# reverse geolocation data in the following order (left to right, joined by ',')
# 8: area_of_interest
# 11: region (I've only seen this applied to islands)
# 4: locality / city
# 2: administrative area (state/province)
# 1: country
# 9: inland_water
# 7: ocean
name = ", ".join(
[
p[0]
for p in [
place_names[8], # area of interest
place_names[11], # region (I've only seen this applied to islands)
place_names[4], # locality / city
place_names[2], # administrative area (state/province)
place_names[1], # country
place_names[9], # inland_water
place_names[7], # ocean
]
if p and p[0]
]
)
self._name = name if name != "" else None
def __eq__(self, other):
if not isinstance(other, type(self)):
return False

View File

@@ -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",
@@ -24,22 +38,217 @@ TEMPLATE_SUBSTITUTIONS = {
"{modified.month}": "Month name in user's locale of the file modification time",
"{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",
"{place.names}": "list of place names from the photo's reverse geolocation data, joined with '_', for example, '18th St NW_Washington_DC_United States'",
"{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 reverse geolocation data",
"{place.name.city}": "City or locality name from the photo's reverse geolocation data",
"{place.name.area_of_interest}": "Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data",
"{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
"{place.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
"{place.city}": "City part of the postal address, e.g. 'Washington'",
"{place.state}": "State part of the postal address, e.g. 'DC'",
"{place.postal_code}": "Postal code part of the postal address, e.g. '20009'",
"{place.country}": "Country name of the postal code, e.g. 'United States'",
"{place.country_code}": "ISO country code of the postal address, e.g. 'US'",
"{place.address.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
"{place.address.city}": "City part of the postal address, e.g. 'Washington'",
"{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
"{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'",
}
# 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",
}
def render_filename_template(
template: str, photo: PhotoInfo, none_str: str = "_"
) -> Tuple[str, list]:
""" render a filename or directory template """
# 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 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 """
# must be a valid keyword
if lookup == "name":
return pathlib.Path(photo.filename).stem
if lookup == "original_name":
return pathlib.Path(photo.original_filename).stem
if lookup == "title":
return photo.title
if lookup == "descr":
return photo.description
if lookup == "created.date":
return DateTimeFormatter(photo.date).date
if lookup == "created.year":
return DateTimeFormatter(photo.date).year
if lookup == "created.yy":
return DateTimeFormatter(photo.date).yy
if lookup == "created.mm":
return DateTimeFormatter(photo.date).mm
if lookup == "created.month":
return DateTimeFormatter(photo.date).month
if lookup == "created.mon":
return DateTimeFormatter(photo.date).mon
if lookup == "created.doy":
return DateTimeFormatter(photo.date).doy
if lookup == "modified.date":
return (
DateTimeFormatter(photo.date_modified).date if photo.date_modified else None
)
if lookup == "modified.year":
return (
DateTimeFormatter(photo.date_modified).year if photo.date_modified else None
)
if lookup == "modified.yy":
return (
DateTimeFormatter(photo.date_modified).yy if photo.date_modified else None
)
if lookup == "modified.mm":
return (
DateTimeFormatter(photo.date_modified).mm if photo.date_modified else None
)
if lookup == "modified.month":
return (
DateTimeFormatter(photo.date_modified).month
if photo.date_modified
else None
)
if lookup == "modified.mon":
return (
DateTimeFormatter(photo.date_modified).mon if photo.date_modified else None
)
if lookup == "modified.doy":
return (
DateTimeFormatter(photo.date_modified).doy if photo.date_modified else None
)
if lookup == "place.name":
return photo.place.name if photo.place else None
if lookup == "place.country_code":
return photo.place.country_code if photo.place else None
if lookup == "place.name.country":
return (
photo.place.names.country[0]
if photo.place and photo.place.names.country
else None
)
if lookup == "place.name.state_province":
return (
photo.place.names.state_province[0]
if photo.place and photo.place.names.state_province
else None
)
if lookup == "place.name.city":
return (
photo.place.names.city[0]
if photo.place and photo.place.names.city
else None
)
if lookup == "place.name.area_of_interest":
return (
photo.place.names.area_of_interest[0]
if photo.place and photo.place.names.area_of_interest
else None
)
if lookup == "place.address":
return (
photo.place.address_str if photo.place and photo.place.address_str else None
)
if lookup == "place.address.street":
return (
photo.place.address.street
if photo.place and photo.place.address.street
else None
)
if lookup == "place.address.city":
return (
photo.place.address.city
if photo.place and photo.place.address.city
else None
)
if lookup == "place.address.state_province":
return (
photo.place.address.state_province
if photo.place and photo.place.address.state_province
else None
)
if lookup == "place.address.postal_code":
return (
photo.place.address.postal_code
if photo.place and photo.place.address.postal_code
else None
)
if lookup == "place.address.country":
return (
photo.place.address.country
if photo.place and photo.place.address.country
else None
)
if lookup == "place.address.country_code":
return (
photo.place.address.iso_country_code
if photo.place and photo.place.address.iso_country_code
else None
)
# if here, didn't get a match
raise KeyError(f"No rule for processing {lookup}")
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\-. ]+))?)(?=\}(?!\}))\}"
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
@@ -47,114 +256,138 @@ def render_filename_template(
if type(photo) is not PhotoInfo:
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
rendered = template
original_name = pathlib.Path(photo.original_filename).stem
current_name = pathlib.Path(photo.filename).stem
created = DateTimeFormatter(photo.date)
if photo.date_modified:
modified = DateTimeFormatter(photo.date_modified)
else:
modified = None
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 """
# make substitutions
rendered = rendered.replace("{name}", current_name)
rendered = rendered.replace("{original_name}", original_name)
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_func(matchobj.group(1), photo)
except KeyError:
return matchobj.group(0)
title = photo.title if photo.title is not None else none_str
rendered = rendered.replace("{title}", f"{title}")
if val is None:
return (
matchobj.group(3) if matchobj.group(3) is not None else none_str
)
else:
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
descr = photo.description if photo.description is not None else none_str
rendered = rendered.replace("{descr}", f"{descr}")
return subst
rendered = rendered.replace("{created.date}", photo.date.date().isoformat())
rendered = rendered.replace("{created.year}", created.year)
rendered = rendered.replace("{created.yy}", created.yy)
rendered = rendered.replace("{created.mm}", created.mm)
rendered = rendered.replace("{created.month}", created.month)
rendered = rendered.replace("{created.mon}", created.mon)
rendered = rendered.replace("{created.doy}", created.doy)
subst_func = make_subst_function(photo, none_str)
if modified is not None:
rendered = rendered.replace(
"{modified.date}", photo.date_modified.date().isoformat()
# do the replacements
rendered = re.sub(regex, subst_func, template)
# 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
]
)
rendered = rendered.replace("{modified.year}", modified.year)
rendered = rendered.replace("{modified.yy}", modified.yy)
rendered = rendered.replace("{modified.mm}", modified.mm)
rendered = rendered.replace("{modified.month}", modified.month)
rendered = rendered.replace("{modified.mon}", modified.mon)
rendered = rendered.replace("{modified.doy}", modified.doy)
else:
rendered = rendered.replace("{modified.year}", none_str)
rendered = rendered.replace("{modified.yy}", none_str)
rendered = rendered.replace("{modified.mm}", none_str)
rendered = rendered.replace("{modified.month}", none_str)
rendered = rendered.replace("{modified.mon}", none_str)
rendered = rendered.replace("{modified.doy}", none_str)
place_name = photo.place.name if photo.place and photo.place.name else none_str
rendered = rendered.replace("{place.name}", place_name)
place_names = (
"_".join(photo.place.names) if photo.place and photo.place.names else none_str
)
rendered = rendered.replace("{place.names}", place_names)
address = (
photo.place.address_str if photo.place and photo.place.address_str else none_str
)
rendered = rendered.replace("{place.address}", address)
street = (
photo.place.address.street
if photo.place and photo.place.address.street
else none_str
)
rendered = rendered.replace("{place.street}", street)
city = (
photo.place.address.city
if photo.place and photo.place.address.city
else none_str
)
rendered = rendered.replace("{place.city}", city)
state = (
photo.place.address.state
if photo.place and photo.place.address.state
else none_str
)
rendered = rendered.replace("{place.state}", state)
postal_code = (
photo.place.address.state
if photo.place and photo.place.address.postal_code
else none_str
)
rendered = rendered.replace("{place.postal_code}", postal_code)
country = (
photo.place.address.state
if photo.place and photo.place.address.country
else none_str
)
rendered = rendered.replace("{place.country}", country)
country_code = (
photo.place.country_code
if photo.place and photo.place.country_code
else none_str
)
rendered = rendered.replace("{place.country_code}", country_code)
# 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
]
# find any {words} that weren't replaced
unmatched = re.findall(r"{\w+}", rendered)
return (rendered, unmatched)
return rendered_strings, unmatched
class DateTimeFormatter:
@@ -163,6 +396,12 @@ class DateTimeFormatter:
def __init__(self, dt: datetime.datetime):
self.dt = dt
@property
def date(self):
""" ISO date in form 2020-03-22 """
date = self.dt.date().isoformat()
return date
@property
def year(self):
""" 4 digit year """

View File

@@ -1,15 +1,20 @@
import fnmatch
import glob
import logging
import os
import os.path
import pathlib
import platform
import re
import sqlite3
import subprocess
import sys
import tempfile
import urllib.parse
import pathlib
from plistlib import load as plistload
import CoreFoundation
import CoreServices
import objc
from Foundation import *
@@ -113,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 """
@@ -124,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 """
@@ -292,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.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

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

View File

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

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-03-19T20:25:48Z</date>
<date>2020-04-18T18:01:02Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-03-19T22:36:41Z</date>
<date>2020-04-18T17:22:55Z</date>
</dict>
</plist>

View File

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

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-03-15T20:18:33Z</date>
<date>2020-04-17T17:49:52Z</date>
</dict>
</plist>

View File

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

View File

@@ -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-19T20:27:27Z</date>
<date>2020-04-17T17:51:16Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>441</integer>
<integer>900</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2020-03-22T12:51:16Z</date>
<date>2020-04-17T14:33:32Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2020-03-22T12:51:16Z</date>
<date>2020-04-17T14:33:32Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2020-03-22T12:51:17Z</date>
<date>2020-04-17T14:33:33Z</date>
<key>BackgroundJobSearch</key>
<date>2020-03-22T12:51:17Z</date>
<date>2020-04-17T14:33:33Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2020-03-22T12:51:16Z</date>
<date>2020-04-17T14:33:31Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2020-03-22T07:13:26Z</date>
<date>2020-04-17T07:32:04Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-03-22T12:51:17Z</date>
<date>2020-04-17T14:33:37Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-03-22T07:13:26Z</date>
<date>2020-04-17T07:32:00Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-03-22T12:51:17Z</date>
<date>2020-04-17T14:33:34Z</date>
<key>SiriPortraitDonation</key>
<date>2020-03-22T07:13:26Z</date>
<date>2020-04-17T07:32:04Z</date>
</dict>
</plist>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>FaceIDModelLastGenerationKey</key>
<date>2020-03-22T07:13:27Z</date>
<date>2020-04-17T07:32:07Z</date>
<key>LastContactClassificationKey</key>
<date>2020-03-22T07:13:29Z</date>
<date>2020-04-17T07:32:12Z</date>
</dict>
</plist>

View File

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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LibrarySchemaVersion</key>
<integer>5001</integer>
<key>MetaSchemaVersion</key>
<integer>3</integer>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>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>

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