Compare commits

...

20 Commits

Author SHA1 Message Date
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
Rhet Turnbull
d769dde358 version bump 2020-03-22 13:07:34 -07:00
Rhet Turnbull
d066435e3d Updated pathvalidate calls 2020-03-22 13:04:00 -07:00
Rhet Turnbull
8f0307fc24 Updated example 2020-03-22 12:55:24 -07:00
Rhet Turnbull
908fead8a2 Added export_by_album.py to examples 2020-03-22 11:37:25 -07:00
Rhet Turnbull
072e894e56 Updated CHANGELOG.md 2020-03-22 09:54:45 -07:00
Rhet Turnbull
47e57ee98e Updated dependencies 2020-03-22 09:54:10 -07:00
115 changed files with 1398 additions and 373 deletions

View File

@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.23.3](https://github.com/RhetTbull/osxphotos/compare/v0.23.1...v0.23.3)
> 22 March 2020
- Initial version of templating system for CLI [`2feb099`](https://github.com/RhetTbull/osxphotos/commit/2feb0999b3f9ffd9a24e37238f780239a027aa49)
- Added __str__ to place [`ad58b03`](https://github.com/RhetTbull/osxphotos/commit/ad58b03f2d31daf33849b141570dd0fb5e0a262e)
- Test library updates [`e90d9c6`](https://github.com/RhetTbull/osxphotos/commit/e90d9c6e11fce7a4e4aa348dcc5f57420c0b6c44)
#### [v0.23.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.0...v0.23.1)
> 21 March 2020
- Fixed requirements.txt for bplist2 [`cda5f44`](https://github.com/RhetTbull/osxphotos/commit/cda5f446933ea2272409d1f153e2a7811626ada6)
- Updated CHANGELOG.md [`b8da976`](https://github.com/RhetTbull/osxphotos/commit/b8da9765b8949eb90852d249c2877eeb1806d987)
- Updated requirements.txt [`9da7ad6`](https://github.com/RhetTbull/osxphotos/commit/9da7ad6dcc021fdafe358d74e1c52f69dc49ade8)
#### [v0.23.0](https://github.com/RhetTbull/osxphotos/compare/v0.22.23...v0.23.0) #### [v0.23.0](https://github.com/RhetTbull/osxphotos/compare/v0.22.23...v0.23.0)
> 21 March 2020 > 21 March 2020

350
README.md
View File

@@ -14,8 +14,9 @@
+ [PhotosDB](#photosdb) + [PhotosDB](#photosdb)
+ [PhotoInfo](#photoinfo) + [PhotoInfo](#photoinfo)
+ [PlaceInfo](#placeinfo) + [PlaceInfo](#placeinfo)
+ [Template Functions](#template-functions)
+ [Utility Functions](#utility-functions) + [Utility Functions](#utility-functions)
+ [Examples](#examples) * [Examples](#examples)
* [Related Projects](#related-projects) * [Related Projects](#related-projects)
* [Contributing](#contributing) * [Contributing](#contributing)
* [Implementation Notes](#implementation-notes) * [Implementation Notes](#implementation-notes)
@@ -214,19 +215,20 @@ Options:
exiftool may be installed from exiftool may be installed from
https://exiftool.org/ https://exiftool.org/
--directory DIRECTORY Optional template for specifying name of --directory DIRECTORY Optional template for specifying name of
output directory. See below for additional output directory in the form
details on templating system '{name,DEFAULT}'. See below for additional
details on templating system.
-h, --help Show this message and exit. -h, --help Show this message and exit.
**Templating System** **Templating System**
With the --directory option, you may specify a template for the export 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 the export DEST argument to export. For example, if template is
'{created.year}/{created.month}', and export desitnation DEST is '{created.year}/{created.month}', and export desitnation DEST is
'/Users/maria/Pictures/export', the actual export directory for a photo would '/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 be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
March 2020. 2020.
In the template, valid template substitutions will be replaced by the In the template, valid template substitutions will be replaced by the
corresponding value from the table below. Invalid substitutions will result corresponding value from the table below. Invalid substitutions will result
@@ -239,55 +241,77 @@ rendered name, escape the curly braces with \, for example, using
'{created.year}/\{name\}' for --directory would result in output of '{created.year}/\{name\}' for --directory would result in output of
2020/{name}/photoname.jpg 2020/{name}/photoname.jpg
In the current implementation, substitutions which have no value will be You may specify an optional default value to use if the substitution does not
replaced by '_', for example, your template looked like contain a value (e.g. the value is null) by specifying the default value after
'{created.year}/{place.address}' but there was no address associated with the a ',' in the template string: for example, if template is
photo, the resulting output would be: '2020/_/photoname.jpg' '{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 If you do not specify a default value and the template substitution has no
subsitutions in a future version. I also plan to extend the templating system value, '_' (underscore) will be used as the default value. For example, in the
to the exported filename so you can specify the filename using a template. 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 Substitution Description
{name} Filename of the photo {name} Filename of the photo
{original_name} Photo's original filename when imported to Photos {original_name} Photo's original filename when imported to
{title} Title of the photo Photos
{descr} Description of the photo {title} Title of the photo
{created.date} Photo's creation date in ISO format, e.g. '2020-03-22' {descr} Description of the photo
{created.year} 4-digit year of file creation time {created.date} Photo's creation date in ISO format, e.g.
{created.yy} 2-digit year of file creation time '2020-03-22'
{created.mm} 2-digit month of the file creation time (zero padded) {created.year} 4-digit year of file creation time
{created.month} Month name in user's locale of the file creation time {created.yy} 2-digit year of file creation time
{created.mon} Month abbreviation in the user's locale of the file {created.mm} 2-digit month of the file creation time
creation time (zero padded)
{created.doy} 3-digit day of year (e.g Julian day) of file creation {created.month} Month name in user's locale of the file
time, starting from 1 (zero padded) creation time
{modified.date} Photo's modification date in ISO format, e.g. {created.mon} Month abbreviation in the user's locale of
'2020-03-22' the file creation time
{modified.year} 4-digit year of file modification time {created.doy} 3-digit day of year (e.g Julian day) of file
{modified.yy} 2-digit year of file modification time creation time, starting from 1 (zero padded)
{modified.mm} 2-digit month of the file modification time (zero {modified.date} Photo's modification date in ISO format,
padded) e.g. '2020-03-22'
{modified.month} Month name in user's locale of the file modification {modified.year} 4-digit year of file modification time
time {modified.yy} 2-digit year of file modification time
{modified.mon} Month abbreviation in the user's locale of the file {modified.mm} 2-digit month of the file modification time
modification time (zero padded)
{modified.doy} 3-digit day of year (e.g Julian day) of file {modified.month} Month name in user's locale of the file
modification time, starting from 1 (zero padded) modification time
{place.name} Place name from the photo's reverse geolocation data {modified.mon} Month abbreviation in the user's locale of
{place.names} list of place names from the photo's reverse the file modification time
geolocation data, joined with '_', for example, '18th {modified.doy} 3-digit day of year (e.g Julian day) of file
St NW_Washington_DC_United States' modification time, starting from 1 (zero
{place.address} Postal address from the photo's reverse geolocation padded)
data, e.g. '2007 18th St NW, Washington, DC 20009, {place.name} Place name from the photo's reverse
United States' geolocation data, as displayed in Photos
{place.street} Street part of the postal address, e.g. '2007 18th St {place.name.country} Country name from the photo's reverse
NW' geolocation data
{place.city} City part of the postal address, e.g. 'Washington' {place.name.state_province} State or province name from the photo's
{place.state} State part of the postal address, e.g. 'DC' reverse geolocation data
{place.postal_code} Postal code part of the postal address, e.g. '20009' {place.name.city} City or locality name from the photo's
{place.country} Country name of the postal code, e.g. 'United States' reverse geolocation data
{place.country_code} ISO country code of the postal address, e.g. 'US' {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'
``` ```
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, including edited versions and live photo movies, group in folders by date created
@@ -310,6 +334,7 @@ Example: export photos to file structure based on 4-digit year and full name of
## Example uses of the package ## Example uses of the package
```python ```python
""" Simple usage of the package """
import os.path import os.path
import osxphotos import osxphotos
@@ -350,34 +375,81 @@ if __name__ == "__main__":
``` ```
```python ```python
""" Export all photos to ~/Desktop/export """ Export all photos to specified directory using album names as folders
If file has been edited, export the edited version, If file has been edited, also export the edited version,
otherwise, export the original version """ otherwise, export the original version
This will result in duplicate photos if photo is in more than album """
import os.path import os.path
import pathlib
import sys
import click
from pathvalidate import is_valid_filepath, sanitize_filepath
import osxphotos import osxphotos
def main(): @click.command()
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") @click.argument("export_path", type=click.Path(exists=True))
photosdb = osxphotos.PhotosDB(db) @click.option(
photos = photosdb.photos() "--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: for p in photos:
if not p.ismissing: if not p.ismissing:
if p.hasadjustments: albums = p.albums
exported = p.export(export_path, edited=True) if not albums:
else: albums = [default_album]
exported = p.export(export_path) for album in albums:
print(f"Exported {p.filename} to {exported}") 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: else:
print(f"Skipping missing photo: {p.filename}") click.echo(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__": if __name__ == "__main__":
main() export() # pylint: disable=no-value-for-parameter
``` ```
## Package Interface ## Package Interface
@@ -840,24 +912,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`. Returns `True` if photo place is user's home address, otherwise `False`.
#### `name` #### `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. Returns `None` if photo does not contain a name.
#### `names` #### `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", - `country`; the name of the country associated with the placemark.
"Adams Morgan", - `state_province`; administrativeArea, The state or province associated with the placemark.
"Washington", - `sub_administrative_area`; additional administrative area information for the placemark.
"Washington", - `city`; locality; the city associated with the placemark.
"Washington", - `additional_city_info`; subLocality, Additional city-level information for the placemark.
"District of Columbia", - `ocean`; the name of the ocean associated with the placemark.
"United States"] - `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` #### `country_code`
Returns the country_code of place, for example "GB". Returns `None` if PhotoInfo contains no country code. Returns the country_code of place, for example "GB". Returns `None` if PhotoInfo contains no country code.
@@ -868,15 +954,15 @@ Returns the full postal address as a string if defined, otherwise `None`.
For example: "2038 18th St NW, Washington, DC 20009, United States" For example: "2038 18th St NW, Washington, DC 20009, United States"
#### `address`: #### `address`:
Returns a `PostalAddress` tuple with details of the postal address containing the following fields: Returns a `PostalAddress` namedtuple with details of the postal address containing the following fields:
- city - `city`
- country - `country`
- postal_code - `postal_code`
- state - `state`
- street - `street`
- sub_administrative_area - `sub_administrative_area`
- sub_locality - `sub_locality`
- iso_country_code - `iso_country_code`
For example: For example:
```python ```python
@@ -886,36 +972,99 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
'96753' '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 the rendered template string with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. strings in the form "{foo}".
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `("2020/{foo}",["{foo}"])`
| 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 geolocationo 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'|
#### `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 ### Utility Functions
The following functions are located in osxphotos.utils 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. **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. 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. 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. Convert latitude, longitude in degrees to degrees, minutes, seconds as string.
lat: latitude in degrees - `lat`: latitude in degrees
lon: longitude in degrees - `lon`: longitude in degrees
returns: string tuple in format ("51 deg 30' 12.86\\" N", "0 deg 7' 54.50\\" W") 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. 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/ Creates a path in dest folder in form dest/YYYY/MM/DD/
dest: valid path as str - `dest`: valid path as str
dt: datetime.timetuple() object - `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. 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 ```python
import osxphotos import osxphotos
@@ -969,7 +1118,7 @@ if __name__ == "__main__":
## Related Projects ## 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. - [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. - [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. - [ndbroadbent/icloud_photos_downloader](https://github.com/ndbroadbent/icloud_photos_downloader): Download photos from iCloud. Currently unmaintained.
@@ -1001,6 +1150,7 @@ Apple does provide a framework ([PhotoKit](https://developer.apple.com/documenta
- [Click](https://pypi.org/project/click/) - [Click](https://pypi.org/project/click/)
- [Mako](https://www.makotemplates.org/) - [Mako](https://www.makotemplates.org/)
- [bpylist2](https://pypi.org/project/bpylist2/) - [bpylist2](https://pypi.org/project/bpylist2/)
- [pathvalidate](https://pypi.org/project/pathvalidate/)
## Acknowledgements ## Acknowledgements
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se

View File

@@ -0,0 +1,75 @@
""" 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
@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
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:
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:
click.echo(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
export() # pylint: disable=no-value-for-parameter

View File

@@ -21,7 +21,7 @@ import osxphotos
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION
from ._version import __version__ from ._version import __version__
from .exiftool import get_exiftool_path from .exiftool import get_exiftool_path
from .template import render_filename_template, TEMPLATE_SUBSTITUTIONS from .template import render_filepath_template, TEMPLATE_SUBSTITUTIONS
from .utils import _copy_file, create_path_by_date from .utils import _copy_file, create_path_by_date
@@ -81,11 +81,11 @@ class ExportCommand(click.Command):
formatter.write_text( formatter.write_text(
"With the --directory option, you may specify a template for the " "With the --directory option, you may specify a template for the "
+ "export directory. This directory will be appended to the export path specified " + "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 " + "'{created.year}/{created.month}', and export desitnation DEST is "
+ "'/Users/maria/Pictures/export', " + "'/Users/maria/Pictures/export', "
+ " the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' " + "the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' "
+ " if the photo was created in March 2020. " + "if the photo was created in March 2020. "
) )
formatter.write("\n") formatter.write("\n")
formatter.write_text( formatter.write_text(
@@ -104,16 +104,22 @@ class ExportCommand(click.Command):
) )
formatter.write("\n") formatter.write("\n")
formatter.write_text( formatter.write_text(
"In the current implementation, substitutions which have no value " "You may specify an optional default value to use if the substitution does not contain a value "
+ "will be replaced by '_', " + "(e.g. the value is null) "
+ "for example, your template looked like '{created.year}/{place.address}' " + "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: " + "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("\n")
formatter.write_text( formatter.write_text(
"I plan to add the option to specify the value to be used for missing " "If you do not specify a default value and the template substitution "
+ "subsitutions in a future version. I also plan to extend the templating system " + "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." + "to the exported filename so you can specify the filename using a template."
) )
@@ -817,8 +823,8 @@ def query(
"--directory", "--directory",
metavar="DIRECTORY", metavar="DIRECTORY",
default=None, default=None,
help="Optional template for specifying name of output directory. " help="Optional template for specifying name of output directory in the form '{name,DEFAULT}'. "
"See below for additional details on templating system", "See below for additional details on templating system.",
) )
@DB_ARGUMENT @DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.argument("dest", nargs=1, type=click.Path(exists=True))
@@ -1403,13 +1409,13 @@ def export_photo(
date_created = photo.date.timetuple() date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created) dest = create_path_by_date(dest, date_created)
elif directory: elif directory:
dirname, unmatched = render_filename_template(directory, photo) dirname, unmatched = render_filepath_template(directory, photo)
if unmatched: if unmatched:
click.echo( click.echo(
f"Possible unmatched substitution in template: {unmatched}", err=True f"Possible unmatched substitution in template: {unmatched}", err=True
) )
dirname = sanitize_filepath(dirname) dirname = sanitize_filepath(dirname, platform="auto")
if not is_valid_filepath(dirname): if not is_valid_filepath(dirname, platform="auto"):
raise ValueError(f"Invalid file path: {dirname}") raise ValueError(f"Invalid file path: {dirname}")
dest = os.path.join(dest, dirname) dest = os.path.join(dest, dirname)
if not os.path.isdir(dest): if not os.path.isdir(dest):

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.23.3" __version__ = "0.24.2"

View File

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

View File

@@ -116,8 +116,6 @@ class PhotoInfo:
) )
return photopath return photopath
# if self._info["masterFingerprint"]:
# if masterFingerprint is not null, path appears to be valid
if self._info["directory"].startswith("/"): if self._info["directory"].startswith("/"):
photopath = os.path.join(self._info["directory"], self._info["filename"]) photopath = os.path.join(self._info["directory"], self._info["filename"])
else: else:
@@ -126,13 +124,6 @@ class PhotoInfo:
) )
return photopath 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 @property
def path_edited(self): def path_edited(self):
""" absolute path on disk of the edited picture """ """ absolute path on disk of the edited picture """
@@ -492,7 +483,7 @@ class PhotoInfo:
@property @property
def place(self): 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 # implementation note: doesn't create the PlaceInfo object until requested
# then memoizes the object in self._place to avoid recreating the object # then memoizes the object in self._place to avoid recreating the object
@@ -500,7 +491,7 @@ class PhotoInfo:
if self._db._db_version < _PHOTOS_5_VERSION: if self._db._db_version < _PHOTOS_5_VERSION:
try: try:
return self._place # pylint: disable=access-member-before-definition return self._place # pylint: disable=access-member-before-definition
except: except AttributeError:
if self._info["placeNames"]: if self._info["placeNames"]:
self._place = PlaceInfo4( self._place = PlaceInfo4(
self._info["placeNames"], self._info["countryCode"] self._info["placeNames"], self._info["countryCode"]

View File

@@ -395,18 +395,6 @@ class PhotosDB:
return dest_path return dest_path
# def _open_sql_file(self, fname):
# """ opens sqlite file fname in read-only mode
# returns tuple of (connection, cursor) """
# try:
# conn = sqlite3.connect(
# f"{pathlib.Path(fname).as_uri()}?mode=ro", timeout=1, uri=True
# )
# c = conn.cursor()
# except sqlite3.Error as e:
# sys.exit(f"An error occurred opening sqlite file: {e.args[0]} {fname}")
# return (conn, c)
def _get_db_version(self): def _get_db_version(self):
""" gets the Photos DB version from LiGlobals table """ """ gets the Photos DB version from LiGlobals table """
""" returns the version as str""" """ returns the version as str"""
@@ -844,21 +832,26 @@ class PhotosDB:
countries = {code[0]: code[1] for code in country_codes} countries = {code[0]: code[1] for code in country_codes}
self._db_countries = countries self._db_countries = countries
# save existing row_factory # get the place data
old_row_factory = c.row_factory place_data = c.execute(
"SELECT modelID, defaultName, type, area " "FROM RKPlace "
# want only the list of values, not a list of tuples ).fetchall()
c.row_factory = lambda cursor, row: row[0] places = {p[0]: p for p in place_data}
self._db_places = places
for uuid in self._dbphotos: for uuid in self._dbphotos:
# get placeId which is then used to lookup defaultName # get placeId which is then used to lookup defaultName
place_ids = c.execute( place_ids_query = c.execute(
"SELECT placeId " "SELECT placeId "
"FROM RKPlaceForVersion " "FROM RKPlaceForVersion "
f"WHERE versionId = '{self._dbphotos[uuid]['modelID']}'" f"WHERE versionId = '{self._dbphotos[uuid]['modelID']}'"
).fetchall() )
self._dbphotos[uuid]["placeIDs"] = place_ids
country_code = [countries[x] for x in place_ids if x in countries] place_ids = [id[0] for id in place_ids_query.fetchall()]
self._dbphotos[uuid]["placeIDs"] = place_ids
country_code = [
countries[x] for x in place_ids if x in countries
]
if len(country_code) > 1: if len(country_code) > 1:
logging.warning(f"Found more than one country code for uuid: {uuid}") logging.warning(f"Found more than one country code for uuid: {uuid}")
@@ -867,19 +860,19 @@ class PhotosDB:
else: else:
self._dbphotos[uuid]["countryCode"] = None self._dbphotos[uuid]["countryCode"] = None
place_names = c.execute( # get the place info that matches the RKPlace modelIDs for this photo
"SELECT DISTINCT defaultName AS name " # (place_ids), sort by area (element 3 of the place_data tuple in places)
"FROM RKPlace " place_names = [
f"WHERE modelId IN({','.join(map(str,place_ids))}) " pname
"ORDER BY area ASC " for pname in sorted(
).fetchall() [places[p] for p in places if p in place_ids],
key=lambda place: place[3],
)
]
self._dbphotos[uuid]["placeNames"] = place_names self._dbphotos[uuid]["placeNames"] = place_names
self._dbphotos[uuid]["reverse_geolocation"] = None # Photos 5 self._dbphotos[uuid]["reverse_geolocation"] = None # Photos 5
# restore row_factory
c.row_factory = old_row_factory
# build album_titles dictionary # build album_titles dictionary
for album_id in self._dbalbum_details: for album_id in self._dbalbum_details:
title = self._dbalbum_details[album_id]["title"] title = self._dbalbum_details[album_id]["title"]

View File

@@ -1,6 +1,9 @@
""" """
PlaceInfo class PlaceInfo class
Provides reverse geolocation info for photos 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 abc import ABC, abstractmethod
from collections import namedtuple # pylint: disable=syntax-error from collections import namedtuple # pylint: disable=syntax-error
@@ -16,13 +19,45 @@ PostalAddress = namedtuple(
"sub_locality", "sub_locality",
"city", "city",
"sub_administrative_area", "sub_administrative_area",
"state", "state_province",
"postal_code", "postal_code",
"country", "country",
"iso_country_code", "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 # The following classes represent Photo Library Reverse Geolocation Info as stored
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA # in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
# These classes are used by bpylist.archiver to unarchive the serialized objects # 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) """ """ Reverse geolocation place info for a photo (Photos <= 4) """
def __init__(self, place_names, country_code): 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._place_names = place_names
self._country_code = country_code self._country_code = country_code
self._process_place_info()
@property @property
def address_str(self): def address_str(self):
@@ -341,11 +385,11 @@ class PlaceInfo4(PlaceInfo):
@property @property
def name(self): def name(self):
return self._place_names[0] return self._name
@property @property
def names(self): def names(self):
return self._place_names return self._names
@property @property
def address(self): def address(self):
@@ -360,6 +404,83 @@ class PlaceInfo4(PlaceInfo):
and self._country_code == other._country_code 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): def __ne__(self, other):
return not self.__eq__(other) return not self.__eq__(other)
@@ -382,6 +503,7 @@ class PlaceInfo5(PlaceInfo):
self._bplist = revgeoloc_bplist self._bplist = revgeoloc_bplist
# todo: check for None? # todo: check for None?
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist) self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
self._process_place_info()
@property @property
def address_str(self): def address_str(self):
@@ -401,22 +523,12 @@ class PlaceInfo5(PlaceInfo):
@property @property
def name(self): def name(self):
""" returns local place name """ """ returns local place name """
name = ( return self._name
self._plrevgeoloc.mapItem.sortedPlaceInfos[0].name
if self._plrevgeoloc.mapItem.sortedPlaceInfos
else None
)
return name
@property @property
def names(self): def names(self):
""" returns list of all place names in reverse order by area """ returns PlaceNames tuple with detailed reverse geolocation place names """
e.g. most local is at index 0, least local (usually country) is at index -1 """ return self._names
names = []
# todo: strip duplicates
for name in self._plrevgeoloc.mapItem.sortedPlaceInfos:
names.append(name.name)
return names
@property @property
def address(self): def address(self):
@@ -426,13 +538,72 @@ class PlaceInfo5(PlaceInfo):
sub_locality=addr._subLocality, sub_locality=addr._subLocality,
city=addr._city, city=addr._city,
sub_administrative_area=addr._subAdministrativeArea, sub_administrative_area=addr._subAdministrativeArea,
state=addr._state, state_province=addr._state,
postal_code=addr._postalCode, postal_code=addr._postalCode,
country=addr._country, country=addr._country,
iso_country_code=addr._ISOCountryCode, iso_country_code=addr._ISOCountryCode,
) )
return address 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): def __eq__(self, other):
if not isinstance(other, type(self)): if not isinstance(other, type(self)):
return False return False

View File

@@ -24,137 +24,251 @@ TEMPLATE_SUBSTITUTIONS = {
"{modified.month}": "Month name in user's locale of the file modification time", "{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.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)", "{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.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
"{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.country_code}": "The ISO country code from the photo's reverse geolocationo 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}": "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.address.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.address.city}": "City part of the postal address, e.g. 'Washington'",
"{place.state}": "State part of the postal address, e.g. 'DC'", "{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
"{place.postal_code}": "Postal code part of the postal address, e.g. '20009'", "{place.address.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.address.country}": "Country name of the postal address, e.g. 'United States'",
"{place.country_code}": "ISO country code of the postal address, e.g. 'US'", "{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
} }
def render_filename_template( def get_template_value(lookup, photo):
""" 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: str, photo: PhotoInfo, none_str: str = "_" template: str, photo: PhotoInfo, none_str: str = "_"
) -> Tuple[str, list]: ) -> Tuple[str, list]:
""" render a filename or directory template """ """ render a filename or directory template """
# pylint: disable=anomalous-backslash-in-string
regex = r"""(?<!\\)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)\}"""
# pylint: disable=anomalous-backslash-in-string
unmatched_regex = r"(?<!\\)(\{[^\\,}]+\})"
# Explanation for regex:
# (?<!\\) Negative Lookbehind to skip escaped braces
# assert regex following does not match "\" preceeding "{"
# \{ Match the opening brace
# 1st Capturing Group ([^\\,}]+) Don't match "\", ",", or "}"
# 2nd Capturing Group (,?(([\w\-. ]+))?)
# ,{0,1} optional ","
# 3rd Capturing Group (([\w\-. ]+))?
# Matches the comma and any word characters after
# 4th Capturing Group ([\w\-. ]+)
# Matches just the characters after the comma
# \} Matches the closing brace
if type(template) is not str: if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}") raise TypeError(f"template must be type str, not {type(template)}")
if type(photo) is not PhotoInfo: if type(photo) is not PhotoInfo:
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}") raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
rendered = template def make_subst_function(photo, none_str):
original_name = pathlib.Path(photo.original_filename).stem """ returns: substitution function for use in re.sub """
current_name = pathlib.Path(photo.filename).stem # closure to capture photo, none_str in subst
created = DateTimeFormatter(photo.date) def subst(matchobj):
if photo.date_modified: groups = len(matchobj.groups())
modified = DateTimeFormatter(photo.date_modified) if groups == 4:
else: try:
modified = None val = get_template_value(matchobj.group(1), photo)
except KeyError:
return matchobj.group(0)
# make substitutions if val is None:
rendered = rendered.replace("{name}", current_name) return (
rendered = rendered.replace("{original_name}", original_name) 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}"
)
title = photo.title if photo.title is not None else none_str return subst
rendered = rendered.replace("{title}", f"{title}")
descr = photo.description if photo.description is not None else none_str subst_func = make_subst_function(photo, none_str)
rendered = rendered.replace("{descr}", f"{descr}")
rendered = rendered.replace("{created.date}", photo.date.date().isoformat()) # do the replacements
rendered = rendered.replace("{created.year}", created.year) rendered = re.sub(regex, subst_func, template)
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)
if modified is not None: # find any {words} that weren't replaced
rendered = rendered.replace( unmatched = re.findall(unmatched_regex, rendered)
"{modified.date}", photo.date_modified.date().isoformat()
)
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 # fix any escaped curly braces
rendered = re.sub(r"\\{", "{", rendered) rendered = re.sub(r"\\{", "{", rendered)
rendered = re.sub(r"\\}", "}", rendered) rendered = re.sub(r"\\}", "}", rendered)
# find any {words} that weren't replaced return rendered, unmatched
unmatched = re.findall(r"{\w+}", rendered)
return (rendered, unmatched)
class DateTimeFormatter: class DateTimeFormatter:
@@ -163,6 +277,12 @@ class DateTimeFormatter:
def __init__(self, dt: datetime.datetime): def __init__(self, dt: datetime.datetime):
self.dt = dt self.dt = dt
@property
def date(self):
""" ISO date in form 2020-03-22 """
date = self.dt.date().isoformat()
return date
@property @property
def year(self): def year(self):
""" 4 digit year """ """ 4 digit year """

View File

@@ -1,12 +1,13 @@
import glob import glob
import logging import logging
import os.path import os.path
import pathlib
import platform import platform
import sqlite3 import sqlite3
import subprocess import subprocess
import sys
import tempfile import tempfile
import urllib.parse import urllib.parse
import pathlib
from plistlib import load as plistload from plistlib import load as plistload
import CoreFoundation import CoreFoundation

View File

@@ -3,8 +3,8 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key> <key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-03-19T20:25:48Z</date> <date>2020-03-27T04:00:09Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key> <key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-03-19T22:36:41Z</date> <date>2020-03-27T04:00:10Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key> <key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer> <integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key> <key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-03-15T20:18:33Z</date> <date>2020-03-27T03:59:54Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key> <key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date> <date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key> <key>SnapshotLastValidated</key>
<date>2020-03-19T20:27:27Z</date> <date>2020-03-27T04:02:59Z</date>
<key>SnapshotTables</key> <key>SnapshotTables</key>
<dict/> <dict/>
</dict> </dict>

View File

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

View File

@@ -3,24 +3,24 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BackgroundHighlightCollection</key> <key>BackgroundHighlightCollection</key>
<date>2020-03-22T12:51:16Z</date> <date>2020-03-28T16:32:26Z</date>
<key>BackgroundHighlightEnrichment</key> <key>BackgroundHighlightEnrichment</key>
<date>2020-03-22T12:51:16Z</date> <date>2020-03-28T16:32:25Z</date>
<key>BackgroundJobAssetRevGeocode</key> <key>BackgroundJobAssetRevGeocode</key>
<date>2020-03-22T12:51:17Z</date> <date>2020-03-28T16:32:26Z</date>
<key>BackgroundJobSearch</key> <key>BackgroundJobSearch</key>
<date>2020-03-22T12:51:17Z</date> <date>2020-03-28T16:32:26Z</date>
<key>BackgroundPeopleSuggestion</key> <key>BackgroundPeopleSuggestion</key>
<date>2020-03-22T12:51:16Z</date> <date>2020-03-28T16:32:25Z</date>
<key>BackgroundUserBehaviorProcessor</key> <key>BackgroundUserBehaviorProcessor</key>
<date>2020-03-22T07:13:26Z</date> <date>2020-03-28T07:30:06Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key> <key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-03-22T12:51:17Z</date> <date>2020-03-28T16:35:58Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key> <key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-03-22T07:13:26Z</date> <date>2020-03-28T07:30:06Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key> <key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-03-22T12:51:17Z</date> <date>2020-03-28T16:32:26Z</date>
<key>SiriPortraitDonation</key> <key>SiriPortraitDonation</key>
<date>2020-03-22T07:13:26Z</date> <date>2020-03-28T07:30:06Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -3,8 +3,8 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>FaceIDModelLastGenerationKey</key> <key>FaceIDModelLastGenerationKey</key>
<date>2020-03-22T07:13:27Z</date> <date>2020-03-28T07:30:06Z</date>
<key>LastContactClassificationKey</key> <key>LastContactClassificationKey</key>
<date>2020-03-22T07:13:29Z</date> <date>2020-03-28T07:30:08Z</date>
</dict> </dict>
</plist> </plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DatabaseMinorVersion</key>
<integer>1</integer>
<key>DatabaseVersion</key>
<integer>112</integer>
<key>LastOpenMode</key>
<integer>2</integer>
<key>LibrarySchemaVersion</key>
<integer>3301</integer>
<key>MetaSchemaVersion</key>
<integer>2</integer>
<key>createDate</key>
<date>2020-03-27T13:51:11Z</date>
</dict>
</plist>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Photos</key>
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>TopLevelAlbums</string>
<string>TopLevelSlideshows</string>
</array>
<key>lastKnownItemCounts</key>
<dict>
<key>other</key>
<integer>0</integer>
<key>photos</key>
<integer>6</integer>
<key>videos</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-03-27T13:51:13Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-03-27T13:51:13Z</date>
</dict>
</plist>

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IncrementalPersonProcessingStage</key>
<integer>4</integer>
<key>PersonBuilderLastMinimumFaceGroupSizeForCreatingMergeCandidates</key>
<integer>15</integer>
<key>PersonBuilderMergeCandidatesEnabled</key>
<true/>
</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>PLLanguageAndLocaleKey</key>
<string>en-US:en_US</string>
<key>PLLastGeoProviderIdKey</key>
<string>7618</string>
<key>PLLastLocationInfoFormatVer</key>
<integer>12</integer>
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-03-27T13:51:12Z</date>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>171</integer>
<key>LibraryBuildTag</key>
<string>9689BC67-F0CE-460F-B268-CF82E1F1BFC5</string>
<key>LibrarySchemaVersion</key>
<integer>3301</integer>
</dict>
</plist>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>FileVersion</key>
<integer>11</integer>
<key>Source</key>
<dict>
<key>35230</key>
<dict>
<key>CountryMinVersions</key>
<dict>
<key>OTHER</key>
<integer>1</integer>
</dict>
<key>CurrentVersion</key>
<integer>1</integer>
<key>NoResultErrorIsSuccess</key>
<true/>
</dict>
<key>57879</key>
<dict>
<key>CountryMinVersions</key>
<dict>
<key>OTHER</key>
<integer>1</integer>
</dict>
<key>CurrentVersion</key>
<integer>1</integer>
<key>NoResultErrorIsSuccess</key>
<true/>
</dict>
<key>7618</key>
<dict>
<key>AddCountyIfNeeded</key>
<true/>
<key>CountryMinVersions</key>
<dict>
<key>OTHER</key>
<integer>10</integer>
</dict>
<key>CurrentVersion</key>
<integer>10</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DatabaseMinorVersion</key>
<integer>1</integer>
<key>DatabaseVersion</key>
<integer>112</integer>
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>171</integer>
<key>LibraryBuildTag</key>
<string>9689BC67-F0CE-460F-B268-CF82E1F1BFC5</string>
<key>LibrarySchemaVersion</key>
<integer>3301</integer>
</dict>
<key>LibrarySchemaVersion</key>
<integer>3301</integer>
<key>MetaSchemaVersion</key>
<integer>2</integer>
<key>SnapshotComplete</key>
<true/>
<key>SnapshotCompletedDate</key>
<date>2020-03-27T13:51:11Z</date>
<key>SnapshotLastValidated</key>
<date>2020-03-27T13:51:11Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>
</plist>

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