Feature shared icloud library 860 (#1149)

* Initial support for iCloud Shared Library, #860

* Initial implementation of ShareInfo, ShareParticipant
This commit is contained in:
Rhet Turnbull 2023-08-12 08:06:49 -06:00 committed by GitHub
parent b833cde599
commit 0e0b4b1b09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 493 additions and 78 deletions

View File

@ -1195,6 +1195,10 @@ Syndicated photos are photos that appear in "Shared with you" album. Photos 7+ o
Return True if photo is part of a shared moment, otherwise False. Shared moments are created when multiple photos are shared via iCloud. (e.g. in Messages)
### `shared_library`
Return True if photo is included in shared iCloud library, otherwise False. Photos 8+ only; returns False if not Photos 8+.
#### `uti`
Returns Uniform Type Identifier (UTI) for the current version of the image, for example: 'public.jpeg' or 'com.apple. quicktime-movie'. If the image has been edited, `uti` will return the UTI for the edited image, otherwise it will return the UTI for the original image.
@ -2268,10 +2272,10 @@ Template statements are white-space sensitive meaning that white space (spaces,
e.g. if Photo keywords are `["foo","bar"]`:
- `"{keyword}"` renders to `"foo", "bar"`
- `"{,+keyword}"` renders to: `"foo,bar"`
- `"{; +keyword}"` renders to: `"foo; bar"`
- `"{+keyword}"` renders to `"foobar"`
* `"{keyword}"` renders to `"foo", "bar"`
* `"{,+keyword}"` renders to: `"foo,bar"`
* `"{; +keyword}"` renders to: `"foo; bar"`
* `"{+keyword}"` renders to `"foobar"`
`template_field`: The template field to resolve. See [Template Substitutions](#template-substitutions) for full list of template fields.
@ -2283,77 +2287,77 @@ e.g. if Photo keywords are `["foo","bar"]`:
Valid filters are:
- `lower`: Convert value to lower case, e.g. 'Value' => 'value'.
- `upper`: Convert value to upper case, e.g. 'Value' => 'VALUE'.
- `strip`: Strip whitespace from beginning/end of value, e.g. ' Value ' => 'Value'.
- `titlecase`: Convert value to title case, e.g. 'my value' => 'My Value'.
- `capitalize`: Capitalize first word of value and convert other words to lower case, e.g. 'MY VALUE' => 'My value'.
- `braces`: Enclose value in curly braces, e.g. 'value => '{value}'.
- `parens`: Enclose value in parentheses, e.g. 'value' => '(value')
- `brackets`: Enclose value in brackets, e.g. 'value' => '[value]'
- `shell_quote`: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.
- `function`: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py
- `split(x)`: Split value into a list of values using x as delimiter, e.g. 'value1;value2' => ['value1', 'value2'] if used with split(;).
- `autosplit`: Automatically split delimited string into separate values; will split strings delimited by comma, semicolon, or space, e.g. 'value1,value2' => ['value1', 'value2'].
- `chop(x)`: Remove x characters off the end of value, e.g. chop(1): 'Value' => 'Valu'; when applied to a list, chops characters from each list value, e.g. chop(1): ['travel', 'beach']=> ['trave', 'beac'].
- `chomp(x)`: Remove x characters from the beginning of value, e.g. chomp(1): ['Value'] => ['alue']; when applied to a list, removes characters from each list value, e.g. chomp(1): ['travel', 'beach']=> ['ravel', 'each'].
- `sort`: Sort list of values, e.g. ['c', 'b', 'a'] => ['a', 'b', 'c'].
- `rsort`: Sort list of values in reverse order, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].
- `reverse`: Reverse order of values, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].
- `uniq`: Remove duplicate values, e.g. ['a', 'b', 'c', 'b', 'a'] => ['a', 'b', 'c'].
- `join(x)`: Join list of values with delimiter x, e.g. join(,): ['a', 'b', 'c'] => 'a,b,c'; the DELIM option functions similar to join(x) but with DELIM, the join happens before being passed to any filters.May optionally be used without an argument, that is 'join()' which joins values together with no delimiter. e.g. join(): ['a', 'b', 'c'] => 'abc'.
- `append(x)`: Append x to list of values, e.g. append(d): ['a', 'b', 'c'] => ['a', 'b', 'c', 'd'].
- `prepend(x)`: Prepend x to list of values, e.g. prepend(d): ['a', 'b', 'c'] => ['d', 'a', 'b', 'c'].
- `appends(x)`: Append s[tring] Append x to each value of list of values, e.g. appends(d): ['a', 'b', 'c'] => ['ad', 'bd', 'cd'].
- `prepends(x)`: Prepend s[tring] x to each value of list of values, e.g. prepends(d): ['a', 'b', 'c'] => ['da', 'db', 'dc'].
- `remove(x)`: Remove x from list of values, e.g. remove(b): ['a', 'b', 'c'] => ['a', 'c'].
- `slice(start:stop:step)`: Slice list using same semantics as Python's list slicing, e.g. slice(1:3): ['a', 'b', 'c', 'd'] => ['b', 'c']; slice(1:4:2): ['a', 'b', 'c', 'd'] => ['b', 'd']; slice(1:): ['a', 'b', 'c', 'd'] => ['b', 'c', 'd']; slice(:-1): ['a', 'b', 'c', 'd'] => ['a', 'b', 'c']; slice(::-1): ['a', 'b', 'c', 'd'] => ['d', 'c', 'b', 'a']. See also sslice().
- `sslice(start:stop:step)`: [s(tring) slice] Slice values in a list using same semantics as Python's string slicing, e.g. sslice(1:3):'abcd => 'bc'; sslice(1:4:2): 'abcd' => 'bd', etc. See also slice().
- `filter(x)`: Filter list of values using predicate x; for example, `{folder_album|filter(contains Events)}` returns only folders/albums containing the word 'Events' in their path.
- `int`: Convert values in list to integer, e.g. 1.0 => 1. If value cannot be converted to integer, remove value from list. ['1.1', 'x'] => ['1']. See also float.
- `float`: Convert values in list to floating point number, e.g. 1 => 1.0. If value cannot be converted to float, remove value from list. ['1', 'x'] => ['1.0']. See also int.
* `lower`: Convert value to lower case, e.g. 'Value' => 'value'.
* `upper`: Convert value to upper case, e.g. 'Value' => 'VALUE'.
* `strip`: Strip whitespace from beginning/end of value, e.g. ' Value ' => 'Value'.
* `titlecase`: Convert value to title case, e.g. 'my value' => 'My Value'.
* `capitalize`: Capitalize first word of value and convert other words to lower case, e.g. 'MY VALUE' => 'My value'.
* `braces`: Enclose value in curly braces, e.g. 'value => '{value}'.
* `parens`: Enclose value in parentheses, e.g. 'value' => '(value')
* `brackets`: Enclose value in brackets, e.g. 'value' => '[value]'
* `shell_quote`: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.
* `function`: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at <https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py>
* `split(x)`: Split value into a list of values using x as delimiter, e.g. 'value1;value2' => ['value1', 'value2'] if used with split(;).
* `autosplit`: Automatically split delimited string into separate values; will split strings delimited by comma, semicolon, or space, e.g. 'value1,value2' => ['value1', 'value2'].
* `chop(x)`: Remove x characters off the end of value, e.g. chop(1): 'Value' => 'Valu'; when applied to a list, chops characters from each list value, e.g. chop(1): ['travel', 'beach']=> ['trave', 'beac'].
* `chomp(x)`: Remove x characters from the beginning of value, e.g. chomp(1): ['Value'] => ['alue']; when applied to a list, removes characters from each list value, e.g. chomp(1): ['travel', 'beach']=> ['ravel', 'each'].
* `sort`: Sort list of values, e.g. ['c', 'b', 'a'] => ['a', 'b', 'c'].
* `rsort`: Sort list of values in reverse order, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].
* `reverse`: Reverse order of values, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].
* `uniq`: Remove duplicate values, e.g. ['a', 'b', 'c', 'b', 'a'] => ['a', 'b', 'c'].
* `join(x)`: Join list of values with delimiter x, e.g. join(,): ['a', 'b', 'c'] => 'a,b,c'; the DELIM option functions similar to join(x) but with DELIM, the join happens before being passed to any filters.May optionally be used without an argument, that is 'join()' which joins values together with no delimiter. e.g. join(): ['a', 'b', 'c'] => 'abc'.
* `append(x)`: Append x to list of values, e.g. append(d): ['a', 'b', 'c'] => ['a', 'b', 'c', 'd'].
* `prepend(x)`: Prepend x to list of values, e.g. prepend(d): ['a', 'b', 'c'] => ['d', 'a', 'b', 'c'].
* `appends(x)`: Append s[tring] Append x to each value of list of values, e.g. appends(d): ['a', 'b', 'c'] => ['ad', 'bd', 'cd'].
* `prepends(x)`: Prepend s[tring] x to each value of list of values, e.g. prepends(d): ['a', 'b', 'c'] => ['da', 'db', 'dc'].
* `remove(x)`: Remove x from list of values, e.g. remove(b): ['a', 'b', 'c'] => ['a', 'c'].
* `slice(start:stop:step)`: Slice list using same semantics as Python's list slicing, e.g. slice(1:3): ['a', 'b', 'c', 'd'] => ['b', 'c']; slice(1:4:2): ['a', 'b', 'c', 'd'] => ['b', 'd']; slice(1:): ['a', 'b', 'c', 'd'] => ['b', 'c', 'd']; slice(:-1): ['a', 'b', 'c', 'd'] => ['a', 'b', 'c']; slice(::-1): ['a', 'b', 'c', 'd'] => ['d', 'c', 'b', 'a']. See also sslice().
* `sslice(start:stop:step)`: [s(tring) slice] Slice values in a list using same semantics as Python's string slicing, e.g. sslice(1:3):'abcd => 'bc'; sslice(1:4:2): 'abcd' => 'bd', etc. See also slice().
* `filter(x)`: Filter list of values using predicate x; for example, `{folder_album|filter(contains Events)}` returns only folders/albums containing the word 'Events' in their path.
* `int`: Convert values in list to integer, e.g. 1.0 => 1. If value cannot be converted to integer, remove value from list. ['1.1', 'x'] => ['1']. See also float.
* `float`: Convert values in list to floating point number, e.g. 1 => 1.0. If value cannot be converted to float, remove value from list. ['1', 'x'] => ['1.0']. See also int.
e.g. if Photo keywords are `["FOO","bar"]`:
- `"{keyword|lower}"` renders to `"foo", "bar"`
- `"{keyword|upper}"` renders to: `"FOO", "BAR"`
- `"{keyword|capitalize}"` renders to: `"Foo", "Bar"`
- `"{keyword|lower|parens}"` renders to: `"(foo)", "(bar)"`
* `"{keyword|lower}"` renders to `"foo", "bar"`
* `"{keyword|upper}"` renders to: `"FOO", "BAR"`
* `"{keyword|capitalize}"` renders to: `"Foo", "Bar"`
* `"{keyword|lower|parens}"` renders to: `"(foo)", "(bar)"`
e.g. if Photo description is "my description":
- `"{descr|titlecase}"` renders to: `"My Description"`
* `"{descr|titlecase}"` renders to: `"My Description"`
e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album}"` renders to `["Folder1/Album1"]`
- `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
- `"{folder_album()}"` renders to `["Folder1Album1"]`
* `"{folder_album}"` renders to `["Folder1/Album1"]`
* `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
* `"{folder_album()}"` renders to `["Folder1Album1"]`
`[find,replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
`conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '`not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are:
- `contains`: template field contains value, similar to python's `in`
- `matches`: template field contains exactly value, unlike `contains`: does not match partial matches
- `startswith`: template field starts with value
- `endswith`: template field ends with value
- `<=`: template field is less than or equal to value
- `>=`: template field is greater than or equal to value
- `<`: template field is less than value
- `>`: template field is greater than value
- `==`: template field equals value
- `!=`: template field does not equal value
* `contains`: template field contains value, similar to python's `in`
* `matches`: template field contains exactly value, unlike `contains`: does not match partial matches
* `startswith`: template field starts with value
* `endswith`: template field ends with value
* `<=`: template field is less than or equal to value
* `>=`: template field is greater than or equal to value
* `<`: template field is less than value
* `>`: template field is greater than value
* `==`: template field equals value
* `!=`: template field does not equal value
The `value` part of the conditional expression is treated as a bare (unquoted) word/phrase. Multiple values may be separated by '|' (the pipe symbol). `value` is itself a template statement so you can use one or more template fields in `value` which will be resolved before the comparison occurs.
For example:
- `{keyword matches Beach}` resolves to True if 'Beach' is a keyword. It would not match keyword 'BeachDay'.
- `{keyword contains Beach}` resolves to True if any keyword contains the word 'Beach' so it would match both 'Beach' and 'BeachDay'.
- `{photo.score.overall > 0.7}` resolves to True if the photo's overall aesthetic score is greater than 0.7.
- `{keyword|lower contains beach}` uses the lower case filter to do case-insensitive matching to match any keyword that contains the word 'beach'.
- `{keyword|lower not contains beach}` uses the `not` modifier to negate the comparison so this resolves to True if there is no keyword that matches 'beach'.
* `{keyword matches Beach}` resolves to True if 'Beach' is a keyword. It would not match keyword 'BeachDay'.
* `{keyword contains Beach}` resolves to True if any keyword contains the word 'Beach' so it would match both 'Beach' and 'BeachDay'.
* `{photo.score.overall > 0.7}` resolves to True if the photo's overall aesthetic score is greater than 0.7.
* `{keyword|lower contains beach}` uses the lower case filter to do case-insensitive matching to match any keyword that contains the word 'beach'.
* `{keyword|lower not contains beach}` uses the `not` modifier to negate the comparison so this resolves to True if there is no keyword that matches 'beach'.
Examples: to export photos that contain certain keywords with the `osxphotos export` command's `--directory` option:
@ -2370,24 +2374,24 @@ This renames any photo that is a favorite as 'Favorite-ImageName.jpg' (where 'Im
e.g. if photo is an HDR image,
- `"{hdr?ISHDR,NOTHDR}"` renders to `"ISHDR"`
* `"{hdr?ISHDR,NOTHDR}"` renders to `"ISHDR"`
and if it is not an HDR image,
- `"{hdr?ISHDR,NOTHDR}"` renders to `"NOTHDR"`
* `"{hdr?ISHDR,NOTHDR}"` renders to `"NOTHDR"`
`,default`: optional default value to use if the template name has no value. This modifier is also used for the value if False for boolean-type fields (see above) as well as to hold a sub-template for values like `{created.strftime}`. If no default value provided, "_" is used.
e.g., if photo has no title set,
- `"{title}"` renders to "_"
- `"{title,I have no title}"` renders to `"I have no title"`
* `"{title}"` renders to "_"
* `"{title,I have no title}"` renders to `"I have no title"`
Template fields such as `created.strftime` use the default value to pass the template to use for `strftime`.
e.g., if photo date is 4 February 2020, 19:07:38,
- `"{created.strftime,%Y-%m-%d-%H%M%S}"` renders to `"2020-02-04-190738"`
* `"{created.strftime,%Y-%m-%d-%H%M%S}"` renders to `"2020-02-04-190738"`
Some template fields such as `"{media_type}"` use the default value to allow customization of the output. For example, `"{media_type}"` resolves to the special media type of the photo such as `panorama` or `selfie`. You may use the default value to override these in form: `"{media_type,video=vidéo;time_lapse=vidéo_accélérée}"`. In this example, if photo was a time_lapse photo, `media_type` would resolve to `vidéo_accélérée` instead of `time_lapse`.
@ -2440,7 +2444,7 @@ cog.out("\n"+get_template_field_table()+"\n")
|{created.hour}|2-digit hour of the photo creation time|
|{created.min}|2-digit minute of the photo creation time|
|{created.sec}|2-digit second of the photo creation time|
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See <https://strftime.org/> for help on strftime templates.|
|{modified}|Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified|
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified|
|{modified.year}|4-digit year of photo modification time; uses creation date if photo is not modified|
@ -2454,7 +2458,7 @@ cog.out("\n"+get_template_field_table()+"\n")
|{modified.hour}|2-digit hour of the photo modification time; uses creation date if photo is not modified|
|{modified.min}|2-digit minute of the photo modification time; uses creation date if photo is not modified|
|{modified.sec}|2-digit second of the photo modification time; uses creation date if photo is not modified|
|{modified.strftime}|Apply strftime template to file modification date/time. Should be used in form {modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. Uses creation date if photo is not modified. See https://strftime.org/ for help on strftime templates.|
|{modified.strftime}|Apply strftime template to file modification date/time. Should be used in form {modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. Uses creation date if photo is not modified. See <https://strftime.org/> for help on strftime templates.|
|{today}|Current date in iso format, e.g. '2020-03-22'|
|{today.date}|Current date in iso format, e.g. '2020-03-22'|
|{today.year}|4-digit year of current date|
@ -2468,7 +2472,7 @@ cog.out("\n"+get_template_field_table()+"\n")
|{today.hour}|2-digit hour of the current date|
|{today.min}|2-digit minute of the current date|
|{today.sec}|2-digit second of the current date|
|{today.strftime}|Apply strftime template to current date/time. Should be used in form {today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|{today.strftime}|Apply strftime template to current date/time. Should be used in form {today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See <https://strftime.org/> for help on strftime templates.|
|{place.name}|Place name from the photo's reverse geolocation data, as displayed in Photos|
|{place.country_code}|The ISO country code from the photo's reverse geolocation data|
|{place.name.country}|Country name from the photo's reverse geolocation data|
@ -2520,17 +2524,17 @@ cog.out("\n"+get_template_field_table()+"\n")
|{label}|Image categorization label associated with a photo (Photos 5+ only). Labels are added automatically by Photos using machine learning algorithms to categorize images. These are not the same as {keyword} which refers to the user-defined keywords/tags applied in Photos.|
|{label_normalized}|All lower case version of 'label' (Photos 5+ only)|
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)|
|{exiftool}|Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
|{exiftool}|Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (<https://exiftool.org>) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See <https://exiftool.org/TagNames/> for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
|{searchinfo.holiday}|Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.activity}|Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{photo}|Provides direct access to the PhotoInfo object for the photo. Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. '{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.|
|{photo}|Provides direct access to the PhotoInfo object for the photo. Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. '{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of the underlying PhotoInfo class. See <https://rhettbull.github.io/osxphotos/> for additional documentation on the PhotoInfo class.|
|{detected_text}|List of text strings found in the image after performing text detection. Using '{detected_text}' will cause osxphotos to perform text detection on your photos using the built-in macOS text detection algorithms which will slow down your export. The results for each photo will be cached in the export database so that future exports with '--update' do not need to reprocess each photo. You may pass a confidence threshold value between 0.0 and 1.0 after a colon as in '{detected_text:0.5}'; The default confidence threshold is 0.75. '{detected_text}' works only on macOS Catalina (10.15) or later. Note: this feature is not the same thing as Live Text in macOS Monterey, which osxphotos does not yet support.|
|{shell_quote}|Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.|
|{strip}|Use in form '{strip,TEMPLATE}'; strips whitespace from begining and end of rendered TEMPLATE value(s).|
|{format}|Use in form, '{format:TYPE:FORMAT,TEMPLATE}'; converts TEMPLATE value to TYPE then formats the value using Python string formatting codes specified by FORMAT; TYPE is one of: 'int', 'float', or 'str'. For example, '{format:float:.1f,{exiftool:EXIF:FocalLength}}' will format focal length to 1 decimal place (e.g. '100.0'). |
|{function}|Execute a python function from an external file and use return value as template substitution. Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. The function will be passed the PhotoInfo object for the photo. See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.|
|{function}|Execute a python function from an external file and use return value as template substitution. Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. The function will be passed the PhotoInfo object for the photo. See <https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py> for an example of how to implement a template function.|
<!--[[[end]]] -->
### <a name="exiftoolExifTool">ExifTool</a>

View File

@ -55,6 +55,9 @@ _PHOTOS_9_MODEL_VERSION = [17000, 17999] # Sonoma dev preview: 17120
# the preview versions of 12.0.0 had a difference schema for syndication info so need to check model version before processing
_PHOTOS_SYNDICATION_MODEL_VERSION = 15323 # 12.0.1
# shared iCloud library versions; dev preview doesn't contain same columns as release version
_PHOTOS_SHARED_LIBRARY_VERSION = 16320 # 13.0
# some table names differ between Photos 5 and later versions
_DB_TABLE_NAMES = {
5: {

View File

@ -621,6 +621,16 @@ _QUERY_PARAMETERS_DICT = {
is_flag=True,
help="Search for photos that are not part of a shared moment",
),
"--shared-library": click.Option(
["--shared-library"],
is_flag=True,
help="Search for photos that are part of a shared library",
),
"--not-shared-library": click.Option(
["--not-shared-library"],
is_flag=True,
help="Search for photos that are not part of a shared library",
),
"--regex": click.Option(
["--regex"],
metavar="REGEX TEMPLATE",

View File

@ -977,6 +977,8 @@ def export(
not_saved_to_library,
shared_moment,
not_shared_moment,
shared_library,
not_shared_library,
selected=False, # Isn't provided on unsupported platforms
# debug, # debug, watch, breakpoint handled in cli/__init__.py
# watch,
@ -1077,6 +1079,7 @@ def export(
exiftool_merge_persons = cfg.exiftool_merge_persons
exiftool_option = cfg.exiftool_option
exiftool_path = cfg.exiftool_path
export_aae = cfg.export_aae
export_as_hardlink = cfg.export_as_hardlink
export_by_date = cfg.export_by_date
exportdb = cfg.exportdb
@ -1135,10 +1138,14 @@ def export(
not_panorama = cfg.not_panorama
not_portrait = cfg.not_portrait
not_reference = cfg.not_reference
not_saved_to_library = cfg.not_saved_to_library
not_screenshot = cfg.not_screenshot
not_selfie = cfg.not_selfie
not_shared = cfg.not_shared
not_shared_library = cfg.not_shared_library
not_shared_moment = cfg.not_shared_moment
not_slow_mo = cfg.not_slow_mo
not_syndicated = cfg.not_syndicated
not_time_lapse = cfg.not_time_lapse
only_movies = cfg.only_movies
only_new = cfg.only_new
@ -1165,11 +1172,13 @@ def export(
replace_keywords = cfg.replace_keywords
report = cfg.report
retry = cfg.retry
saved_to_library = cfg.saved_to_library
screenshot = cfg.screenshot
selected = cfg.selected
selfie = cfg.selfie
shared = cfg.shared
export_aae = cfg.export_aae
shared_library = cfg.shared_library
shared_moment = cfg.shared_moment
sidecar = cfg.sidecar
sidecar_drop_ext = cfg.sidecar_drop_ext
sidecar_template = cfg.sidecar_template
@ -1182,6 +1191,7 @@ def export(
skip_uuid_from_file = cfg.skip_uuid_from_file
slow_mo = cfg.slow_mo
strip = cfg.strip
syndicated = cfg.syndicated
theme = cfg.theme
time_lapse = cfg.time_lapse
timestamp = cfg.timestamp
@ -1197,16 +1207,11 @@ def export(
uti = cfg.uti
uuid = cfg.uuid
uuid_from_file = cfg.uuid_from_file
# this is the one option that is named differently in the config file than the variable passed by --verbose (verbose_flag)
verbose_flag = cfg.verbose
verbose_flag = (
cfg.verbose
) # this is named differently in the config file than the variable passed by --verbose (verbose_flag)
xattr_template = cfg.xattr_template
year = cfg.year
syndicated = cfg.syndicated
not_syndicated = cfg.not_syndicated
saved_to_library = cfg.saved_to_library
not_saved_to_library = cfg.not_saved_to_library
shared_moment = cfg.shared_moment
not_shared_moment = cfg.not_shared_moment
# config file might have changed verbose
verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)

View File

@ -65,6 +65,8 @@ from .platform import assert_macos, is_macos
from .query_builder import get_query
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .shareinfo import ShareInfo, get_moment_share_info, get_share_info
from .shareparticipant import ShareParticipant, get_share_participants
from .uti import get_preferred_uti_extension, get_uti_for_extension
from .utils import _get_resource_loc, hexdigest, list_directory
@ -1435,6 +1437,41 @@ class PhotoInfo:
"""Returns True if photo is part of a shared moment otherwise False (Photos 7+ only)"""
return bool(self._info["moment_share"])
@cached_property
def shared_moment_info(self) -> ShareInfo | None:
"""Returns ShareInfo object with information about the shared moment the photo is part of (Photos 7+ only)"""
if self._db.photos_version < 7:
return None
try:
return get_moment_share_info(self._db, self.uuid)
except ValueError:
return None
@cached_property
def share_info(self) -> ShareInfo | None:
"""Returns ShareInfo object with information about the shared photo in a shared iCloud library (Photos 8+ only) (currently experimental)"""
if self._db.photos_version < 8:
return None
try:
return get_share_info(self._db, self.uuid)
except ValueError:
return None
@cached_property
def shared_library(self) -> bool:
"""Returns True if photo is in a shared iCloud library otherwise False (Photos 8+ only)"""
# TODO: this is just a guess right now as I don't currently use shared libraries
return bool(self._info["active_library_participation_state"])
@cached_property
def share_participants(self) -> list[ShareParticipant]:
"""Returns list of ShareParticpant objects with information on who the photo is shared with (Photos 8+ only)"""
if self._db.photos_version < 8:
return []
return get_share_participants(self._db, self.uuid)
@property
def labels(self):
"""returns list of labels applied to photo by Photos image categorization

View File

@ -0,0 +1,62 @@
""" Methods for PhotosDB to process shared iCloud library data (#860)"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .._constants import _DB_TABLE_NAMES, _PHOTOS_SHARED_LIBRARY_VERSION
from ..sqlite_utils import sqlite_open_ro
if TYPE_CHECKING:
from osxphotos.photosdb import PhotosDB
logger = logging.getLogger("osxphotos")
def _process_shared_library_info(self: PhotosDB):
"""Process syndication information"""
if self.photos_version < 7:
raise NotImplementedError(
f"syndication info not implemented for this database version: {self.photos_version}"
)
if self._model_ver < _PHOTOS_SHARED_LIBRARY_VERSION:
return
_process_shared_library_info_8(self)
def _process_shared_library_info_8(photosdb: PhotosDB):
"""Process shared iCloud library info for Photos 8.0 and later
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
zasset = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
(conn, cursor) = sqlite_open_ro(db)
result = cursor.execute(
f"""
SELECT
{zasset}.ZUUID,
{zasset}.ZACTIVELIBRARYSCOPEPARTICIPATIONSTATE,
{zasset}.ZLIBRARYSCOPESHARESTATE,
{zasset}.ZLIBRARYSCOPE
FROM {zasset}
"""
)
for row in result:
uuid = row[0]
if uuid not in photosdb._dbphotos:
logger.debug(f"Skipping shared library info for missing uuid: {uuid}")
continue
info = photosdb._dbphotos[uuid]
info["active_library_participation_state"] = row[1]
info["library_scope_share_state"] = row[2]
info["library_scope"] = row[3]

View File

@ -62,7 +62,11 @@ from ..rich_utils import add_rich_markup_tag
from ..sqlite_utils import sqlite_db_is_locked, sqlite_open_ro
from ..unicode import normalize_unicode
from ..utils import _check_file_exists, get_last_library_path, noop
from .photosdb_utils import get_photos_version_from_model, get_db_version, get_model_version
from .photosdb_utils import (
get_db_version,
get_model_version,
get_photos_version_from_model,
)
if is_macos:
import photoscript
@ -91,6 +95,7 @@ class PhotosDB:
labels_normalized,
labels_normalized_as_dict,
)
from ._photosdb_process_shared_library import _process_shared_library_info
from ._photosdb_process_syndicationinfo import _process_syndicationinfo
def __init__(
@ -286,7 +291,7 @@ class PhotosDB:
# Dict to hold data on imports for Photos <= 4
self._db_import_group = {}
# Dict to hold syndication info for Photos >= 8
# Dict to hold syndication info for Photos >= 7
# key is UUID and value is dict of syndication info
self._db_syndication_uuid = {}
@ -1552,6 +1557,12 @@ class PhotosDB:
info["UTI_raw"] = None
info["raw_pair_info"] = None
# placeholders for shared library info on Photos 8+
for uuid in self._dbphotos:
self._dbphotos[uuid]["active_library_participation_state"] = None
self._dbphotos[uuid]["library_scope_share_state"] = None
self._dbphotos[uuid]["library_scope"] = None
# done with the database connection
conn.close()
@ -2220,6 +2231,11 @@ class PhotosDB:
info["UTI_edited_photo"] = None
info["UTI_edited_video"] = None
# placeholder for shared library info (Photos 8+)
info["active_library_participation_state"] = None
info["library_scope_share_state"] = None
info["library_scope"] = None
self._dbphotos[uuid] = info
# compute signatures for finding possible duplicates
@ -2532,6 +2548,10 @@ class PhotosDB:
verbose("Processing syndication info.")
self._process_syndicationinfo()
if self.photos_version >= 8:
verbose("Processing shared iCloud library info")
self._process_shared_library_info()
verbose("Done processing details from Photos library.")
def _process_moments(self):
@ -3546,6 +3566,11 @@ class PhotosDB:
elif options.not_shared_moment:
photos = [p for p in photos if not p.shared_moment]
if options.shared_library:
photos = [p for p in photos if p.shared_library]
elif options.not_shared_library:
photos = [p for p in photos if not p.shared_library]
if options.function:
for function in options.function:
photos = function[0](photos)

View File

@ -114,6 +114,8 @@ class QueryOptions:
not_saved_to_library: search for syndicated photos that have not been saved to the Photos library
shared_moment: search for photos that have been shared via a shared moment
not_shared_moment: search for photos that have not been shared via a shared moment
shared_library: search for photos that are part of a shared iCloud library
not_shared_library: search for photos that are not part of a shared iCloud library
"""
added_after: Optional[datetime.datetime] = None
@ -204,6 +206,8 @@ class QueryOptions:
not_saved_to_library: Optional[bool] = None
shared_moment: Optional[bool] = None
not_shared_moment: Optional[bool] = None
shared_library: Optional[bool] = None
not_shared_library: Optional[bool] = None
def asdict(self):
return asdict(self)
@ -276,7 +280,9 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions:
("syndicated", "not_syndicated"),
("saved_to_library", "not_saved_to_library"),
("shared_moment", "not_shared_moment"),
("shared_library", "not_shared_library"),
]
# TODO: add option to validate requiring at least one query arg
for arg, not_arg in exclusive:
if kwargs.get(arg) and kwargs.get(not_arg):

200
osxphotos/shareinfo.py Normal file
View File

@ -0,0 +1,200 @@
"""Info about shared photos"""
from __future__ import annotations
import dataclasses
import datetime
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ._constants import TIME_DELTA
if TYPE_CHECKING:
from .photosdb import PhotosDB
@dataclass
class ShareInfo:
"""Info about a share"""
_pk: int | None
cloud_delete_state: int | None
local_publish_state: int | None
public_permission: int | None
scope_type: int | None
status: int | None
trashed_state: int | None
auto_share_policy: int | None
cloud_item_count: int | None
cloud_local_state: int | None
cloud_photo_count: int | None
cloud_video_count: int | None
exit_state: int | None
participant_cloud_update_state: int | None
preview_state: int | None
scope_syncing_state: int | None
asset_count: int | None
force_sync_attempted: int | None
photos_count: int | None
should_ignore_budgets: int | None
should_notify_on_upload_completion: int | None
uploaded_photos_count: int | None
uploaded_videos_count: int | None
videos_count: int | None
creation_date: datetime.datetime | None
expiry_date: datetime.datetime | None
trashed_date: datetime.datetime | None
last_participant_asset_trash_notification_date: datetime.datetime | None
last_participant_asset_trash_notification_viewed_date: datetime.datetime | None
end_date: datetime.datetime | None
start_date: datetime.datetime | None
scope_identifier: str | None
title: str | None
uuid: str | None
originating_scope_identifier: str | None
share_url: str | None
rules_data: bytes | None
preview_data: bytes | None
thumbnail_image_data: bytes | None
exit_source: int | None
count_of_assets_added_by_camera_smart_sharing: int | None
exit_type: int | None
def __post_init__(self):
"""Convert dates from str to datetime"""
for field in [
"creation_date",
"expiry_date",
"trashed_date",
"last_participant_asset_trash_notification_date",
"last_participant_asset_trash_notification_viewed_date",
"end_date",
"start_date",
]:
if val := getattr(self, field):
setattr(self, field, datetime.datetime.fromtimestamp(val + TIME_DELTA))
def asdict(self):
"""Return info as dict"""
return dataclasses.asdict(self)
def get_moment_share_info(db: PhotosDB, uuid: str | None) -> ShareInfo:
"""Get info about a moment share"""
sql = """ SELECT
ZSHARE.Z_PK,
ZSHARE.ZCLOUDDELETESTATE,
ZSHARE.ZLOCALPUBLISHSTATE,
ZSHARE.ZPUBLICPERMISSION,
ZSHARE.ZSCOPETYPE,
ZSHARE.ZSTATUS,
ZSHARE.ZTRASHEDSTATE,
ZSHARE.ZAUTOSHAREPOLICY,
ZSHARE.ZCLOUDITEMCOUNT,
ZSHARE.ZCLOUDLOCALSTATE,
ZSHARE.ZCLOUDPHOTOCOUNT,
ZSHARE.ZCLOUDVIDEOCOUNT,
ZSHARE.ZEXITSTATE,
ZSHARE.ZPARTICIPANTCLOUDUPDATESTATE,
ZSHARE.ZPREVIEWSTATE,
ZSHARE.ZSCOPESYNCINGSTATE,
ZSHARE.ZASSETCOUNT,
ZSHARE.ZFORCESYNCATTEMPTED,
ZSHARE.ZPHOTOSCOUNT,
ZSHARE.ZSHOULDIGNOREBUDGETS,
ZSHARE.ZSHOULDNOTIFYONUPLOADCOMPLETION,
ZSHARE.ZUPLOADEDPHOTOSCOUNT,
ZSHARE.ZUPLOADEDVIDEOSCOUNT,
ZSHARE.ZVIDEOSCOUNT,
ZSHARE.ZCREATIONDATE,
ZSHARE.ZEXPIRYDATE,
ZSHARE.ZTRASHEDDATE,
ZSHARE.ZLASTPARTICIPANTASSETTRASHNOTIFICATIONDATE,
ZSHARE.ZLASTPARTICIPANTASSETTRASHNOTIFICATIONVIEWEDDATE,
ZSHARE.ZENDDATE,
ZSHARE.ZSTARTDATE,
ZSHARE.ZSCOPEIDENTIFIER,
ZSHARE.ZTITLE,
ZSHARE.ZUUID,
ZSHARE.ZORIGINATINGSCOPEIDENTIFIER,
ZSHARE.ZSHAREURL,
ZSHARE.ZRULESDATA,
ZSHARE.ZPREVIEWDATA,
ZSHARE.ZTHUMBNAILIMAGEDATA,
ZSHARE.ZEXITSOURCE,
ZSHARE.ZCOUNTOFASSETSADDEDBYCAMERASMARTSHARING,
ZSHARE.ZEXITTYPE
FROM ZSHARE
JOIN ZASSET ON ZASSET.ZMOMENTSHARE = ZSHARE.Z_PK
WHERE ZASSET.ZUUID = '{}'
;"""
sql = sql.format(uuid)
if row := db.execute(sql).fetchone():
return ShareInfo(*row)
raise ValueError(f"Could not find share for uuid {uuid}")
def get_share_info(db: PhotosDB, uuid: str | None) -> ShareInfo:
"""Get info about a moment share"""
# TODO: this is a total guess right now. I think that ZSHARE holds information
# about both shared moments and shared iCloud Library
# The foreign key for shared moments appears to be ZASSET.ZMOMENTSHARE
# but I don't know the key for shared iCloud Libraries
# I'm guessing it's ZASSET.ZSHARESCOPE but I don't know for sure and will need
# to test on a library that has shared iCloud Library and shared moments
sql = """ SELECT
ZSHARE.Z_PK,
ZSHARE.ZCLOUDDELETESTATE,
ZSHARE.ZLOCALPUBLISHSTATE,
ZSHARE.ZPUBLICPERMISSION,
ZSHARE.ZSCOPETYPE,
ZSHARE.ZSTATUS,
ZSHARE.ZTRASHEDSTATE,
ZSHARE.ZAUTOSHAREPOLICY,
ZSHARE.ZCLOUDITEMCOUNT,
ZSHARE.ZCLOUDLOCALSTATE,
ZSHARE.ZCLOUDPHOTOCOUNT,
ZSHARE.ZCLOUDVIDEOCOUNT,
ZSHARE.ZEXITSTATE,
ZSHARE.ZPARTICIPANTCLOUDUPDATESTATE,
ZSHARE.ZPREVIEWSTATE,
ZSHARE.ZSCOPESYNCINGSTATE,
ZSHARE.ZASSETCOUNT,
ZSHARE.ZFORCESYNCATTEMPTED,
ZSHARE.ZPHOTOSCOUNT,
ZSHARE.ZSHOULDIGNOREBUDGETS,
ZSHARE.ZSHOULDNOTIFYONUPLOADCOMPLETION,
ZSHARE.ZUPLOADEDPHOTOSCOUNT,
ZSHARE.ZUPLOADEDVIDEOSCOUNT,
ZSHARE.ZVIDEOSCOUNT,
ZSHARE.ZCREATIONDATE,
ZSHARE.ZEXPIRYDATE,
ZSHARE.ZTRASHEDDATE,
ZSHARE.ZLASTPARTICIPANTASSETTRASHNOTIFICATIONDATE,
ZSHARE.ZLASTPARTICIPANTASSETTRASHNOTIFICATIONVIEWEDDATE,
ZSHARE.ZENDDATE,
ZSHARE.ZSTARTDATE,
ZSHARE.ZSCOPEIDENTIFIER,
ZSHARE.ZTITLE,
ZSHARE.ZUUID,
ZSHARE.ZORIGINATINGSCOPEIDENTIFIER,
ZSHARE.ZSHAREURL,
ZSHARE.ZRULESDATA,
ZSHARE.ZPREVIEWDATA,
ZSHARE.ZTHUMBNAILIMAGEDATA,
ZSHARE.ZEXITSOURCE,
ZSHARE.ZCOUNTOFASSETSADDEDBYCAMERASMARTSHARING,
ZSHARE.ZEXITTYPE
FROM ZSHARE
JOIN ZASSET ON ZASSET.ZLIBRARYSCOPE = ZSHARE.Z_PK
WHERE ZASSET.ZUUID = '{}'
;"""
sql = sql.format(uuid)
if row := db.execute(sql).fetchone():
return ShareInfo(*row)
raise ValueError(f"Could not find share for uuid {uuid}")

View File

@ -0,0 +1,63 @@
"""Information about share participants for shared photos"""
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .photosdb import PhotosDB
def get_share_participants(db: PhotosDB, uuid: str) -> list[ShareParticipant]:
"""Return list of ShareParticipant objects for the given database"""
sql = """ SELECT
ZSHAREPARTICIPANT.Z_PK,
ZSHAREPARTICIPANT.ZACCEPTANCESTATUS,
ZSHAREPARTICIPANT.ZISCURRENTUSER,
ZSHAREPARTICIPANT.ZEXITSTATE,
ZSHAREPARTICIPANT.ZPERMISSION,
ZSHAREPARTICIPANT.ZPERSON,
ZSHAREPARTICIPANT.Z54_SHARE,
ZSHAREPARTICIPANT.ZSHARE,
ZSHAREPARTICIPANT.ZEMAILADDRESS,
ZSHAREPARTICIPANT.ZPARTICIPANTID,
ZSHAREPARTICIPANT.ZPHONENUMBER,
ZSHAREPARTICIPANT.ZUSERIDENTIFIER,
ZSHAREPARTICIPANT.ZUUID,
ZSHAREPARTICIPANT.ZNAMECOMPONENTS
FROM ZSHAREPARTICIPANT
JOIN ZASSETCONTRIBUTOR ON ZSHAREPARTICIPANT.Z_PK = ZASSETCONTRIBUTOR.ZPARTICIPANT
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.Z_PK = ZASSETCONTRIBUTOR.Z3LIBRARYSCOPEASSETCONTRIBUTORS
JOIN ZASSET ON ZASSET.Z_PK = ZADDITIONALASSETATTRIBUTES.ZASSET
WHERE ZASSET.ZUUID = '{}';""".format(
uuid
)
rows = db.execute(sql)
return [ShareParticipant(*row) for row in rows]
@dataclass
class ShareParticipant:
"""Information about a share participant"""
_pk: int
_acceptance_status: int
is_current_user: bool
_exit_state: int
_permission: int
_person: int
_z54_share: int
_share: int
email_address: str
participant_id: str
phone_number: str
user_identifier: str
uuid: str
_name_components: bytes
def asdict(self):
"""Return share participant as a dict"""
return dataclasses.asdict(self)