Feature syndicated photos 1054 (#1093)

* Added find() to repl

* Added find() to repl

* Added support for syndicated photos #1054
This commit is contained in:
Rhet Turnbull 2023-06-18 09:54:04 -07:00 committed by GitHub
parent 0473b45631
commit 8de871ccf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 330 additions and 94 deletions

View File

@ -390,7 +390,6 @@ Python < 3.11.
For example, the following code will work on Python >= 3.11. This code is available in the `examples` directory as
[concurrent_export.py](https://github.com/RhetTbull/osxphotos/blob/main/examples/concurrent_export.py).
```python
"""Example for concurrent export of photos using osxphotos.PhotoExporter.export()
@ -1183,6 +1182,15 @@ Returns True if photo is a [cloud asset](#iscloudasset) and is synched to iCloud
**Note**: Applies to master (original) photo only. It's possible for the master to be in iCloud but a local edited version is not yet synched to iCloud. `incloud` provides status of only the master photo. osxphotos does not yet provide a means to determine if the edited version is in iCloud. If you need this feature, please open an [issue](https://github.com/RhetTbull/osxphotos/issues).
#### `syndicated`
Return true if photo was shared via syndication (e.g. via Messages, etc.); these are photos that appear in "Shared with you" album. Photos 8+ only; returns None if not Photos 8+.
#### `saved_to_library`
Return True if syndicated photo has been saved to library; returns False if photo is not syndicated or has not been saved to the library.
Syndicated photos are photos that appear in "Shared with you" album. Photos 8+ only; returns None 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.
@ -2255,10 +2263,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.
@ -2270,77 +2278,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:
@ -2357,24 +2365,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`.
@ -2426,7 +2434,7 @@ cog.out(get_template_field_table())
|{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|
@ -2440,7 +2448,7 @@ cog.out(get_template_field_table())
|{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|
@ -2454,7 +2462,7 @@ cog.out(get_template_field_table())
|{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|
@ -2506,17 +2514,17 @@ cog.out(get_template_field_table())
|{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

@ -590,6 +590,26 @@ _QUERY_PARAMETERS_DICT = {
is_flag=True,
help="Search for photos that are not in iCloud (have not been synched)",
),
"--syndicated": click.Option(
["--syndicated"],
is_flag=True,
help="Search for photos that have been shared via syndication ('Shared with You' album via Messages, etc.)",
),
"--not-syndicated": click.Option(
["--not-syndicated"],
is_flag=True,
help="Search for photos that have not been shared via syndication ('Shared with You' album via Messages, etc.)",
),
"--saved-to-library": click.Option(
["--saved-to-library"],
is_flag=True,
help="Search for syndicated photos that have saved to the library",
),
"--not-saved-to-library": click.Option(
["--not-saved-to-library"],
is_flag=True,
help="Search for syndicated photos that have not saved to the library",
),
"--regex": click.Option(
["--regex"],
metavar="REGEX TEMPLATE",

View File

@ -886,6 +886,10 @@ def export(
verbose_flag,
xattr_template,
year,
syndicated,
not_syndicated,
saved_to_library,
not_saved_to_library,
selected=False, # Isn't provided on unsupported platforms
# debug, # debug, watch, breakpoint handled in cli/__init__.py
# watch,
@ -1107,6 +1111,11 @@ def export(
verbose_flag = cfg.verbose
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
# config file might have changed verbose
verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
verbose(f"Loaded options from file [filepath]{load_config}")
@ -1155,6 +1164,8 @@ def export(
("slow_mo", "not_slow_mo"),
("time_lapse", "not_time_lapse"),
("title", "no_title"),
("syndicated", "not_syndicated"),
("saved_to_library", "not_saved_to_library"),
]
dependent_options = [
("append", ("report")),

View File

@ -5,8 +5,11 @@ import os
import os.path
import pathlib
import re
import shlex
import subprocess
import sys
import time
from functools import partial
from typing import List
import click
@ -100,6 +103,7 @@ def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
get_photo = photosdb.get_photo
show = _show_photo
spotlight = _spotlight_photo
find = partial(_find_in_library, photosdb)
get_selected = _get_selected(photosdb)
try:
selected = get_selected()
@ -129,28 +133,31 @@ def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
)
print(f"\nThe following functions may be helpful:")
print(
f"- get_photo(uuid): return a PhotoInfo object for photo with uuid; e.g. get_photo('B13F4485-94E0-41CD-AF71-913095D62E31')"
"- get_photo(uuid): return a PhotoInfo object for photo with uuid; e.g. get_photo('B13F4485-94E0-41CD-AF71-913095D62E31')"
)
print(
f"- get_selected(); return list of PhotoInfo objects for photos selected in Photos"
"- get_selected(); return list of PhotoInfo objects for photos selected in Photos"
)
print(
f"- show(photo): open a photo object in the default viewer; e.g. show(selected[0])"
"- show(photo): open a photo object in the default viewer; e.g. show(selected[0])"
)
print(
f"- show(path): open a file at path in the default viewer; e.g. show('/path/to/photo.jpg')"
"- show(path): open a file at path in the default viewer; e.g. show('/path/to/photo.jpg')"
)
print(f"- spotlight(photo): open a photo and spotlight it in Photos")
print("- spotlight(photo): open a photo and spotlight it in Photos")
# print(
# f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
# )
print(
f"- inspect(object): print information about an object; e.g. inspect(PhotoInfo)"
"- inspect(object): print information about an object; e.g. inspect(PhotoInfo)"
)
print(
f"- explore(object): interactively explore an object with objexplore; e.g. explore(PhotoInfo)"
"- explore(object): interactively explore an object with objexplore; e.g. explore(PhotoInfo)"
)
print(f"- q, quit, quit(), exit, exit(): exit this interactive shell\n")
print(
"- find(text): search for files matching text in Photos library; e.g. find('B13F4485')"
)
print("- q, quit, quit(), exit, exit(): exit this interactive shell\n")
embed_repl(
globals=globals(),
@ -233,3 +240,20 @@ def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List:
) from e
return photos
def _find_in_library(photosdb: PhotosDB, search_str: str) -> list[str]:
"""Find files in Photos library matching search_str using find command"""
# this is a quick and dirty way to find files in the Photos library
# e.g. those matching a UUID or a filename
library_path = photosdb.library_path
if not library_path:
raise ValueError("Could not find Photos library")
search_str = shlex.quote(search_str)
library_path = shlex.quote(library_path)
cmd = f"find {library_path} | grep {search_str}"
output = subprocess.check_output(cmd, shell=True, universal_newlines=True)
# Split the output into lines and return as a list
return output.strip().split("\n")

View File

@ -65,10 +65,11 @@ from .query_builder import get_query
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .uti import get_preferred_uti_extension, get_uti_for_extension
from .utils import _get_resource_loc, assert_macos, is_macos, hexdigest, list_directory
from .utils import _get_resource_loc, assert_macos, hexdigest, is_macos, list_directory
if is_macos:
from osxmetadata import OSXMetaData
from .text_detection import detect_text
__all__ = ["PhotoInfo", "PhotoInfoNone", "frozen_photoinfo_factory"]
@ -134,8 +135,7 @@ class PhotoInfo:
if not self.hasadjustments and self._db._db_version <= _PHOTOS_4_VERSION:
return None
imagedate = self._info["lastmodifieddate"]
if imagedate:
if imagedate := self._info["lastmodifieddate"]:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
@ -172,6 +172,9 @@ class PhotoInfo:
"""Returns candidate path for original photo on Photos >= version 5"""
if self._info["shared"]:
return self._path_5_shared()
if self.syndicated and not self.saved_to_library:
# path for "shared with you" syndicated photos that have not yet been saved to the library
return self._path_syndication()
return (
os.path.join(self._info["directory"], self._info["filename"])
if self._info["directory"].startswith("/")
@ -211,6 +214,21 @@ class PhotoInfo:
filename,
)
def _path_syndication(self):
"""Return path for syndicated photo on Photos >= version 8"""
# Photos 8+ stores syndicated photos in a separate directory
# in ~/Photos Library.photoslibrary/scopes/syndication/originals/X/UUID.ext
# where X is first digit of UUID
syndication_path = "scopes/syndication/originals"
uuid_dir = self.uuid[0]
path = os.path.join(
self._db._library_path,
syndication_path,
uuid_dir,
self.filename,
)
return path if os.path.isfile(path) else None
def _path_4(self):
"""Returns candidate path for original photo on Photos <= version 4"""
if self._info["has_raw"]:
@ -881,6 +899,10 @@ class PhotoInfo:
elif self.live_photo and self.path and not self.ismissing:
if self.shared:
return self._path_live_photo_shared_5()
if self.syndicated and not self.saved_to_library:
# syndicated ("Shared with you") photos not yet saved to library
return self._path_live_syndicated()
filename = pathlib.Path(self.path)
photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
photopath = str(photopath)
@ -938,8 +960,24 @@ class PhotoInfo:
photopath = None
return photopath
def _path_live_syndicated(self):
"""Return path for live syndicated photo on Photos >= version 8"""
# Photos 8+ stores live syndicated photos in a separate directory
# in ~/Photos Library.photoslibrary/scopes/syndication/originals/X/UUID_3.mov
# where X is first digit of UUID
syndication_path = "scopes/syndication/originals"
uuid_dir = self.uuid[0]
filename = f"{pathlib.Path(self.filename).stem}_3.mov"
live_photo = os.path.join(
self._db._library_path,
syndication_path,
uuid_dir,
filename,
)
return live_photo if os.path.isfile(live_photo) else None
@cached_property
def path_derivatives(self):
def path_derivatives(self) -> list[str]:
"""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()
@ -948,24 +986,36 @@ class PhotoInfo:
return self._path_derivatives_5_shared()
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path) / f"resources/derivatives/{directory}"
)
if self.syndicated and not self.saved_to_library:
# syndicated ("Shared with you") photos not yet saved to library
derivative_path = "scopes/syndication/resources/derivatives"
thumb_path = (
f"{derivative_path}/masters/{directory}/{self.uuid}_4_5005_c.jpeg"
)
else:
derivative_path = f"resources/derivatives/{directory}"
thumb_path = (
f"resources/derivatives/masters/{directory}/{self.uuid}_4_5005_c.jpeg"
)
derivative_path = pathlib.Path(self._db._library_path).joinpath(derivative_path)
thumb_path = pathlib.Path(self._db._library_path).joinpath(thumb_path)
# find all files that start with uuid in derivative path
files = list(derivative_path.glob(f"{self.uuid}*.*"))
# previews may be missing from derivatives path
# there are what appear to be low res thumbnails in the "masters" subfolder
thumb_path = (
pathlib.Path(self._db._library_path)
/ f"resources/derivatives/masters/{directory}/{self.uuid}_4_5005_c.jpeg"
)
if thumb_path.exists():
files.append(thumb_path)
# sort by file size, largest first
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"):
# ensure .mov is first in list as poster image could be larger than the movie preview
derivatives[1], derivatives[0] = derivatives[0], derivatives[1]
return derivatives
@ -1284,6 +1334,38 @@ class PhotoInfo:
self._search_info_normalized = SearchInfo(self, normalized=True)
return self._search_info_normalized
@cached_property
def syndicated(self) -> bool | None:
"""Return true if photo was shared via syndication (e.g. via Messages, etc.);
these are photos that appear in "Shared with you" album.
Photos 8+ only; returns None if not Photos 8+.
"""
if self._db.photos_version < 8:
return None
try:
return (
self._db._db_syndication_uuid[self.uuid]["syndication_identifier"]
is not None
)
except KeyError:
return False
@cached_property
def saved_to_library(self) -> bool | None:
"""Return True if syndicated photo has been saved to library;
returns False if photo is not syndicated or has not been saved to the library.
Returns None if not Photos 8+.
Syndicated photos are photos that appear in "Shared with you" album; Photos 8+ only.
"""
if self._db.photos_version < 8:
return None
try:
return self._db._db_syndication_uuid[self.uuid]["syndication_history"] != 0
except KeyError:
return False
@property
def labels(self):
"""returns list of labels applied to photo by Photos image categorization

View File

@ -101,10 +101,13 @@ class PhotosAlbum:
try:
photos.append(photoscript.Photo(p.uuid))
except Exception as e:
print(f"Error creating Photo object for photo {self._format_uuid(p.uuid)}: {e}")
self.verbose(
f"Error creating Photo object for photo {self._format_uuid(p.uuid)}: {e}"
)
print(f"photos: {photos}")
for photolist in chunked(photos, 10):
print(f"photolist: {photolist}")
self.album.add(photolist)
photo_len = len(photo_list)
self.verbose(

View File

@ -7,7 +7,6 @@ import logging
import pathlib
import uuid as uuidlib
from functools import lru_cache
from pprint import pformat
from .._constants import _PHOTOS_4_VERSION, search_category_factory
from ..sqlite_utils import sqlite_db_is_locked, sqlite_open_ro
@ -154,7 +153,7 @@ def _process_searchinfo(self):
def labels(self):
"""return list of all search info labels found in the library"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
logging.warning("SearchInfo not implemented for this library version")
return []
return list(self._db_searchinfo_labels.keys())
@ -164,7 +163,7 @@ def labels(self):
def labels_normalized(self):
"""return list of all normalized search info labels found in the library"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
logging.warning("SearchInfo not implemented for this library version")
return []
return list(self._db_searchinfo_labels_normalized.keys())
@ -174,8 +173,8 @@ def labels_normalized(self):
def labels_as_dict(self):
"""return labels as dict of label: count in reverse sorted order (descending)"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return dict()
logging.warning("SearchInfo not implemented for this library version")
return {}
labels = {k: len(v) for k, v in self._db_searchinfo_labels.items()}
labels = dict(sorted(labels.items(), key=lambda kv: kv[1], reverse=True))
@ -186,8 +185,8 @@ def labels_as_dict(self):
def labels_normalized_as_dict(self):
"""return normalized labels as dict of label: count in reverse sorted order (descending)"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return dict()
logging.warning("SearchInfo not implemented for this library version")
return {}
labels = {k: len(v) for k, v in self._db_searchinfo_labels_normalized.items()}
labels = dict(sorted(labels.items(), key=lambda kv: kv[1], reverse=True))
return labels

View File

@ -0,0 +1,60 @@
""" Methods for PhotosDB to process Syndication info (#1054) """
from __future__ import annotations
from .._constants import _DB_TABLE_NAMES
from ..sqlite_utils import sqlite_open_ro
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from osxphotos.photosdb import PhotosDB
def _process_syndicationinfo(self: PhotosDB):
"""Process syndication information"""
self._db_syndication_uuid = {}
if self.photos_version < 8:
raise NotImplementedError(
f"syndication info not implemented for this database version: {self.photos_version}"
)
else:
_process_syndicationinfo_8(self)
def _process_syndicationinfo_8(photosdb: PhotosDB):
"""Process Syndication 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}.ZSYNDICATIONSTATE,
ZADDITIONALASSETATTRIBUTES.ZSYNDICATIONHISTORY,
ZADDITIONALASSETATTRIBUTES.ZSYNDICATIONIDENTIFIER
FROM {zasset}
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {zasset}.Z_PK
"""
)
for row in result:
uuid = row[0]
syndication_state = row[1]
syndication_history = row[2]
syndication_identifier = row[3]
photosdb._db_syndication_uuid[uuid] = {
"syndication_state": syndication_state,
"syndication_identifier": syndication_identifier,
"syndication_history": syndication_history,
}

View File

@ -96,6 +96,7 @@ class PhotosDB:
labels_normalized,
labels_normalized_as_dict,
)
from ._photosdb_process_syndicationinfo import _process_syndicationinfo
def __init__(
self,
@ -290,6 +291,10 @@ class PhotosDB:
# Dict to hold data on imports for Photos <= 4
self._db_import_group = {}
# Dict to hold syndication info for Photos >= 8
# key is UUID and value is dict of syndication info
self._db_syndication_uuid = {}
logger.debug(f"dbfile = {dbfile}")
if dbfile is None:
@ -2517,6 +2522,10 @@ class PhotosDB:
verbose("Processing moments.")
self._process_moments()
if self.photos_version >= 8:
verbose("Processing syndication info.")
self._process_syndicationinfo()
verbose("Done processing details from Photos library.")
def _process_moments(self):
@ -3516,6 +3525,16 @@ class PhotosDB:
added_after = datetime_naive_to_local(added_after)
photos = [p for p in photos if p.date_added and p.date_added > added_after]
if options.syndicated:
photos = [p for p in photos if p.syndicated]
elif options.not_syndicated:
photos = [p for p in photos if not p.syndicated]
if options.saved_to_library:
photos = [p for p in photos if p.syndicated and p.saved_to_library]
elif options.not_saved_to_library:
photos = [p for p in photos if p.syndicated and not p.saved_to_library]
if options.function:
for function in options.function:
photos = function[0](photos)

View File

@ -108,6 +108,10 @@ class QueryOptions:
uti: list of UTIs to search for
uuid: list of uuids to search for
year: search for photos taken in a given year
syndicated: search for photos that have been shared via syndication ("Shared with You" album via Messages, etc.)
not_syndicated: search for photos that have not been shared via syndication ("Shared with You" album via Messages, etc.)
saved_to_library: search for syndicated photos that have been saved to the Photos library
not_saved_to_library: search for syndicated photos that have not been saved to the Photos library
"""
added_after: Optional[datetime.datetime] = None
@ -192,6 +196,10 @@ class QueryOptions:
uti: Optional[Iterable[str]] = None
uuid: Optional[Iterable[str]] = None
year: Optional[Iterable[int]] = None
syndicated: Optional[bool] = None
not_syndicated: Optional[bool] = None
saved_to_library: Optional[bool] = None
not_saved_to_library: Optional[bool] = None
def asdict(self):
return asdict(self)
@ -261,6 +269,8 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions:
("deleted", "not_deleted"),
("deleted", "deleted_only"),
("deleted_only", "not_deleted"),
("syndicated", "not_syndicated"),
("saved_to_library", "not_saved_to_library"),
]
# TODO: add option to validate requiring at least one query arg
for arg, not_arg in exclusive: