Compare commits

..

37 Commits

Author SHA1 Message Date
Rhet Turnbull
173a0fce28 Added RenderOptions to {function} template, #496 2021-07-18 11:56:50 -07:00
Rhet Turnbull
b04ea8174d Added album_sort_order example 2021-07-18 09:07:16 -07:00
Rhet Turnbull
e40ecc45ad Update README.md 2021-07-18 07:57:34 -07:00
Rhet Turnbull
277b1614b9 Updated CHANGELOG.md [skip ci] 2021-07-16 20:09:48 -07:00
Rhet Turnbull
88099de688 Updated README.md [skip ci] 2021-07-16 20:07:50 -07:00
Rhet Turnbull
7d81b94c16 Upgraded osxmetadata to add new extended attributes 2021-07-16 19:45:19 -07:00
Rhet Turnbull
d627cfc4fa Update README.md 2021-07-15 20:25:28 -07:00
Rhet Turnbull
bf208bbe4b Updated tutorial with --regex example [skip ci] 2021-07-07 10:14:15 -07:00
Rhet Turnbull
79ba6f813f Updated CHANGELOG.md [skip ci] 2021-07-07 10:13:48 -07:00
Rhet Turnbull
141c0244e4 Added --selected, closes #489 2021-07-07 06:59:40 -07:00
Rhet Turnbull
7e0276beb7 Updated CHANGELOG.md [skip ci] 2021-07-06 22:03:48 -07:00
Rhet Turnbull
1bf11b0414 Fixed cleanup to delete empty folders, #491 2021-07-06 21:57:43 -07:00
allcontributors[bot]
c23f3fc5e4 docs: add mkirkland4874 as a contributor for example (#492)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-07-06 21:05:34 -07:00
Rhet Turnbull
016297d2ff Added example for {function} template 2021-07-06 21:00:16 -07:00
Rhet Turnbull
aa64283b55 Updated README.md [skip ci], closes #488 2021-07-06 12:17:02 -07:00
Rhet Turnbull
3973c27238 Updated CHANGELOG.md [skip ci] 2021-07-04 12:55:31 -07:00
Rhet Turnbull
2e32d62237 Added test for try/except block in cli export 2021-07-04 12:25:24 -07:00
Rhet Turnbull
d497b94ad5 Re-enabled try/except in cli export 2021-07-04 12:09:13 -07:00
Rhet Turnbull
8c09ae82a4 Updated CHANGELOG.md [skip ci] 2021-07-04 11:49:24 -07:00
Rhet Turnbull
632169f277 Added --preview-if-missing, #446 2021-07-04 10:03:36 -07:00
Rhet Turnbull
675371f0d7 Updated README.md [skip ci] 2021-07-04 08:41:58 -07:00
Rhet Turnbull
7e2d09bf12 Added --preview, #470 2021-07-04 08:39:06 -07:00
Rhet Turnbull
28c681aa96 Refactored export2, #485, #486 2021-07-03 22:50:03 -07:00
Rhet Turnbull
5d39aa92df Update README.md 2021-07-02 19:07:02 -07:00
Rhet Turnbull
b4dbad5e74 Fixed path_derivatives to always return jpeg if photo is a photo 2021-07-02 18:59:01 -07:00
Rhet Turnbull
b1b099257f Updated README.md [skip ci] 2021-07-02 13:27:22 -07:00
Rhet Turnbull
63e8410841 Updated CHANGELOG.md [skip ci] 2021-07-02 13:23:48 -07:00
Rhet Turnbull
2e1c91cd67 Added get_selected() to REPL 2021-07-02 13:19:15 -07:00
Rhet Turnbull
391b0a577b Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-07-02 13:01:28 -07:00
Rhet Turnbull
1d26ac9630 Removed _applescript, #461 2021-07-02 13:01:09 -07:00
Rhet Turnbull
03b4f59549 Removed _applescript, #461 2021-07-02 13:00:40 -07:00
Rhet Turnbull
9aa3ac3640 Updated CHANGELOG.md [skip ci] 2021-07-02 12:48:23 -07:00
Rhet Turnbull
6339e3c70e Updated README.md [skip ci] 2021-07-02 12:43:17 -07:00
Rhet Turnbull
4cc3220287 Fix for path_raw when file is reference, #480 2021-07-02 12:39:41 -07:00
Rhet Turnbull
f32c4f4acd Updated CHANGELOG.md [skip ci] 2021-06-30 22:54:34 -07:00
allcontributors[bot]
aba2ce0923 docs: add jcommisso07 as a contributor for data (#483)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-06-30 22:52:51 -07:00
allcontributors[bot]
c209ceae2e docs: add mkirkland4874 as a contributor for bug (#482)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-06-30 22:49:50 -07:00
27 changed files with 1269 additions and 2581 deletions

View File

@@ -222,6 +222,25 @@
"contributions": [
"bug"
]
},
{
"login": "mkirkland4874",
"name": "mkirkland4874",
"avatar_url": "https://avatars.githubusercontent.com/u/36466711?v=4",
"profile": "https://github.com/mkirkland4874",
"contributions": [
"bug",
"example"
]
},
{
"login": "jcommisso07",
"name": "Joseph Commisso",
"avatar_url": "https://avatars.githubusercontent.com/u/3111054?v=4",
"profile": "https://github.com/jcommisso07",
"contributions": [
"data"
]
}
],
"contributorsPerLine": 7,

View File

@@ -4,6 +4,83 @@ 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.42.62](https://github.com/RhetTbull/osxphotos/compare/v0.42.61...v0.42.62)
> 16 July 2021
- Upgraded osxmetadata to add new extended attributes [`7d81b94`](https://github.com/RhetTbull/osxphotos/commit/7d81b94c16623d11312aaf1b0c47fb580d01bc66)
- Updated tutorial with --regex example [skip ci] [`bf208bb`](https://github.com/RhetTbull/osxphotos/commit/bf208bbe4b965a2d39fc1836335b7b65f402af30)
- Update README.md [`d627cfc`](https://github.com/RhetTbull/osxphotos/commit/d627cfc4fa22497769babc3d686393c6043d1f37)
#### [v0.42.61](https://github.com/RhetTbull/osxphotos/compare/v0.42.60...v0.42.61)
> 7 July 2021
- Added --selected, closes #489 [`#489`](https://github.com/RhetTbull/osxphotos/issues/489)
#### [v0.42.60](https://github.com/RhetTbull/osxphotos/compare/v0.42.59...v0.42.60)
> 6 July 2021
- docs: add mkirkland4874 as a contributor for example [`#492`](https://github.com/RhetTbull/osxphotos/pull/492)
- Updated README.md [skip ci], closes #488 [`#488`](https://github.com/RhetTbull/osxphotos/issues/488)
- Added example for {function} template [`016297d`](https://github.com/RhetTbull/osxphotos/commit/016297d2ffcf2e8db0d659ccfe7411ecff3dd41b)
- Fixed cleanup to delete empty folders, #491 [`1bf11b0`](https://github.com/RhetTbull/osxphotos/commit/1bf11b0414a7fcf785c792b98f6231821bdad4d4)
#### [v0.42.59](https://github.com/RhetTbull/osxphotos/compare/v0.42.58...v0.42.59)
> 4 July 2021
- Re-enabled try/except in cli export [`d497b94`](https://github.com/RhetTbull/osxphotos/commit/d497b94ad506bf6cf044bbabe7fcbf4ab9d5b9e7)
- Added test for try/except block in cli export [`2e32d62`](https://github.com/RhetTbull/osxphotos/commit/2e32d62237f59b16a9be422104347d6a1332865c)
#### [v0.42.58](https://github.com/RhetTbull/osxphotos/compare/v0.42.57...v0.42.58)
> 4 July 2021
- Added --preview-if-missing, #446 [`632169f`](https://github.com/RhetTbull/osxphotos/commit/632169f2774558ef8487eb7fb9323aecbadedd88)
#### [v0.42.57](https://github.com/RhetTbull/osxphotos/compare/v0.42.54...v0.42.57)
> 4 July 2021
- Refactored export2, #485, #486 [`28c681a`](https://github.com/RhetTbull/osxphotos/commit/28c681aa96874588bc59335b2a0db3b8be6eabaa)
- Added --preview, #470 [`7e2d09b`](https://github.com/RhetTbull/osxphotos/commit/7e2d09bf123428c09a669d8d581e1a35e374273d)
- Fixed path_derivatives to always return jpeg if photo is a photo [`b4dbad5`](https://github.com/RhetTbull/osxphotos/commit/b4dbad5e7451447480699105fb62b157dce8195d)
#### [v0.42.54](https://github.com/RhetTbull/osxphotos/compare/v0.42.52...v0.42.54)
> 2 July 2021
- Removed _applescript, #461 [`1d26ac9`](https://github.com/RhetTbull/osxphotos/commit/1d26ac9630dd0a414c01cc4f89a080e4efd7fd97)
- Removed _applescript, #461 [`03b4f59`](https://github.com/RhetTbull/osxphotos/commit/03b4f59549de54da91c36feba613d69f9e86e47b)
- Added get_selected() to REPL [`2e1c91c`](https://github.com/RhetTbull/osxphotos/commit/2e1c91cd672eefe84063933437e5d691f5ad1db1)
#### [v0.42.52](https://github.com/RhetTbull/osxphotos/compare/v0.42.51...v0.42.52)
> 2 July 2021
- docs: add jcommisso07 as a contributor for data [`#483`](https://github.com/RhetTbull/osxphotos/pull/483)
- docs: add mkirkland4874 as a contributor for bug [`#482`](https://github.com/RhetTbull/osxphotos/pull/482)
- Fix for path_raw when file is reference, #480 [`4cc3220`](https://github.com/RhetTbull/osxphotos/commit/4cc322028790b3beefce42af5e35c23976b1a35a)
- Updated README.md [skip ci] [`6339e3c`](https://github.com/RhetTbull/osxphotos/commit/6339e3c70ee174394af356710de4bf9442bad9fc)
#### [v0.42.51](https://github.com/RhetTbull/osxphotos/compare/v0.42.46...v0.42.51)
> 30 June 2021
- Alpha support for Monterey/macOS 12 [`08147e9`](https://github.com/RhetTbull/osxphotos/commit/08147e91d92013c9cd179187a447f81bc08de3af)
- Refactored UTI utils to get ready for Monterey [`d034605`](https://github.com/RhetTbull/osxphotos/commit/d0346057843aae3a72a79695819df31385db596f)
- Updated photokit code to work with raw+jpeg, #478 [`a73db3a`](https://github.com/RhetTbull/osxphotos/commit/a73db3a1bbc2a320d68dcf7f31f1074bc23a242a)
#### [v0.42.46](https://github.com/RhetTbull/osxphotos/compare/v0.42.45...v0.42.46)
> 23 June 2021
- Bug fix for template functions #477 [`4931758`](https://github.com/RhetTbull/osxphotos/commit/49317582c4582e291463d368425513b09a799058)
- Updated README.md [skip ci] [`64fd852`](https://github.com/RhetTbull/osxphotos/commit/64fd85253508b51c3f945f4c8ff02585f1b90aab)
- Fixed deprecation warning [`3fbfc55`](https://github.com/RhetTbull/osxphotos/commit/3fbfc55e84756844070f4080ce415ba77d5c7665)
#### [v0.42.45](https://github.com/RhetTbull/osxphotos/compare/v0.42.44...v0.42.45)
> 20 June 2021

113
README.md
View File

@@ -3,9 +3,8 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![tests](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/osxphotos)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-23-orange.svg?style=flat)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Downloads](https://static.pepy.tech/personalized-badge/osxphotos?period=month&units=international_system&left_color=black&right_color=brightgreen&left_text=downloads/month)](https://pepy.tech/project/osxphotos)
[![All Contributors](https://img.shields.io/badge/all_contributors-25-orange.svg?style=flat)](#contributors)
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
@@ -411,6 +410,12 @@ To export only photos contained in the album "Summer Vacation":
`osxphotos export /path/to/export --album "Summer Vacation"`
In Photos, it's possible to have multiple albums with the same name. In this case, osxphotos would export photos from all albums matching the value passed to `--album`. If you wanted to export only one of the albums and this album is in a folder, the `--regex` option (short for "regular expression"), which does pattern matching, could be used with the `{folder_album}` template to match the specific album. For example, if you had a "Summer Vacation" album inside the folder "2018" and also one with the same name inside the folder "2019", you could export just the album "2018/Summer Vacation" using this command:
`osxphotos export /path/to/export --regex "2018/Summer Vacation" "{folder_album}"`
This command matches the pattern "2018/Summer Vacation" against the full folder/album path for every photo.
There are also a number of query options to export only certain types of photos. For example, to export only photos taken with iPhone "Portrait Mode":
`osxphotos export /path/to/export --portrait`
@@ -755,6 +760,9 @@ Options:
more than one regular expression match by
repeating '--regex' with different arguments.
--selected Filter for photos that are currently selected
in Photos.
--query-eval CRITERIA Evaluate CRITERIA to filter photos. CRITERIA
will be evaluated in context of the following
python list comprehension: `photos = [photo
@@ -891,6 +899,28 @@ Options:
best quality, a value of 0.0 specifies maximum
compression. Defaults to 1.0
--preview Export preview image generated by Photos. This
is a lower-resolution image used by Photos to
quickly preview the image. See also --preview-
suffix and --preview-if-missing.
--preview-if-missing Export preview image generated by Photos if
the actual photo file is missing from the
library. This may be helpful if photos were
not copied to the Photos library and the
original photo is missing. See also --preview-
suffix and --preview.
--preview-suffix SUFFIX Optional suffix template for naming preview
photos. Default name for preview photos is in
form 'photoname_preview.ext'. For example,
with '--preview-suffix _low_res', the preview
photo would be named 'photoname_low_res.ext'.
The default suffix is '_preview'. Multi-value
templates (see Templating System) are not
permitted with --preview-suffix. See also
--preview and --preview-if-missing.
--download-missing Attempt to download missing photos from
iCloud. The current implementation uses
Applescript to interact with Photos to export
@@ -1049,8 +1079,10 @@ Options:
--xattr-template ATTRIBUTE TEMPLATE
Set extended attribute ATTRIBUTE to TEMPLATE
value. Valid attributes are: 'authors',
'comment', 'copyright', 'description',
'findercomment', 'headline', 'keywords'. For
'comment', 'copyright', 'creator',
'description', 'findercomment', 'headline',
'keywords', 'participants', 'projects',
'rating', 'subject', 'title', 'version'. For
example, to set Finder comment to the photo's
title and description: '--xattr-template
findercomment "{title}; {descr}" See Extended
@@ -1282,6 +1314,10 @@ comment A comment related to the file. This differs from the Finder
copyright The copyright owner of the file contents. A string.
(com.apple.metadata:kMDItemCopyright)
creator Application used to create the document content (for example
“Word”, “Pages”, and so on). A string.
(com.apple.metadata:kMDItemCreator)
description A description of the content of the resource. The description
may include an abstract, table of contents, reference to a
graphical representation of content or a free-text account of
@@ -1299,6 +1335,29 @@ keywords Keywords associated with this file. For example, “Birthday”,
and searchable in Spotlight using "tag:tag_name". A list of
strings. (com.apple.metadata:kMDItemKeywords)
participants The list of people who are visible in an image or movie or
written about in a document. A list of strings.
(com.apple.metadata:kMDItemParticipants)
projects The list of projects that this file is part of. For example, if
you were working on a movie all of the files could be marked as
belonging to the project “My Movie”. A list of strings.
(com.apple.metadata:kMDItemProjects)
rating User rating of this item. For example, the stars rating of an
iTunes track. An integer.
(com.apple.metadata:kMDItemStarRating)
subject Subject of the this item. A string.
(com.apple.metadata:kMDItemSubject)
title The title of the file. For example, this could be the title of
a document, the name of a song, or the subject of an email
message. A string. (com.apple.metadata:kMDItemTitle)
version The version number of this file. A string.
(com.apple.metadata:kMDItemVersion)
For additional information on extended attributes see: https://developer.apple.c
om/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_key
@@ -1756,7 +1815,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.42.51'
{osxphotos_version} The osxphotos version, e.g. '0.42.62'
{osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for
@@ -2492,7 +2551,7 @@ Returns the absolute path to the edited photo on disk as a string. If the photo
**Note**: will also return None if the edited photo is missing on disk.
#### `path_derivatives`
Returns list of paths to any derivative preview images associated with the photo. The list of returned paths is sorted in descieding order by size (the largest, presumably highest quality) preview image will be the first element in the returned list. These will be named something like this on Photos 5+:
Returns list of paths to any derivative preview images associated with the photo. The list of returned paths is sorted in descending order by size (the largest, presumably highest quality) preview image will be the first element in the returned list. These will be named something like this on Photos 5+:
- `F19E06B8-A712-4B5C-907A-C007D37BDA16_1_101_o.jpeg`
- `F19E06B8-A712-4B5C-907A-C007D37BDA16_1_102_o.jpeg`
@@ -2670,6 +2729,9 @@ Returns the path to the live video component of a [live photo](#live_photo). If
**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud.
#### `path_edited_live_photo`
Returns the path to the edited live video component of an edited [live photo](#live_photo). If photo is not a live photo or not edited, returns None.
#### `portrait`
Returns True if photo was taken in iPhone portrait mode, otherwise False.
@@ -2781,11 +2843,11 @@ Returns a JSON representation of all photo info.
Returns a dictionary representation of all photo info.
#### `export()`
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
`export(dest, filename=None, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).
- *filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
- filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- export_as_hardlink: boolean; if True (default=False), will hardlink files instead of copying them
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
@@ -2820,24 +2882,31 @@ Then
If overwrite=False and increment=False, export will fail if destination file already exists
#### <a name="rendertemplate">`render_template()`</a>
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None, filename=False, dirname=False, strip=False)`
#### <a name="rendertemplate">`render_template(template_str, options=None)`</a>
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template_str`: str in osxphotos template language (OTL) format. See also [Template System](#template-system) table. See notes below regarding specific details of the syntax.
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
- `path_sep`: optional character to use as path separator when joining path like fields such as `{folder_album}`; default is `os.path.sep`. May also be provided in the template itself. If provided both in the call to `render_template()` and in the template itself, the value in the template string takes precedence.
- `expand_inplace`: expand multi-valued substitutions in-place as a single string instead of returning individual strings
- `inplace_sep`: optional string to use as separator between multi-valued keywords with expand_inplace; default is ','
- `filename`: if True, template output will be sanitized to produce valid file name
- `dirname`: if True, template output will be sanitized to produce valid directory name
- `strip`: if True, leading/trailign whitespace will be stripped from rendered template strings
- `options`: an optional osxphotos.phototemplate.RenderOptions object specifying the options to pass to the rendering engine.
`RenderOptions` has the following properties:
- template: str template
- none_str: str to use default for None values, default is '_'
- path_sep: optional string to use as path separator, default is os.path.sep
- expand_inplace: expand multi-valued substitutions in-place as a single string instead of returning individual strings
- inplace_sep: optional string to use as separator between multi-valued keywords with expand_inplace; default is ','
- filename: if True, template output will be sanitized to produce valid file name
- dirname: if True, template output will be sanitized to produce valid directory name
- strip: if True, strips leading/trailing whitespace from rendered templates
- edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
- export_dir: set to the export directory if you want to evalute {export_dir} template
- filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
- quote: quote path templates for execution in the shell
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"]. If there are unmatched strings, rendered will be []. E.g. a template statement must fully match or will result in error and return all unmatched fields in unmatched.
e.g. `render_template("{created.year}/{foo}", photo)` would return `([],["foo"])`
e.g. `photo.render_template("{created.year}/{foo}")` would return `([],["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"]`
@@ -3578,7 +3647,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.51'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.62'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{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|
@@ -3794,6 +3863,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<tr>
<td align="center"><a href="http://blog.dewost.com/"><img src="https://avatars.githubusercontent.com/u/17090228?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Philippe Dewost</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=pdewost" title="Documentation">📖</a> <a href="#example-pdewost" title="Examples">💡</a> <a href="#ideas-pdewost" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/kaduskj"><img src="https://avatars.githubusercontent.com/u/983067?v=4?s=75" width="75px;" alt=""/><br /><sub><b>kaduskj</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Akaduskj" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/mkirkland4874"><img src="https://avatars.githubusercontent.com/u/36466711?v=4?s=75" width="75px;" alt=""/><br /><sub><b>mkirkland4874</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Amkirkland4874" title="Bug reports">🐛</a> <a href="#example-mkirkland4874" title="Examples">💡</a></td>
<td align="center"><a href="https://github.com/jcommisso07"><img src="https://avatars.githubusercontent.com/u/3111054?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Joseph Commisso</b></sub></a><br /><a href="#data-jcommisso07" title="Data">🔣</a></td>
</tr>
</table>

View File

@@ -0,0 +1,98 @@
""" Example function for use with osxphotos export --post-function option showing how to record album sort order """
import pathlib
from typing import Optional
from osxphotos import ExportResults, PhotoInfo
from osxphotos.albuminfo import AlbumInfo
def _get_album_sort_order(album: AlbumInfo, photo: PhotoInfo) -> Optional[int]:
"""Get the sort order of photo in album
Returns: sort order as int or None if photo not found in album
"""
# get the album sort order from the album_info
sort_order = 0 # change this to 1 if you want counting to start at 1
for album_photo in album.photos:
if album_photo.uuid == photo.uuid:
# found the photo we're processing
break
sort_order += 1
else:
# didn't find the photo, so skip this file
return None
return sort_order
def album_sort_order(
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
):
"""Call this with osxphotos export /path/to/export --post-function post_function.py::post_function
This will get called immediately after the photo has been exported
Args:
photo: PhotoInfo instance for the photo that's just been exported
results: ExportResults instance with information about the files associated with the exported photo
verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed)
**kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions
Notes:
Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag
Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color
Will not be called if --dry-run flag is enabled
Will be called immediately after export and before any --post-command commands are executed
"""
# ExportResults has the following properties
# fields with filenames contain the full path to the file
# exported: list of all files exported
# new: list of all new files exported (--update)
# updated: list of all files updated (--update)
# skipped: list of all files skipped (--update)
# exif_updated: list of all files that were updated with --exiftool
# touched: list of all files that had date updated with --touch-file
# converted_to_jpeg: list of files converted to jpeg with --convert-to-jpeg
# sidecar_json_written: list of all JSON sidecar files written
# sidecar_json_skipped: list of all JSON sidecar files skipped (--update)
# sidecar_exiftool_written: list of all exiftool sidecar files written
# sidecar_exiftool_skipped: list of all exiftool sidecar files skipped (--update)
# sidecar_xmp_written: list of all XMP sidecar files written
# sidecar_xmp_skipped: list of all XMP sidecar files skipped (--update)
# missing: list of all missing files
# error: list tuples of (filename, error) for any errors generated during export
# exiftool_warning: list of tuples of (filename, warning) for any warnings generated by exiftool with --exiftool
# exiftool_error: list of tuples of (filename, error) for any errors generated by exiftool with --exiftool
# xattr_written: list of files that had extended attributes written
# xattr_skipped: list of files that where extended attributes were skipped (--update)
# deleted_files: list of deleted files
# deleted_directories: list of deleted directories
# exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album
# skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album
# missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album
for filepath in results.exported:
# do your processing here
filepath = pathlib.Path(filepath)
album_dir = filepath.parent.name
if album_dir not in photo.albums:
return
# get the first album that matches this name of which the photo is a member
album_info = None
for album in photo.album_info:
if album.title == album_dir:
album_info = album
break
else:
# didn't find the album, so skip this file
return
sort_order = _get_album_sort_order(album_info, photo)
if sort_order is None:
# didn't find the photo, so skip this file
return
verbose(f"Sort order for {filepath} in album {album_dir} is {sort_order}")
with open(str(filepath) + "_sort_order.txt", "w") as f:
f.write(str(sort_order))

173
examples/export_template.py Normal file
View File

@@ -0,0 +1,173 @@
""" Example showing how to use a custom function for osxphotos {function} template
to export photos in a folder structure similar to Photos' own structure
Use: osxphotos export /path/to/export --directory "{function:/path/to/export_template.py::photos_folders}"
This will likely export multiple copies of each photo. If using APFS file system, this should be
a non-issue as osxphotos will use copy-on-write so each exported photo doesn't take up additional space
unless you edit the photo.
Thank-you @mkirkland4874 for the inspiration for this example!
This will produce output similar to this:
Library
- Photos
-- {created.year}
---- {created.mm}
------ {created.dd}
- Favorites
- Hidden
- Recently Deleted
- People
- Places
- Imports
Media Types
- Videos
- Selfies
- Portrait
- Panoramas
- Time-lapse
- Slow-mo
- Bursts
- Screenshots
My Albums
-- Album 1
-- Album 2
-- Folder 1
---- Album 3
Shared Albums
-- Shared Album 1
-- Shared Album 2
"""
from typing import List, Union
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.datetime_formatter import DateTimeFormatter
from osxphotos.path_utils import sanitize_dirname
from osxphotos.phototemplate import RenderOptions
def place_folder(photo: osxphotos.PhotoInfo) -> str:
"""Return places as folder in format Country/State/City/etc."""
if not photo.place:
return ""
places = []
if photo.place.names.country:
places.append(photo.place.names.country[0])
if photo.place.names.state_province:
places.append(photo.place.names.state_province[0])
if photo.place.names.sub_administrative_area:
places.append(photo.place.names.sub_administrative_area[0])
if photo.place.names.additional_city_info:
places.append(photo.place.names.additional_city_info[0])
if photo.place.names.area_of_interest:
places.append(photo.place.names.area_of_interest[0])
if places:
return "Library/Places/" + "/".join(sanitize_dirname(place) for place in places)
else:
return ""
def photos_folders(photo: osxphotos.PhotoInfo, options: osxphotos.phototemplate.RenderOptions, **kwargs) -> Union[List, str]:
"""template function for use with --directory to export photos in a folder structure similar to Photos
Args:
photo: osxphotos.PhotoInfo object
options: RenderOptions instance
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
Returns: list of directories for each photo
"""
rendered_date, _ = photo.render_template("{created.year}/{created.mm}/{created.dd}")
date_path = rendered_date[0]
def add_date_path(path):
"""add date path (year/mm/dd)"""
return f"{path}/{date_path}"
# Library
directories = []
if not photo.hidden and not photo.intrash and not photo.shared:
# set directories to [Library/Photos/year/mm/dd]
# render_template returns a tuple of [rendered value(s)], [unmatched]
# here, we can ignore the unmatched value, assigned to _, as we know template will match
directories, _ = photo.render_template(
"Library/Photos/{created.year}/{created.mm}/{created.dd}"
)
if photo.favorite:
directories.append(add_date_path("Library/Favorites"))
if photo.hidden:
directories.append(add_date_path("Library/Hidden"))
if photo.intrash:
directories.append(add_date_path("Library/Recently Deleted"))
directories.extend(
[
add_date_path(f"Library/People/{person}")
for person in photo.persons
if person != _UNKNOWN_PERSON
]
)
if photo.place:
directories.append(add_date_path(place_folder(photo)))
if photo.import_info:
dt = DateTimeFormatter(photo.import_info.creation_date)
directories.append(f"Library/Imports/{dt.year}/{dt.mm}/{dt.dd}")
# Media Types
if photo.ismovie:
directories.append(add_date_path("Media Types/Videos"))
if photo.selfie:
directories.append(add_date_path("Media Types/Selfies"))
if photo.live_photo:
directories.append(add_date_path("Media Types/Live Photos"))
if photo.portrait:
directories.append(add_date_path("Media Types/Portrait"))
if photo.panorama:
directories.append(add_date_path("Media Types/Panoramas"))
if photo.time_lapse:
directories.append(add_date_path("Media Types/Time-lapse"))
if photo.slow_mo:
directories.append(add_date_path("Media Types/Slo-mo"))
if photo.burst:
directories.append(add_date_path("Media Types/Bursts"))
if photo.screenshot:
directories.append(add_date_path("Media Types/Screenshots"))
# Albums
# render the folders and albums in folder/subfolder/album format
# the __NO_ALBUM__ is used as a sentinel to strip out photos not in an album
# use RenderOptions.dirname to force the rendered folder_album value to be sanitized as a valid path
# use RenderOptions.none_str to specify custom value for any photo that doesn't belong to an album so
# those can be filtered out; if not specified, none_str is "_"
folder_albums, _ = photo.render_template(
"{folder_album}", RenderOptions(dirname=True, none_str="__NO_ALBUM__")
)
root_directory = "Shared Albums/" if photo.shared else "My Albums/"
directories.extend(
[
root_directory + folder_album
for folder_album in folder_albums
if folder_album != "__NO_ALBUM__"
]
)
return directories

View File

@@ -1,162 +0,0 @@
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
import sys
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
from . import kae
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
######################################################################
class AppleScript:
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
"""
_codecs = Codecs()
def __init__(self, source=None, path=None):
"""
source : str | None -- AppleScript source code
path : str | None -- full path to .scpt/.applescript file
Notes:
- Either the path or the source argument must be provided.
- If the script cannot be read/compiled, a ScriptError is raised.
"""
if path:
url = NSURL.fileURLWithPath_(path)
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
if errorinfo:
raise ScriptError(errorinfo)
elif source:
self._script = NSAppleScript.alloc().initWithSource_(source)
else:
raise ValueError("Missing source or path argument.")
if not self._script.isCompiled():
errorinfo = self._script.compileAndReturnError_(None)[1]
if errorinfo:
raise ScriptError(errorinfo)
def __repr__(self):
s = self.source
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
##
def _newevent(self, suite, code, args):
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
return evt
def _unpackresult(self, result, errorinfo):
if not result:
raise ScriptError(errorinfo)
return self._codecs.unpack(result)
##
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
def run(self, *args):
""" Run the script, optionally passing arguments to its run handler.
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
Result : anything | None -- the script's return value, if any
Notes:
- The run handler must be explicitly declared in order to pass arguments.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
if args:
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
else:
return self._unpackresult(*self._script.executeAndReturnError_(None))
def call(self, name, *args):
""" Call the specified user-defined handler.
name : str -- the handler's name (case-sensitive)
args : anything -- arguments to pass to script, if any; see documentation for supported types
Result : anything | None -- the script's return value, if any
Notes:
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
fourcharcode(kae.keyASSubroutineName))
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
##
class ScriptError(Exception):
""" Indicates an AppleScript compilation/execution error. """
def __init__(self, errorinfo):
self._errorinfo = dict(errorinfo)
def __repr__(self):
return 'ScriptError({})'.format(self._errorinfo)
@property
def message(self):
""" str -- the error message """
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
if not msg:
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
return msg
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
doc="int | None -- the error number, if given")
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
doc="str | None -- the name of the application that reported the error, where relevant")
@property
def range(self):
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
range = self._errorinfo.get(NSAppleScriptErrorRange)
if range:
start = range.rangeValue().location
end = start + range.rangeValue().length
return (start, end)
else:
return None
def __str__(self):
msg = self.message
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
if v is not None:
msg += s.format(v)
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
##
kMissingValue = AEType(kae.cMissingValue) # convenience constant

View File

@@ -1,269 +0,0 @@
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
import datetime, struct, sys
from Foundation import NSAppleEventDescriptor, NSURL
from . import kae
__all__ = ['Codecs', 'AEType', 'AEEnum']
######################################################################
def fourcharcode(code):
""" Convert four-char code for use in NSAppleEventDescriptor methods.
code : bytes -- four-char code, e.g. b'utxt'
Result : int -- OSType, e.g. 1970567284
"""
return struct.unpack('>I', code)[0]
#######
class Codecs:
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
kMacEpoch = datetime.datetime(1904, 1, 1)
kUSRF = fourcharcode(kae.keyASUserRecordFields)
def __init__(self):
# Clients may add/remove/replace encoder and decoder items:
self.encoders = {
NSAppleEventDescriptor.class__(): self.packdesc,
type(None): self.packnone,
bool: self.packbool,
int: self.packint,
float: self.packfloat,
bytes: self.packbytes,
str: self.packstr,
list: self.packlist,
tuple: self.packlist,
dict: self.packdict,
datetime.datetime: self.packdatetime,
AEType: self.packtype,
AEEnum: self.packenum,
}
if sys.version_info.major < 3: # 2.7 compatibility
self.encoders[unicode] = self.packstr
self.decoders = {fourcharcode(k): v for k, v in {
kae.typeNull: self.unpacknull,
kae.typeBoolean: self.unpackboolean,
kae.typeFalse: self.unpackboolean,
kae.typeTrue: self.unpackboolean,
kae.typeSInt32: self.unpacksint32,
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
kae.typeUTF8Text: self.unpackunicodetext,
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
kae.typeUnicodeText: self.unpackunicodetext,
kae.typeLongDateTime: self.unpacklongdatetime,
kae.typeAEList: self.unpackaelist,
kae.typeAERecord: self.unpackaerecord,
kae.typeAlias: self.unpackfile,
kae.typeFSS: self.unpackfile,
kae.typeFSRef: self.unpackfile,
kae.typeFileURL: self.unpackfile,
kae.typeType: self.unpacktype,
kae.typeEnumeration: self.unpackenumeration,
}.items()}
def pack(self, data):
"""Pack Python data.
data : anything -- a Python value
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
"""
try:
return self.encoders[data.__class__](data) # quick lookup by type/class
except (KeyError, AttributeError) as e:
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
if isinstance(data, type):
return encoder(data)
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
def unpack(self, desc):
"""Unpack an Apple event descriptor.
desc : NSAppleEventDescriptor
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
"""
decoder = self.decoders.get(desc.descriptorType())
# unpack known type
if decoder:
return decoder(desc)
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
if rec:
rec = self.unpackaerecord(rec)
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
return rec
# return as-is
return desc
##
def _packbytes(self, desctype, data):
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
fourcharcode(desctype), data, len(data))
def packdesc(self, val):
return val
def packnone(self, val):
return NSAppleEventDescriptor.nullDescriptor()
def packbool(self, val):
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
def packint(self, val):
if (-2**31) <= val < (2**31):
return NSAppleEventDescriptor.descriptorWithInt32_(val)
else:
return self.pack(float(val))
def packfloat(self, val):
return self._packbytes(kae.typeFloat, struct.pack('d', val))
def packbytes(self, val):
return self._packbytes(kae.typeData, val)
def packstr(self, val):
return NSAppleEventDescriptor.descriptorWithString_(val)
def packdatetime(self, val):
delta = val - self.kMacEpoch
sec = delta.days * 3600 * 24 + delta.seconds
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
def packlist(self, val):
lst = NSAppleEventDescriptor.listDescriptor()
for item in val:
lst.insertDescriptor_atIndex_(self.pack(item), 0)
return lst
def packdict(self, val):
record = NSAppleEventDescriptor.recordDescriptor()
usrf = desctype = None
for key, value in val.items():
if isinstance(key, AEType):
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
desctype = value
else:
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
else:
if not usrf:
usrf = NSAppleEventDescriptor.listDescriptor()
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
if usrf:
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
if desctype:
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
if newrecord:
record = newrecord
else: # coercion failed for some reason, so pack as normal key-value pair
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
return record
def packtype(self, val):
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
def packenum(self, val):
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
#######
def unpacknull(self, desc):
return None
def unpackboolean(self, desc):
return desc.booleanValue()
def unpacksint32(self, desc):
return desc.int32Value()
def unpackfloat64(self, desc):
return struct.unpack('d', bytes(desc.data()))[0]
def unpackunicodetext(self, desc):
return desc.stringValue()
def unpacklongdatetime(self, desc):
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
def unpackaelist(self, desc):
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
def unpackaerecord(self, desc):
dct = {}
for i in range(desc.numberOfItems()):
key = desc.keywordForDescriptorAtIndex_(i + 1)
value = desc.descriptorForKeyword_(key)
if key == self.kUSRF:
lst = self.unpackaelist(value)
for i in range(0, len(lst), 2):
dct[lst[i]] = lst[i+1]
else:
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
return dct
def unpacktype(self, desc):
return AEType(struct.pack('>I', desc.typeCodeValue()))
def unpackenumeration(self, desc):
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
def unpackfile(self, desc):
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
return NSURL.URLWithString_(url).path()
#######
class AETypeBase:
""" Base class for AEType and AEEnum.
Notes:
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
"""
def __init__(self, code):
"""
code : bytes -- four-char code, e.g. b'utxt'
"""
if not isinstance(code, bytes):
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
elif len(code) != 4:
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
self._code = code
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
def __hash__(self):
return hash(self._code)
def __eq__(self, val):
return val.__class__ == self.__class__ and val.code == self._code
def __ne__(self, val):
return not self == val
def __repr__(self):
return "{}({!r})".format(self.__class__.__name__, self._code)
##
class AEType(AETypeBase):
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
class AEEnum(AETypeBase):
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""

File diff suppressed because it is too large Load Diff

View File

@@ -210,6 +210,9 @@ DEFAULT_EDITED_SUFFIX = "_edited"
# Default suffix to add to original images
DEFAULT_ORIGINAL_SUFFIX = ""
# Default suffix to add to preview images
DEFAULT_PREVIEW_SUFFIX = "_preview"
# Colors for print CLI messages
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"
@@ -224,10 +227,17 @@ EXTENDED_ATTRIBUTE_NAMES = [
"authors",
"comment",
"copyright",
"creator",
"description",
"findercomment",
"headline",
"keywords",
"participants",
"projects",
"rating",
"subject",
"title",
"version",
]
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.51"
__version__ = "0.42.63"

View File

@@ -16,6 +16,8 @@ import time
import bitmath
import click
import osxmetadata
import photoscript
import rich.traceback
import yaml
from rich import pretty
@@ -31,6 +33,7 @@ from ._constants import (
DEFAULT_EDITED_SUFFIX,
DEFAULT_JPEG_QUALITY,
DEFAULT_ORIGINAL_SUFFIX,
DEFAULT_PREVIEW_SUFFIX,
EXTENDED_ATTRIBUTE_NAMES,
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
OSXPHOTOS_EXPORT_DB,
@@ -64,6 +67,8 @@ from .utils import expand_and_validate_filepath, load_function
# set via --verbose/-V
VERBOSE = False
rich.traceback.install()
def verbose_(*args, **kwargs):
"""print output if verbose flag set"""
@@ -531,6 +536,11 @@ def QUERY_OPTIONS(f):
"For example, to find photos in an album that begins with 'Beach': '--regex \"^Beach\" \"{album}\"'. "
"You may specify more than one regular expression match by repeating '--regex' with different arguments.",
),
o(
"--selected",
is_flag=True,
help="Filter for photos that are currently selected in Photos.",
),
o(
"--query-eval",
metavar="CRITERIA",
@@ -700,6 +710,29 @@ def cli(ctx, db, json_, debug):
"a value of 0.0 specifies maximum compression. "
f"Defaults to {DEFAULT_JPEG_QUALITY}",
)
@click.option(
"--preview",
is_flag=True,
help="Export preview image generated by Photos. "
"This is a lower-resolution image used by Photos to quickly preview the image. "
"See also --preview-suffix and --preview-if-missing.",
)
@click.option(
"--preview-if-missing",
is_flag=True,
help="Export preview image generated by Photos if the actual photo file is missing from the library. "
"This may be helpful if photos were not copied to the Photos library and the original photo is missing. "
"See also --preview-suffix and --preview.",
)
@click.option(
"--preview-suffix",
metavar="SUFFIX",
help="Optional suffix template for naming preview photos. Default name for preview photos is in form "
f"'photoname{DEFAULT_PREVIEW_SUFFIX}.ext'. For example, with '--preview-suffix _low_res', the preview photo "
f"would be named 'photoname_low_res.ext'. The default suffix is '{DEFAULT_PREVIEW_SUFFIX}'. "
"Multi-value templates (see Templating System) are not permitted with --preview-suffix. "
"See also --preview and --preview-if-missing.",
)
@click.option(
"--download-missing",
is_flag=True,
@@ -1154,11 +1187,15 @@ def export(
min_size,
max_size,
regex,
selected,
query_eval,
query_function,
duplicate,
post_command,
post_function,
preview,
preview_suffix,
preview_if_missing,
):
"""Export photos from the Photos database.
Export path DEST is required.
@@ -1314,11 +1351,15 @@ def export(
min_size = cfg.min_size
max_size = cfg.max_size
regex = cfg.regex
selected = cfg.selected
query_eval = cfg.query_eval
query_function = cfg.query_function
duplicate = cfg.duplicate
post_command = cfg.post_command
post_function = cfg.post_function
preview = cfg.preview
preview_suffix = cfg.preview_suffix
preview_if_missing = cfg.preview_if_missing
# config file might have changed verbose
VERBOSE = bool(verbose)
@@ -1408,6 +1449,9 @@ def export(
original_suffix = (
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
)
preview_suffix = (
DEFAULT_PREVIEW_SUFFIX if preview_suffix is None else preview_suffix
)
retry = 0 if not retry else retry
if not os.path.isdir(dest):
@@ -1626,6 +1670,7 @@ def export(
min_size=min_size,
max_size=max_size,
regex=regex,
selected=selected,
query_eval=query_eval,
function=query_function,
duplicate=duplicate,
@@ -1725,6 +1770,9 @@ def export(
replace_keywords=replace_keywords,
retry=retry,
export_dir=dest,
export_preview=preview,
preview_suffix=preview_suffix,
preview_if_missing=preview_if_missing,
)
if post_function:
@@ -2031,6 +2079,7 @@ def query(
min_size,
max_size,
regex,
selected,
query_eval,
query_function,
add_to_album,
@@ -2065,6 +2114,7 @@ def query(
min_size,
max_size,
regex,
selected,
duplicate,
]
exclusive = [
@@ -2195,6 +2245,7 @@ def query(
query_eval=query_eval,
function=query_function,
regex=regex,
selected=selected,
duplicate=duplicate,
)
@@ -2379,6 +2430,9 @@ def export_photo(
replace_keywords=False,
retry=0,
export_dir=None,
export_preview=False,
preview_suffix=None,
preview_if_missing=False,
):
"""Helper function for export that does the actual export
@@ -2420,6 +2474,9 @@ def export_photo(
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error
export_dir: top-level export directory for {export_dir} template
export_preview: export the preview image generated by Photos
preview_suffix: str, template to use as suffix for preview images
preview_if_missing: bool, export preview if original is missing
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2480,27 +2537,12 @@ def export_photo(
if "exiftool" in sidecar:
sidecar_flags |= SIDECAR_EXIFTOOL
rendered_suffix = ""
if original_suffix:
try:
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(original_suffix, options)
except ValueError as e:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix '{original_suffix}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix: may not use multi-valued templates: '{original_suffix}': results={rendered_suffix}",
)
rendered_suffix = rendered_suffix[0]
rendered_suffix = _render_suffix_template(
original_suffix, "original_suffix", "--original-suffix", strip, dest, photo
)
rendered_preview_suffix = _render_suffix_template(
preview_suffix, "preview_suffix", "--preview-suffix", strip, dest, photo
)
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
@@ -2579,6 +2621,9 @@ def export_photo(
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
export_preview=export_preview,
preview_suffix=rendered_preview_suffix,
preview_if_missing=preview_if_missing,
)
if export_edited and photo.hasadjustments:
@@ -2610,35 +2655,12 @@ def export_photo(
):
edited_ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
if edited_suffix:
try:
options = RenderOptions(
filename=True,
strip=strip,
export_dir=dest,
)
rendered_suffix, unmatched = photo.render_template(
edited_suffix, options
)
except ValueError as e:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix '{edited_suffix}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix '{edited_suffix}': unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix: may not use multi-valued templates: '{edited_suffix}': results={rendered_suffix}",
)
rendered_suffix = rendered_suffix[0]
edited_filename = f"{edited_filename.stem}{rendered_suffix}{edited_ext}"
else:
edited_filename = f"{edited_filename.stem}{edited_ext}"
rendered_edited_suffix = _render_suffix_template(
edited_suffix, "edited_suffix", "--edited-suffix", strip, dest, photo
)
edited_filename = (
f"{edited_filename.stem}{rendered_edited_suffix}{edited_ext}"
)
verbose_(
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
@@ -2654,13 +2676,13 @@ def export_photo(
dest=dest,
dry_run=dry_run,
strip=strip,
export_original=export_original,
export_original=False,
missing=missing_edited,
verbose=verbose,
sidecar_flags=sidecar_flags,
sidecar_flags=sidecar_flags if not export_original else 0,
sidecar_drop_ext=sidecar_drop_ext,
export_live=export_live,
export_raw=export_raw,
export_raw=not export_original and export_raw,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
exiftool=exiftool,
@@ -2684,11 +2706,44 @@ def export_photo(
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
export_preview=not export_original and export_preview,
preview_suffix=rendered_preview_suffix,
preview_if_missing=preview_if_missing,
)
return results
def _render_suffix_template(suffix_template, var_name, option_name, strip, dest, photo):
"""render suffix template
Returns:
rendered template
"""
if not suffix_template:
return ""
try:
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(suffix_template, options)
except ValueError as e:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name} '{suffix_template}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name} '{suffix_template}': results={rendered_suffix} unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name}: may not use multi-valued templates: '{suffix_template}': results={rendered_suffix}",
)
return rendered_suffix[0]
def export_photo_with_template(
photo,
filename,
@@ -2729,9 +2784,11 @@ def export_photo_with_template(
replace_keywords,
retry,
export_dir,
export_preview,
preview_suffix,
preview_if_missing,
):
"""Evaluate directory template then export photo to each directory"""
results = ExportResults()
dest_paths = get_dirnames_from_template(
@@ -2740,17 +2797,18 @@ def export_photo_with_template(
# export the photo to each path in dest_paths
for dest_path in dest_paths:
# TODO: if --skip-original-if-edited, it's possible edited version is on disk but
# original is missing, in which case we should download the edited version
if export_original:
if missing:
if missing and not preview_if_missing:
space = " " if not verbose else ""
verbose_(
f"{space}Skipping missing photo {photo.original_filename} ({photo.uuid})"
)
results.missing.append(str(pathlib.Path(dest_path) / filename))
continue
elif photo.intrash and (not photo.path or use_photos_export):
elif (
photo.intrash
and (not photo.path or use_photos_export)
and not preview_if_missing
):
# skip deleted files if they're missing or using use_photos_export
# as AppleScript/PhotoKit cannot export deleted photos
space = " " if not verbose else ""
@@ -2764,12 +2822,16 @@ def export_photo_with_template(
continue
else:
# exporting the edited version
if missing:
if missing and not preview_if_missing:
space = " " if not verbose else ""
verbose_(f"{space}Skipping missing edited photo for {filename}")
results.missing.append(str(pathlib.Path(dest_path) / filename))
continue
elif photo.intrash and (not photo.path_edited or use_photos_export):
elif (
photo.intrash
and (not photo.path_edited or use_photos_export)
and not preview_if_missing
):
# skip deleted files if they're missing or using use_photos_export
# as AppleScript/PhotoKit cannot export deleted photos
space = " " if not verbose else ""
@@ -2779,7 +2841,7 @@ def export_photo_with_template(
results.missing.append(str(pathlib.Path(dest_path) / filename))
continue
render_options = RenderOptions(export_dir=export_dir)
render_options = RenderOptions(export_dir=export_dir, dest_path=dest_path)
tries = 0
while tries <= retry:
@@ -2788,8 +2850,10 @@ def export_photo_with_template(
try:
export_results = photo.export2(
dest_path,
filename,
original_filename=filename,
edited=edited,
original=export_original,
edited_filename=filename,
sidecar=sidecar_flags,
sidecar_drop_ext=sidecar_drop_ext,
live_photo=export_live,
@@ -2819,6 +2883,8 @@ def export_photo_with_template(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
render_options=render_options,
preview=export_preview or (missing and preview_if_missing),
preview_suffix=preview_suffix,
)
for warning_ in export_results.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
@@ -3225,13 +3291,13 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
# delete empty directories
deleted_dirs = []
for p in pathlib.Path(dest_path).rglob("*"):
path = str(p).lower()
# if directory and directory is empty
if p.is_dir() and not next(p.iterdir(), False):
verbose_(f"Deleting empty directory {p}")
fileutil.rmdir(p)
deleted_dirs.append(str(p))
# walk directory tree bottom up and verify contents are empty
for dirpath, _, _ in os.walk(dest_path, topdown=False):
if not list(pathlib.Path(dirpath).glob("*")):
# directory and directory is empty
verbose_(f"Deleting empty directory {dirpath}")
fileutil.rmdir(dirpath)
deleted_dirs.append(str(dirpath))
return (deleted_files, deleted_dirs)
@@ -3900,11 +3966,24 @@ def _load_photos_db(dbpath):
def _get_photos(photosdb):
"""get list of all photos in photosdb"""
photos = photosdb.photos(images=True, movies=True)
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
return photos
def _get_selected(photosdb):
"""get list of PhotoInfo objects for photos selected in Photos"""
def get_selected():
selected = photoscript.PhotosLibrary().selection
if not selected:
return []
return photosdb.photos(uuid=[p.uuid for p in selected])
return get_selected
@cli.command()
@DB_OPTION
@click.pass_obj
@@ -3925,6 +4004,7 @@ def repl(ctx, cli_obj, db):
# shortcut for helper functions
get_photo = photosdb.get_photo
show = _show_photo
get_selected = _get_selected(photosdb)
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds")
print("The following variables are defined:")
@@ -3934,6 +4014,9 @@ def repl(ctx, cli_obj, db):
)
print(f"\nThe following functions may be helpful:")
print(f"- get_photo(uuid): return a PhotoInfo object for photo with uuid")
print(
f"- get_selected(): return list of PhotoInfo objects for photos selected in Photos"
)
print(f"- show(photo): open a photo object in the default viewer")
print(
f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"

View File

@@ -30,7 +30,6 @@ from typing import Optional
import photoscript
from mako.template import Template
# from .._applescript import AppleScript
from .._constants import (
_MAX_IPTC_KEYWORD_LEN,
_OSXPHOTOS_NONE_SENTINEL,
@@ -38,6 +37,7 @@ from .._constants import (
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
_XMP_TEMPLATE_NAME_BETA,
DEFAULT_PREVIEW_SUFFIX,
LIVE_VIDEO_EXTENSIONS,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
@@ -380,7 +380,7 @@ def rename_jpeg_files(files, jpeg_ext, fileutil):
def export(
self,
dest,
*filename,
filename=None,
edited=False,
live_photo=False,
raw_photo=False,
@@ -411,12 +411,12 @@ def export(
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version
(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
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
overwrite: (boolean, default=False); if True will overwrite files if they already exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
@@ -450,10 +450,25 @@ def export(
if sidecar_xmp:
sidecar |= SIDECAR_XMP
if not filename:
if not edited:
filename = self.original_filename
else:
original_name = pathlib.Path(self.original_filename)
if self.path_edited:
ext = pathlib.Path(self.path_edited).suffix
else:
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
ext = "." + ext
filename = original_name.stem + "_edited" + ext
results = self.export2(
dest,
*filename,
original=not edited,
original_filename=filename,
edited=edited,
edited_filename=filename,
live_photo=live_photo,
raw_photo=raw_photo,
export_as_hardlink=export_as_hardlink,
@@ -467,7 +482,7 @@ def export(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
render_options = render_options,
render_options=render_options,
)
return results.exported
@@ -476,8 +491,10 @@ def export(
def export2(
self,
dest,
*filename,
original=True,
original_filename=None,
edited=False,
edited_filename=None,
live_photo=False,
raw_photo=False,
export_as_hardlink=False,
@@ -510,7 +527,9 @@ def export2(
persons=True,
location=True,
replace_keywords=False,
render_options: Optional[RenderOptions] = None
preview=False,
preview_suffix=DEFAULT_PREVIEW_SUFFIX,
render_options: Optional[RenderOptions] = None,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -522,8 +541,8 @@ def export2(
in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
original: (boolean, default=True); if True, will export the original version of the photo
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 associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
@@ -566,6 +585,8 @@ def export2(
persons: if True, include persons in exported metadata
location: if True, include location in exported metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
preview: if True, also exports preview image
preview_suffix: optional string to append to end of filename for preview images
render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
Returns: ExportResults class
@@ -610,209 +631,275 @@ def export2(
self._render_options = render_options or RenderOptions()
# suffix to add to edited files
# e.g. name will be filename_edited.jpg
edited_identifier = "_edited"
# check edited and raise exception trying to export edited version of
# photo that hasn't been edited
export_original = original
export_edited = edited
if edited and not self.hasadjustments:
raise ValueError(
"Photo does not have adjustments, cannot export edited version"
)
# check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
raise ValueError("dest must not be None")
elif not dry_run and not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
# if filename passed, use it
fname = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited and not use_photos_export:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited:
raise FileNotFoundError(
"edited=True but path_edited is none; hasadjustments: "
f" {self.hasadjustments}"
)
edited_name = pathlib.Path(self.path_edited).name
edited_suffix = pathlib.Path(edited_name).suffix
fname = (
pathlib.Path(self.original_filename).stem
+ edited_identifier
+ edited_suffix
)
original_filename = original_filename or self.original_filename
dest_original = pathlib.Path(dest) / original_filename
if not edited_filename:
if not edited:
edited_filename = self.original_filename
else:
fname = self.original_filename
original_name = pathlib.Path(self.original_filename)
if self.path_edited:
ext = pathlib.Path(self.path_edited).suffix
else:
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
ext = "." + ext
edited_filename = original_name.stem + "_edited" + ext
dest_edited = pathlib.Path(dest) / edited_filename
uti = self.uti if edited else self.uti_original
if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
fname_new = pathlib.Path(fname)
if convert_to_jpeg and self.isphoto:
something_to_convert = False
ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
fname = str(fname_new.parent / f"{fname_new.stem}{ext}")
if export_original and self.uti_original != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
something_to_convert = True
dest_original = dest_original.parent / f"{dest_original.stem}{ext}"
if export_edited and self.uti != "public.jpeg":
# in Big Sur+, edited HEICs are HEIC
something_to_convert = True
dest_edited = dest_edited.parent / f"{dest_edited.stem}{ext}"
convert_to_jpeg = something_to_convert
else:
# nothing to convert
convert_to_jpeg = False
# check destination path
dest = pathlib.Path(dest)
fname = pathlib.Path(fname)
dest = dest / fname
# check to see if file exists and if so, add (1), (2), etc until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# e.g. exporting sidecar for file1.png and file1.jpeg
# if file1.png exists and exporting file1.jpeg,
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
count = 0
if not update and increment and not overwrite:
count = 1
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = findfiles(f"{dest_original.stem}*", str(dest_original.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem
dest_new = dest_original.stem
while dest_new.lower() in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
dest_new = f"{dest_original.stem} ({count})"
dest_original = dest_original.parent / f"{dest_new}{dest_original.suffix}"
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not update and not overwrite and not increment:
if (
dest_original.exists()
and export_original
and not update
and not overwrite
and not increment
):
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
f"destination exists ({dest_original}); overwrite={overwrite}, increment={increment}"
)
self._render_options.filepath = str(dest)
if export_edited:
if not update and increment and not overwrite:
dest_files = findfiles(f"{dest_edited.stem}*", str(dest_edited.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest_edited.stem
if count:
# incremented above when checking original destination
dest_new = f"{dest_new} ({count})"
while dest_new.lower() in dest_files:
count += 1
dest_new = f"{dest.stem} ({count})"
dest_edited = dest_edited.parent / f"{dest_new}{dest_edited.suffix}"
# if overwrite==False and #increment==False, export should fail if file exists
if dest_edited.exists() and not update and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest_edited}); overwrite={overwrite}, increment={increment}"
)
self._render_options.filepath = (
str(dest_original) if export_original else str(dest_edited)
)
all_results = ExportResults()
if not use_photos_export:
if use_photos_export:
# TODO: collapse these into a single call (refactor _export_photo_with_photos_export)
if original:
self._export_photo_with_photos_export(
dest_original,
all_results,
fileutil,
export_db,
use_photokit=use_photokit,
dry_run=dry_run,
timeout=timeout,
jpeg_ext=jpeg_ext,
touch_file=touch_file,
update=update,
overwrite=overwrite,
live_photo=live_photo,
edited=False,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
)
if edited:
self._export_photo_with_photos_export(
dest_edited,
all_results,
fileutil,
export_db,
use_photokit=use_photokit,
dry_run=dry_run,
timeout=timeout,
jpeg_ext=jpeg_ext,
touch_file=touch_file,
update=update,
overwrite=overwrite,
live_photo=live_photo,
edited=True,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
)
else:
# find the source file on disk and export
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"Cannot export edited photo if path_edited is None"
)
else:
if self.path is not None:
src = self.path
else:
raise FileNotFoundError("Cannot export photo if path is None")
export_src_dest = []
if edited and self.path_edited is not None:
export_src_dest.append((self.path_edited, dest_edited))
elif not edited and self.path is not None:
export_src_dest.append((self.path, dest_original))
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
for src, dest in export_src_dest:
if not pathlib.Path(src).is_file():
raise FileNotFoundError(f"{src} does not appear to exist")
# found source now try to find right destination
if update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
dest_uuid = self.uuid
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
count = 1
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
dest_files = glob.glob(glob_str)
found_match = False
for file_ in dest_files:
dest_uuid = export_db.get_uuid_for_file(file_)
if dest_uuid == self.uuid:
dest = pathlib.Path(file_)
found_match = True
break
elif dest_uuid is None and fileutil.cmp(src, file_):
# files match, update the UUID
dest = pathlib.Path(file_)
found_match = True
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
break
if not found_match:
# increment the destination file
# found source now try to find right destination
if update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
dest_uuid = self.uuid
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
found_match = False
for file_ in dest_files:
dest_uuid = export_db.get_uuid_for_file(file_)
if dest_uuid == self.uuid:
dest = pathlib.Path(file_)
found_match = True
break
elif dest_uuid is None and fileutil.cmp(src, file_):
# files match, update the UUID
dest = pathlib.Path(file_)
found_match = True
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
break
# export the dest file
results = self._export_photo(
src,
dest,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
edited=edited,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
all_results += results
if not found_match:
# increment the destination file
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
# export the dest file
results = self._export_photo(
src,
dest,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
edited=edited,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
all_results += results
dest = dest_original if export_original else dest_edited
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
if export_original and live_photo and self.live_photo and self.path_live_photo:
live_name = dest.parent / f"{dest.stem}.mov"
src_live = self.path_live_photo
results = self._export_photo(
src_live,
live_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
all_results += results
if src_live is not None:
results = self._export_photo(
src_live,
live_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
all_results += results
if (
export_edited
and live_photo
and self.live_photo
and self.path_edited_live_photo
):
live_name = dest.parent / f"{dest_edited.stem}.mov"
src_live = self.path_edited_live_photo
results = self._export_photo(
src_live,
live_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
all_results += results
# copy associated RAW image if requested
if raw_photo and self.has_raw:
if raw_photo and self.has_raw and self.path_raw:
raw_path = pathlib.Path(self.path_raw)
raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
@@ -832,26 +919,30 @@ def export2(
ignore_signature=ignore_signature,
)
all_results += results
else:
self._export_photo_with_photos_export(
dest,
filename,
all_results,
fileutil,
export_db,
use_photokit=use_photokit,
dry_run=dry_run,
timeout=timeout,
jpeg_ext=jpeg_ext,
touch_file=touch_file,
update=update,
overwrite=overwrite,
live_photo=live_photo,
edited=edited,
edited_identifier=edited_identifier,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
)
# copy preview image if requested
if preview and self.path_derivatives:
# Photos keeps multiple different derivatives and path_derivatives returns list of them
# first derivative is the largest so export that one
preview_path = pathlib.Path(self.path_derivatives[0])
preview_ext = preview_path.suffix
preview_name = dest.parent / f"{dest.stem}{preview_suffix}{preview_ext}"
if preview_path is not None:
results = self._export_photo(
preview_path,
preview_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
all_results += results
# export metadata
sidecars = []
@@ -862,6 +953,7 @@ def export2(
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
dest = dest_original if export_original else dest_edited
dest_suffix = "" if sidecar_drop_ext else dest.suffix
if sidecar & SIDECAR_JSON:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
@@ -1112,7 +1204,6 @@ def export2(
def _export_photo_with_photos_export(
self,
dest,
filename,
all_results,
fileutil,
export_db,
@@ -1125,7 +1216,6 @@ def _export_photo_with_photos_export(
overwrite=None,
live_photo=None,
edited=None,
edited_identifier=None,
convert_to_jpeg=None,
jpeg_quality=1.0,
):
@@ -1138,15 +1228,10 @@ def _export_photo_with_photos_export(
# shared photos (in shared albums) show up as not having adjustments (not edited)
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
# so tell Photos to export the current version in this case
if filename:
# use filename stem provided
filestem = dest.stem
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}"
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{filestem}{ext}"
# didn't get passed a filename, add _edited
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{dest.stem}.{ext}"
if use_photokit:
photolib = PhotoLibrary()
@@ -1191,7 +1276,7 @@ def _export_photo_with_photos_export(
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
filestem=dest.stem,
original=False,
edited=True,
live_photo=live_photo,
@@ -1205,7 +1290,6 @@ def _export_photo_with_photos_export(
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
else:
# export original version and not edited
filestem = dest.stem
if use_photokit:
photolib = PhotoLibrary()
photo = None
@@ -1242,7 +1326,7 @@ def _export_photo_with_photos_export(
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
filestem=dest.stem,
original=True,
edited=False,
live_photo=live_photo,
@@ -1609,7 +1693,9 @@ def _exiftool_dict(
)
if description_template is not None:
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
options = dataclasses.replace(
self._render_options, expand_inplace=True, inplace_sep=", "
)
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description
@@ -1648,7 +1734,9 @@ def _exiftool_dict(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
options = dataclasses.replace(
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
for template_str in keyword_template:
rendered, unmatched = self.render_template(template_str, options)
if unmatched:
@@ -1926,7 +2014,9 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None:
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
options = dataclasses.replace(
self._render_options, expand_inplace=True, inplace_sep=", "
)
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
else:
@@ -1959,7 +2049,9 @@ def _xmp_sidecar(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
options = dataclasses.replace(
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
for template_str in keyword_template:
rendered, unmatched = self.render_template(template_str, options)
if unmatched:

View File

@@ -149,41 +149,11 @@ class PhotoInfo:
except AttributeError:
self._path = None
photopath = None
# TODO: should path try to return path even if ismissing?
if self._info["isMissing"] == 1:
return photopath # path would be meaningless until downloaded
if self._db._db_version <= _PHOTOS_4_VERSION:
if self._info["has_raw"]:
# return the path to JPEG even if RAW is original
vol = (
self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
if self._info["raw_pair_info"]["volumeId"] is not None
else None
)
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path,
self._info["raw_pair_info"]["imagePath"],
)
else:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
return self._path_4()
if self._info["shared"]:
# shared photo
@@ -213,6 +183,37 @@ class PhotoInfo:
self._path = photopath
return photopath
def _path_4(self):
"""return path for photo on Photos <= version 4"""
if self._info["has_raw"]:
# return the path to JPEG even if RAW is original
vol = (
self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
if self._info["raw_pair_info"]["volumeId"] is not None
else None
)
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path,
self._info["raw_pair_info"]["imagePath"],
)
else:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
else:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
@property
def path_edited(self):
"""absolute path on disk of the edited picture"""
@@ -252,14 +253,10 @@ class PhotoInfo:
filename = None
if self._info["type"] == _PHOTO_TYPE:
# it's a photo
if self._db._photos_ver == 5:
filename = f"{self._uuid}_1_201_a.jpeg"
if self._db._photos_ver != 5 and self.uti == "public.heic":
filename = f"{self._uuid}_1_201_a.heic"
else:
# could be a heic or a jpeg
if self.uti == "public.heic":
filename = f"{self._uuid}_1_201_a.heic"
else:
filename = f"{self._uuid}_1_201_a.jpeg"
filename = f"{self._uuid}_1_201_a.jpeg"
elif self._info["type"] == _MOVIE_TYPE:
# it's a movie
filename = f"{self._uuid}_2_0_a.mov"
@@ -345,6 +342,37 @@ class PhotoInfo:
return photopath
@property
def path_edited_live_photo(self):
"""return path to edited version of live photo movie; only valid for Photos 5+"""
if self._db._db_version < _PHOTOS_5_VERSION:
return None
try:
return self._path_edited_live_photo
except AttributeError:
self._path_edited_live_photo = self._path_edited_5_live_photo()
return self._path_edited_live_photo
def _path_edited_5_live_photo(self):
"""return path_edited_live_photo for Photos >= 5"""
if self._db._db_version < _PHOTOS_5_VERSION:
raise RuntimeError("Wrong database format!")
if self.live_photo and self._info["hasAdjustments"]:
library = self._db._library_path
directory = self._uuid[0] # first char of uuid
filename = f"{self._uuid}_2_100_a.mov"
photopath = os.path.join(
library, "resources", "renders", directory, filename
)
if not os.path.isfile(photopath):
photopath = None
else:
photopath = None
return photopath
@property
def path_raw(self):
"""absolute path of associated RAW image or None if there is not one"""
@@ -374,21 +402,9 @@ class PhotoInfo:
# 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:
return self._path_raw_4()
if not self.isreference:
filestem = pathlib.Path(self._info["filename"]).stem
# raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
@@ -405,12 +421,40 @@ class PhotoInfo:
if not raw_file:
photopath = None
else:
photopath = os.path.join(filepath, raw_file[0])
if not os.path.isfile(photopath):
photopath = None
photopath = pathlib.Path(filepath) / raw_file[0]
photopath = str(photopath) if photopath.is_file() else None
else:
# is a reference
try:
photopath = (
pathlib.Path("/Volumes")
/ self._info["raw_volume"]
/ self._info["raw_relative_path"]
)
photopath = str(photopath) if photopath.is_file() else None
except KeyError:
# don't have the path details
photopath = None
return photopath
def _path_raw_4(self):
"""Return path_raw for Photos <= version 4"""
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
@property
def description(self):
"""long / extended description of picture"""
@@ -839,20 +883,35 @@ class PhotoInfo:
@property
def path_derivatives(self):
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return self._path_derivatives_4()
try:
return self._path_derivatives
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
self._path_derivatives = self._path_derivatives_4()
return self._path_derivatives
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "derivatives"
/ directory
)
files = derivative_path.glob(f"{self.uuid}*.*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
return [str(filename) for filename in files if filename.suffix != ".THM"]
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "derivatives"
/ directory
)
files = derivative_path.glob(f"{self.uuid}*.*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
derivatives = [
str(filename) for filename in files if filename.suffix != ".THM"
]
if (
self.isphoto
and len(derivatives) > 1
and derivatives[0].endswith(".mov")
):
derivatives[1], derivatives[0] = derivatives[0], derivatives[1]
self._path_derivatives = derivatives
return self._path_derivatives
def _path_derivatives_4(self):
"""Return paths to all derivative (preview) files for Photos <= 4"""

View File

@@ -17,6 +17,7 @@ from pprint import pformat
from typing import List
import bitmath
import photoscript
from .._constants import (
_DB_TABLE_NAMES,
@@ -246,6 +247,9 @@ class PhotosDB:
# key is tuple of (original_filesize, date) and value is list of uuids that match that signature
self._db_signatures = {}
# Dict to hold information on volume names (Photos 5+)
self._db_filesystem_volumes = {}
if _debug():
logging.debug(f"dbfile = {dbfile}")
@@ -599,9 +603,9 @@ class PhotosDB:
verbose = self._verbose
verbose("Processing database.")
verbose(f"Database version: {self._db_version}.")
self._photos_ver = 4 # only used in Photos 5+
self._photos_ver = 4 # only used in Photos 5+
(conn, c) = _open_sql_file(self._tmp_db)
# get info to associate persons with photos
@@ -2348,7 +2352,8 @@ class PhotosDB:
ZINTERNALRESOURCE.ZDATALENGTH,
null,
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
ZINTERNALRESOURCE.ZRESOURCETYPE
ZINTERNALRESOURCE.ZRESOURCETYPE,
ZINTERNALRESOURCE.ZFILESYSTEMBOOKMARK
FROM {asset_table}
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
@@ -2360,14 +2365,15 @@ class PhotosDB:
ZINTERNALRESOURCE.ZDATALENGTH,
ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER,
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
ZINTERNALRESOURCE.ZRESOURCETYPE
ZINTERNALRESOURCE.ZRESOURCETYPE,
ZINTERNALRESOURCE.ZFILESYSTEMBOOKMARK
FROM {asset_table}
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
"""
c.execute(sql_raw)
for row in c:
@@ -2378,6 +2384,33 @@ class PhotosDB:
self._dbphotos[uuid]["UTI_raw"] = row[2]
self._dbphotos[uuid]["datastore_subtype"] = row[3]
self._dbphotos[uuid]["resource_type"] = row[4]
self._dbphotos[uuid]["raw_bookmark"] = row[5]
# get paths for the relative imports for RAW+JPEG images
c.execute(
f""" SELECT
{asset_table}.ZUUID,
ZFILESYSTEMVOLUME.ZNAME,
ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME
FROM {asset_table}
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
JOIN ZFILESYSTEMBOOKMARK ON ZFILESYSTEMBOOKMARK.ZRESOURCE = ZINTERNALRESOURCE.Z_PK
JOIN ZFILESYSTEMVOLUME ON ZFILESYSTEMVOLUME.Z_PK = ZINTERNALRESOURCE.ZFILESYSTEMVOLUME
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
"""
)
# path to the raw image will be /Volumes/ZFILESYSTEMVOLUME.ZNAME/ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME
# 0: {asset_table}.ZUUID, -- UUID
# 1: ZFILESYSTEMVOLUME.ZNAME, -- name of the volume
# 2: ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME -- path to the raw image
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
self._dbphotos[uuid]["raw_volume"] = row[1]
self._dbphotos[uuid]["raw_relative_path"] = row[2]
# add faces and keywords to photo data
for uuid in self._dbphotos:
@@ -3292,6 +3325,18 @@ class PhotosDB:
elif options.no_location:
photos = [p for p in photos if p.location == (None, None)]
if options.selected:
# photos selected in Photos app
try:
# catch AppleScript errors as the scripting interfce to Photos is flaky
selected = photoscript.PhotosLibrary().selection
selected_uuid = [p.uuid for p in selected]
photos = [p for p in photos if p.uuid in selected_uuid]
except Exception:
# no photos selected or a selected photo was "open"
# selection only works if photos selected in main media browser
photos = []
if options.function:
for function in options.function:
photos = function[0](photos)

View File

@@ -250,6 +250,7 @@ class RenderOptions:
strip: if True, strips leading/trailing whitespace from rendered templates
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
export_dir: set to the export directory if you want to evalute {export_dir} template
dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
quote: quote path templates for execution in the shell
"""
@@ -263,6 +264,7 @@ class RenderOptions:
strip: bool = False
edited_version: bool = False
export_dir: Optional[str] = None
dest_path: Optional[str] = None
filepath: Optional[str] = None
quote: bool = False
@@ -354,8 +356,10 @@ class PhotoTemplate:
self.dirname = options.dirname
self.strip = options.strip
self.export_dir = options.export_dir
self.dest_path = options.dest_path
self.filepath = options.filepath
self.quote = options.quote
self.options = options
try:
model = self.parser.parse(template)
@@ -1182,7 +1186,7 @@ class PhotoTemplate:
raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename_validated, funcname)
values = template_func(self.photo)
values = template_func(self.photo, options=self.options)
if not isinstance(values, (str, list)):
raise TypeError(

View File

@@ -83,6 +83,7 @@ class QueryOptions:
location: Optional[bool] = None
no_location: Optional[bool] = None
function: Optional[List[Tuple[callable, str]]] = None
selected: Optional[bool] = None
def asdict(self):
return asdict(self)

View File

@@ -238,6 +238,12 @@ To export only photos contained in the album "Summer Vacation":
`osxphotos export /path/to/export --album "Summer Vacation"`
In Photos, it's possible to have multiple albums with the same name. In this case, osxphotos would export photos from all albums matching the value passed to `--album`. If you wanted to export only one of the albums and this album is in a folder, the `--regex` option (short for "regular expression"), which does pattern matching, could be used with the `{folder_album}` template to match the specific album. For example, if you had a "Summer Vacation" album inside the folder "2018" and also one with the same name inside the folder "2019", you could export just the album "2018/Summer Vacation" using this command:
`osxphotos export /path/to/export --regex "2018/Summer Vacation" "{folder_album}"`
This command matches the pattern "2018/Summer Vacation" against the full folder/album path for every photo.
There are also a number of query options to export only certain types of photos. For example, to export only photos taken with iPhone "Portrait Mode":
`osxphotos export /path/to/export --portrait`

View File

@@ -275,23 +275,6 @@ def findfiles(pattern, path_):
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):
# """ Force Photos to open a specific library
# library_path: path to the Photos library """
# open_scpt = AppleScript(
# f"""
# on openLibrary
# tell application "Photos"
# open POSIX file "{library_path}"
# end tell
# end openLibrary
# """
# )
# open_scpt.run()
def _open_sql_file(dbname):
"""opens sqlite file dbname in read-only mode
returns tuple of (connection, cursor)"""

View File

@@ -15,7 +15,7 @@ dataclasses==0.7;python_version<'3.7'
wurlitzer==2.1.0
photoscript==0.1.3
toml==0.10.2
osxmetadata==0.99.14
osxmetadata==0.99.25
textx==2.3.0
rich==10.2.2
bitmath==1.3.3.1

View File

@@ -90,7 +90,7 @@ setup(
"wurlitzer==2.1.0",
"photoscript==0.1.3",
"toml==0.10.2",
"osxmetadata==0.99.14",
"osxmetadata==0.99.25",
"textx==2.3.0",
"rich==10.2.2",
"bitmath==1.3.3.1",

View File

@@ -787,7 +787,7 @@ def test_export_7(photosdb):
def test_export_8(photosdb):
# try to export missing file
# should raise exception
# should return empty list
import os
import os.path
import tempfile
@@ -796,11 +796,7 @@ def test_export_8(photosdb):
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
with pytest.raises(Exception) as e:
assert photos[0].export(dest)[0]
assert e.type == type(FileNotFoundError())
assert photos[0].export(dest) == []
def test_export_9(photosdb):

View File

@@ -127,6 +127,8 @@ UUID_DICT_LOCAL = {
"burst_not_selected": "89E235DD-B9AC-4E8D-BDA2-986981CA7582", # IMG_9813.JPG
"burst_default": "F5E6BD24-B493-44E9-BDA2-7AD9D2CC8C9D", # IMG_9816.JPG
"burst_not_default": "75154738-83AA-4DCD-A913-632D5D1C0FEE", # IMG_9814.JPG
"live_edited": "54A01B04-16D7-4FDE-8860-19F2A641E433", # IMG_3203.HEIC
"live": "8EC216A2-0032-4934-BD3F-04C6259B3304", # IMG_3259.HEIC
}
UUID_PUMPKIN_FARM = [
@@ -864,17 +866,12 @@ def test_export_7(photosdb):
def test_export_8(photosdb):
# try to export missing file
# should raise exception
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
with pytest.raises(Exception) as e:
assert photos[0].export(dest)[0]
assert e.type == type(FileNotFoundError())
assert photos[0].export(dest) == []
def test_export_9(photosdb):
@@ -1273,6 +1270,20 @@ def test_burst_default_pic(photosdb_local):
assert not photo.burst_default_pick
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
def test_path_edited_live_photo(photosdb_local):
"""test path_edited_live_photo (needs image from local library)"""
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["live_edited"])
assert photo.path_edited_live_photo is not None
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
def test_path_edited_live_photo_not_edited(photosdb_local):
"""test path_edited_live_photo for a live photo that's not edited (needs image from local library)"""
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["live"])
assert photo.path_edited_live_photo is None
def test_is_reference(photosdb):
"""test isreference"""
@@ -1377,7 +1388,7 @@ def test_duplicates_2(photosdb):
def test_compound_query(photosdb):
""" test photos() with multiple query terms """
"""test photos() with multiple query terms"""
photos = photosdb.photos(persons=["Katie", "Maria"], albums=["Multi Keyword"])
assert len(photos) == 2
@@ -1386,21 +1397,21 @@ def test_compound_query(photosdb):
def test_multi_keyword(photosdb):
""" test photos() with multiple keywords """
"""test photos() with multiple keywords"""
photos = photosdb.photos(keywords=["Kids", "wedding"])
assert len(photos) == 6
def test_multi_album(photosdb):
""" test photos() with multiple albums """
"""test photos() with multiple albums"""
photos = photosdb.photos(albums=["Pumpkin Farm", "Test Album"])
assert len(photos) == 3
def test_multi_uuid(photosdb):
""" test photos() with multiple uuids """
"""test photos() with multiple uuids"""
photos = photosdb.photos(uuid=[UUID_DICT["favorite"], UUID_DICT["not_favorite"]])
assert len(photos) == 2

View File

@@ -134,6 +134,7 @@ CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}"
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}"
CLI_EXPORT_PREVIEW_SUFFIX = "_lowres"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"Pumkins1.jpg",
@@ -430,6 +431,8 @@ CLI_EXPORT_UUID_KEYWORD_PATHSEP = "7783E8E6-9CAC-40F3-BE22-81FB7051C266"
CLI_EXPORT_UUID_LONG_DESCRIPTION = "8846E3E6-8AC8-4857-8448-E3D025784410"
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
CLI_EXPORT_UUID_FILENAME_PREVIEW = "Pumkins2_preview.jpeg"
CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE = "Pumkins2_lowres.jpeg"
CLI_EXPORT_BY_DATE_TOUCH_UUID = [
"1EB2B765-0765-43BA-A90C-0D0580E6172C", # Pumpkins3.jpg
@@ -774,6 +777,12 @@ UUID_DUPLICATES = [
UUID_LOCATION = "D79B8D77-BFFC-460B-9312-034F2877D35B" # Pumkins2.jpg
UUID_NO_LOCATION = "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4" # Tulips.jpg"
UUID_DICT_MISSING = {
"8E1D7BC9-9321-44F9-8CFB-4083F6B9232A": "IMG_2000.jpeg", # missing
"A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C": "Pumpkins4.jpeg", # missing
"D79B8D77-BFFC-460B-9312-034F2877D35B": "Pumkins2.jpg", # not missing
}
def modify_file(filename):
"""appends data to a file to modify it"""
@@ -1242,6 +1251,100 @@ def test_export_uuid_from_file():
assert sorted(files) == sorted(CLI_EXPORT_UUID_FROM_FILE_FILENAMES)
def test_export_preview():
"""test export with --preview"""
import glob
import os
import os.path
import osxphotos
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--preview",
"--uuid",
CLI_EXPORT_UUID,
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert CLI_EXPORT_UUID_FILENAME_PREVIEW in files
def test_export_preview_suffix():
"""test export with --preview and --preview-suffix"""
import glob
import os
import os.path
import osxphotos
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--preview",
"--preview-suffix",
CLI_EXPORT_PREVIEW_SUFFIX,
"--uuid",
CLI_EXPORT_UUID,
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE in files
def test_export_preview_if_missing():
"""test export with --preview_if_missing"""
import glob
import os
import os.path
import osxphotos
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
uuid_options = []
for uuid in UUID_DICT_MISSING:
uuid_options.extend(["--uuid", uuid])
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--preview-if-missing",
"--preview-suffix",
"",
*uuid_options,
],
)
assert result.exit_code == 0
files = glob.glob("*")
expected_files = list(UUID_DICT_MISSING.values())
assert sorted(files) == sorted(expected_files)
def test_export_as_hardlink():
import glob
import os
@@ -3756,6 +3859,31 @@ def test_export_deleted_only_2():
assert len(files) == PHOTOS_IN_TRASH_LEN_14_6
def test_export_error(monkeypatch):
"""Test that export catches errors thrown by export2"""
# Note: I often comment out the try/except block in cli.py::export_photo_with_template when
# debugging to see exactly where the error is
# this test verifies I've re-enabled that code
import osxphotos
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
def throw_error(*args, **kwargs):
raise ValueError("Argh!")
monkeypatch.setattr(osxphotos.PhotoInfo, "export2", throw_error)
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", CLI_EXPORT_UUID],
)
assert result.exit_code == 0
assert "Error exporting" in result.output
def test_places():
import json
import os

View File

@@ -240,7 +240,6 @@ def test_export_7(photosdb):
def test_export_8(photosdb):
# try to export missing file
# should raise exception
import os
import os.path
import tempfile
@@ -249,12 +248,7 @@ def test_export_8(photosdb):
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())
assert photos[0].export(dest) == []
def test_export_9(photosdb):

View File

@@ -211,7 +211,6 @@ def test_export_7(photosdb):
def test_export_8(photosdb):
# try to export missing file
# should raise exception
import os
import os.path
import tempfile
@@ -220,12 +219,7 @@ def test_export_8(photosdb):
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)[0]
assert e.type == type(FileNotFoundError())
assert photos[0].export(dest) == []
def test_export_9(photosdb):

View File

@@ -863,17 +863,12 @@ def test_export_7(photosdb):
def test_export_8(photosdb):
# try to export missing file
# should raise exception
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
with pytest.raises(Exception) as e:
assert photos[0].export(dest)[0]
assert e.type == type(FileNotFoundError())
assert photos[0].export(dest) == []
def test_export_9(photosdb):

View File

@@ -31,7 +31,7 @@ def test_dd_to_dms():
assert _dd_to_dms(-0.001) == (0, 0, -3.6)
@pytest.mark.skip(reason="Fails on some machines")
def test_get_system_library_path():
import osxphotos