Feature exportdb update 990 (#992)
* Added cloud_guid, #990 * Added fingerprint, cloud_guid to PhotoInfo.json * Added stub for --migrate-photos-library * Added --migrate-photos-library option to exportdb, #990
This commit is contained in:
parent
30c036a287
commit
06b02b4a23
138
API_README.md
138
API_README.md
@ -1312,6 +1312,14 @@ Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the
|
||||
|
||||
Returns list of PhotoInfo objects for *possible* duplicates or empty list if no matching duplicates. Photos are considered possible duplicates if the photo's original file size, date created, height, and width match another those of another photo. This does not do a byte-for-byte comparison or compute a hash which makes it fast and allows for identification of possible duplicates even if originals are not downloaded from iCloud. The signature-based approach should be robust enough to match duplicates created either through the "duplicate photo" menu item or imported twice into the library but you should not rely on this 100% for identification of all duplicates.
|
||||
|
||||
#### `cloud_guid`
|
||||
|
||||
For photos in iCloud, returns the cloud GUID for the photo. This is the unique identifier for the photo in iCloud. For photos not in iCloud, returns None.
|
||||
|
||||
#### `cloud_owner_hashed_id`
|
||||
|
||||
For shared photos, returns the hashed ID of the owner of the shared photo. For photos not shared, returns None.
|
||||
|
||||
#### `fingerprint`
|
||||
|
||||
Returns a unique fingerprint for the original photo file. This is a hash of the original photo file and is useful for finding duplicates or correlating photos across multiple libraries.
|
||||
@ -2126,10 +2134,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.
|
||||
|
||||
@ -2141,75 +2149,75 @@ 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'].
|
||||
- `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'].
|
||||
* `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:
|
||||
|
||||
@ -2226,24 +2234,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`.
|
||||
|
||||
@ -2295,7 +2303,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|
|
||||
@ -2309,7 +2317,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|
|
||||
@ -2323,7 +2331,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|
|
||||
@ -2375,17 +2383,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>
|
||||
|
||||
@ -119,7 +119,7 @@ def rich_echo(
|
||||
# if not outputting to terminal, use a huge width to avoid wrapping
|
||||
# otherwise tests fail
|
||||
width = 10_000
|
||||
console = get_rich_console() or Console(theme=theme, width=width)
|
||||
console = get_rich_console() or Console(theme or get_rich_theme(), width=width)
|
||||
if markdown:
|
||||
message = Markdown(message)
|
||||
# Markdown always adds a new line so disable unless explicitly specified
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
from textwrap import dedent
|
||||
|
||||
import click
|
||||
from rich import print
|
||||
@ -15,6 +16,7 @@ from osxphotos.export_db import (
|
||||
ExportDB,
|
||||
)
|
||||
from osxphotos.export_db_utils import (
|
||||
export_db_backup,
|
||||
export_db_check_signatures,
|
||||
export_db_get_errors,
|
||||
export_db_get_last_run,
|
||||
@ -23,9 +25,12 @@ from osxphotos.export_db_utils import (
|
||||
export_db_touch_files,
|
||||
export_db_update_signatures,
|
||||
export_db_vacuum,
|
||||
export_db_migrate_photos_library,
|
||||
export_db_get_last_library,
|
||||
)
|
||||
from osxphotos.utils import pluralize
|
||||
|
||||
from .cli_params import THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION
|
||||
from .click_rich_echo import (
|
||||
rich_click_echo,
|
||||
rich_echo,
|
||||
@ -34,7 +39,6 @@ from .click_rich_echo import (
|
||||
set_rich_theme,
|
||||
)
|
||||
from .color_themes import get_theme
|
||||
from .cli_params import TIMESTAMP_OPTION, VERBOSE_OPTION
|
||||
from .export import render_and_validate_report
|
||||
from .param_types import TemplateString
|
||||
from .report_writer import export_report_writer_factory
|
||||
@ -140,6 +144,15 @@ from .verbose import get_verbose_console, verbose_print
|
||||
metavar="SQL_STATEMENT",
|
||||
help="Execute SQL_STATEMENT against export database and print results.",
|
||||
)
|
||||
@click.option(
|
||||
"--migrate-photos-library",
|
||||
metavar="PHOTOS_LIBRARY",
|
||||
help="Migrate the export database to use the specified Photos library. "
|
||||
"Use this if you have moved your Photos library to a new location or computer and "
|
||||
"want to keep using the same export database. "
|
||||
"This will update the UUIDs in the export database to match the new Photos library.",
|
||||
type=click.Path(exists=True, file_okay=True, dir_okay=True),
|
||||
)
|
||||
@click.option(
|
||||
"--export-dir",
|
||||
help="Optional path to export directory (if not parent of export database).",
|
||||
@ -153,10 +166,11 @@ from .verbose import get_verbose_console, verbose_print
|
||||
)
|
||||
@VERBOSE_OPTION
|
||||
@TIMESTAMP_OPTION
|
||||
@THEME_OPTION
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
help="Run in dry-run mode (don't actually update files), e.g. for use with --update-signatures.",
|
||||
help="Run in dry-run mode (don't actually update files); for example, use with --update-signatures or --migrate-photos-library.",
|
||||
)
|
||||
@click.argument("export_db", metavar="EXPORT_DATABASE", type=click.Path(exists=True))
|
||||
def exportdb(
|
||||
@ -170,9 +184,11 @@ def exportdb(
|
||||
last_errors,
|
||||
last_run,
|
||||
migrate,
|
||||
migrate_photos_library,
|
||||
report,
|
||||
save_config,
|
||||
sql,
|
||||
theme,
|
||||
timestamp,
|
||||
touch_file,
|
||||
update_signatures,
|
||||
@ -185,12 +201,12 @@ def exportdb(
|
||||
version,
|
||||
):
|
||||
"""Utilities for working with the osxphotos export database"""
|
||||
verbose = verbose_print(verbose_flag, timestamp=timestamp)
|
||||
verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
|
||||
|
||||
# validate options and args
|
||||
if append and not report:
|
||||
rich_echo(
|
||||
"[error]Error: --append requires --report; ee --help for more information.[/]",
|
||||
rich_echo_error(
|
||||
"[error]Error: --append requires --report; see --help for more information.[/]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
@ -200,7 +216,7 @@ def exportdb(
|
||||
# assume it's the export folder
|
||||
export_db = export_db / OSXPHOTOS_EXPORT_DB
|
||||
if not export_db.is_file():
|
||||
rich_echo(
|
||||
rich_echo_error(
|
||||
f"[error]Error: {OSXPHOTOS_EXPORT_DB} missing from {export_db.parent}[/error]"
|
||||
)
|
||||
sys.exit(1)
|
||||
@ -226,7 +242,9 @@ def exportdb(
|
||||
]
|
||||
]
|
||||
if sum(sub_commands) > 1:
|
||||
rich_echo("[error]Only a single sub-command may be specified at a time[/error]")
|
||||
rich_echo_error(
|
||||
"[error]Only a single sub-command may be specified at a time[/error]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# process sub-commands
|
||||
@ -235,7 +253,7 @@ def exportdb(
|
||||
try:
|
||||
osxphotos_ver, export_db_ver = export_db_get_version(export_db)
|
||||
except Exception as e:
|
||||
rich_echo(
|
||||
rich_echo_error(
|
||||
f"[error]Error: could not read version from {export_db}: {e}[/error]"
|
||||
)
|
||||
sys.exit(1)
|
||||
@ -250,7 +268,7 @@ def exportdb(
|
||||
start_size = pathlib.Path(export_db).stat().st_size
|
||||
export_db_vacuum(export_db)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
rich_echo(
|
||||
@ -264,7 +282,7 @@ def exportdb(
|
||||
export_db, export_dir, verbose, dry_run
|
||||
)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
rich_echo(
|
||||
@ -276,7 +294,7 @@ def exportdb(
|
||||
try:
|
||||
last_run_info = export_db_get_last_run(export_db)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
rich_echo(f"last run at [time]{last_run_info[0]}:")
|
||||
@ -287,7 +305,7 @@ def exportdb(
|
||||
try:
|
||||
export_db_save_config_to_file(export_db, save_config)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
rich_echo(f"Saved configuration to [filepath]{save_config}")
|
||||
@ -299,7 +317,7 @@ def exportdb(
|
||||
export_db, export_dir, verbose_=verbose
|
||||
)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
rich_echo(
|
||||
@ -314,7 +332,7 @@ def exportdb(
|
||||
export_db, export_dir, verbose_=verbose, dry_run=dry_run
|
||||
)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
rich_echo(
|
||||
@ -328,7 +346,7 @@ def exportdb(
|
||||
try:
|
||||
info_rec = exportdb.get_file_record(info)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
if info_rec:
|
||||
@ -343,7 +361,7 @@ def exportdb(
|
||||
try:
|
||||
error_list = export_db_get_errors(export_db)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
if error_list:
|
||||
@ -375,7 +393,7 @@ def exportdb(
|
||||
try:
|
||||
info_rec = exportdb.get_photoinfo_for_uuid(uuid_info)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
if info_rec:
|
||||
@ -393,7 +411,7 @@ def exportdb(
|
||||
try:
|
||||
file_list = exportdb.get_files_for_uuid(uuid_files)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
if file_list:
|
||||
@ -433,12 +451,14 @@ def exportdb(
|
||||
report_filename = render_and_validate_report(report_template, "", export_dir)
|
||||
export_results = exportdb.get_export_results(run_id)
|
||||
if not export_results:
|
||||
rich_echo(f"[error]No report results found for run ID {run_id}[/error]")
|
||||
rich_echo_error(
|
||||
f"[error]No report results found for run ID {run_id}[/error]"
|
||||
)
|
||||
sys.exit(1)
|
||||
try:
|
||||
report_writer = export_report_writer_factory(report_filename, append=append)
|
||||
except ValueError as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
report_writer.write(export_results)
|
||||
report_writer.close()
|
||||
@ -463,9 +483,39 @@ def exportdb(
|
||||
c = exportdb._conn.cursor()
|
||||
results = c.execute(sql)
|
||||
except Exception as e:
|
||||
rich_echo(f"[error]Error: {e}[/error]")
|
||||
rich_echo_error(f"[error]Error: {e}[/error]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
for row in results:
|
||||
print(row)
|
||||
sys.exit(0)
|
||||
|
||||
if migrate_photos_library:
|
||||
# migrate Photos library to new library and update UUIDs in export database
|
||||
last_library = export_db_get_last_library(export_db)
|
||||
rich_echo(
|
||||
dedent(
|
||||
f"""
|
||||
[warning]:warning-emoji: This command will update your export database ([filepath]{export_db}[/])
|
||||
to use [filepath]{migrate_photos_library}[/] as the new source library.
|
||||
The last library used was [filepath]{last_library}[/].
|
||||
This will allow you to use the export database with the new library but it will
|
||||
no longer work correctly with the old library unless you run the `--migrate-photos-library`
|
||||
command again to update the export database to use the previous library.
|
||||
|
||||
A backup of the export database will be created in the same directory as the export database.
|
||||
"""
|
||||
)
|
||||
)
|
||||
if not click.confirm("Do you want to continue?"):
|
||||
sys.exit(0)
|
||||
if not dry_run:
|
||||
backup_file = export_db_backup(export_db)
|
||||
verbose(f"Backed up export database to [filepath]{backup_file}[/]")
|
||||
migrated, notmigrated = export_db_migrate_photos_library(
|
||||
export_db, migrate_photos_library, verbose, dry_run
|
||||
)
|
||||
rich_echo(
|
||||
f"Migrated [num]{migrated}[/] {pluralize(migrated, 'photo', 'photos')}, "
|
||||
f"[num]{notmigrated}[/] not migrated."
|
||||
)
|
||||
|
||||
@ -200,15 +200,17 @@ def _verbose_print_function(
|
||||
Returns:
|
||||
function to print output
|
||||
"""
|
||||
if not verbose:
|
||||
return noop
|
||||
|
||||
# configure console even if verbose is False so that rich_echo will work correctly
|
||||
global _console
|
||||
if file:
|
||||
_console.console = Console(theme=theme, file=file)
|
||||
else:
|
||||
_console.console = Console(theme=theme, width=10_000)
|
||||
|
||||
if not verbose:
|
||||
return noop
|
||||
|
||||
# closure to capture timestamp
|
||||
def verbose_(*args, level: int = 1):
|
||||
"""print output if verbose flag set"""
|
||||
|
||||
@ -1,28 +1,37 @@
|
||||
""" Utility functions for working with export_db """
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
from typing import Callable, Optional, Tuple, Union
|
||||
from typing import Any, Callable, Optional, Tuple, Union
|
||||
|
||||
import toml
|
||||
from rich import print
|
||||
|
||||
from osxphotos.photoinfo import PhotoInfo
|
||||
|
||||
from ._constants import OSXPHOTOS_EXPORT_DB
|
||||
from ._version import __version__
|
||||
from .configoptions import ConfigOptions
|
||||
from .export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB
|
||||
from .fileutil import FileUtil
|
||||
from .photosdb import PhotosDB
|
||||
from .utils import noop
|
||||
from .utils import hexdigest, noop
|
||||
|
||||
__all__ = [
|
||||
"export_db_backup",
|
||||
"export_db_check_signatures",
|
||||
"export_db_get_errors",
|
||||
"export_db_get_last_library",
|
||||
"export_db_get_last_run",
|
||||
"export_db_get_version",
|
||||
"export_db_migrate_photos_library",
|
||||
"export_db_save_config_to_file",
|
||||
"export_db_touch_files",
|
||||
"export_db_update_signatures",
|
||||
@ -293,3 +302,238 @@ def export_db_touch_files(
|
||||
rec.dest_sig = (dest_mode, dest_size, ts)
|
||||
|
||||
return (touched, not_touched, skipped)
|
||||
|
||||
|
||||
def export_db_migrate_photos_library(
|
||||
dbfile: Union[str, pathlib.Path],
|
||||
photos_library: Union[str, pathlib.Path],
|
||||
verbose: Callable = noop,
|
||||
dry_run: bool = False,
|
||||
):
|
||||
"""
|
||||
Migrate export database to new Photos library
|
||||
This will attempt to match photos in the new library to photos in the old library
|
||||
and update the UUIDs in the export database
|
||||
"""
|
||||
verbose(f"Loading data from export database {dbfile}")
|
||||
conn = sqlite3.connect(str(dbfile))
|
||||
c = conn.cursor()
|
||||
results = c.execute("SELECT uuid, photoinfo FROM photoinfo;").fetchall()
|
||||
exportdb_uuids = {}
|
||||
for row in results:
|
||||
uuid = row[0]
|
||||
photoinfo = json.loads(row[1])
|
||||
exportdb_uuids[uuid] = photoinfo
|
||||
|
||||
verbose(f"Loading data from Photos library {photos_library}")
|
||||
photosdb = PhotosDB(dbfile=photos_library, verbose=verbose)
|
||||
photosdb_fingerprint = {}
|
||||
photosdb_cloud_guid = {}
|
||||
photosdb_shared = {}
|
||||
for photo in photosdb.photos():
|
||||
photosdb_fingerprint[
|
||||
f"{photo.original_filename}:{photo.fingerprint}"
|
||||
] = photo.uuid
|
||||
photosdb_cloud_guid[
|
||||
f"{photo.original_filename}:{photo.cloud_guid}"
|
||||
] = photo.uuid
|
||||
if photo.shared:
|
||||
photosdb_shared[_shared_photo_key(photo)] = photo.uuid
|
||||
verbose("Matching photos in export database to photos in Photos library")
|
||||
matched = 0
|
||||
notmatched = 0
|
||||
for uuid, photoinfo in exportdb_uuids.items():
|
||||
if photoinfo.get("shared"):
|
||||
key = _shared_photo_key(photoinfo)
|
||||
if key in photosdb_shared:
|
||||
new_uuid = photosdb_shared[key]
|
||||
verbose(
|
||||
f"[green]Matched by shared info[/green]: [uuid]{uuid}[/] -> [uuid]{new_uuid}[/]"
|
||||
)
|
||||
_export_db_update_uuid_info(
|
||||
conn, uuid, new_uuid, photoinfo, photosdb, dry_run
|
||||
)
|
||||
matched += 1
|
||||
continue
|
||||
if cloud_guid := photoinfo.get("cloud_guid", None):
|
||||
key = f"{photoinfo['original_filename']}:{cloud_guid}"
|
||||
if key in photosdb_cloud_guid:
|
||||
new_uuid = photosdb_cloud_guid[key]
|
||||
verbose(
|
||||
f"[green]Matched by cloud_guid[/green]: [uuid]{uuid}[/] -> [uuid]{new_uuid}[/]"
|
||||
)
|
||||
_export_db_update_uuid_info(
|
||||
conn, uuid, new_uuid, photoinfo, photosdb, dry_run
|
||||
)
|
||||
matched += 1
|
||||
continue
|
||||
if fingerprint := photoinfo.get("fingerprint", None):
|
||||
key = f"{photoinfo['original_filename']}:{fingerprint}"
|
||||
if key in photosdb_fingerprint:
|
||||
new_uuid = photosdb_fingerprint[key]
|
||||
verbose(
|
||||
f"[green]Matched by fingerprint[/green]: [uuid]{uuid}[/] -> [uuid]{new_uuid}[/]"
|
||||
)
|
||||
_export_db_update_uuid_info(
|
||||
conn, uuid, new_uuid, photoinfo, photosdb, dry_run
|
||||
)
|
||||
matched += 1
|
||||
continue
|
||||
else:
|
||||
verbose(
|
||||
f"[dark_orange]No match found for photo[/dark_orange]: [uuid]{uuid}[/], [filename]{photoinfo.get('original_filename')}[/]"
|
||||
)
|
||||
notmatched += 1
|
||||
|
||||
if not dry_run:
|
||||
conn.execute("VACUUM;")
|
||||
conn.close()
|
||||
return (matched, notmatched)
|
||||
|
||||
|
||||
def _shared_photo_key(photo: PhotoInfo | dict[str, Any]) -> str:
|
||||
"""return a key for matching a shared photo between libraries"""
|
||||
photoinfo = photo.asdict() if isinstance(photo, PhotoInfo) else photo
|
||||
date = photoinfo.get("date")
|
||||
if isinstance(date, datetime.datetime):
|
||||
date = date.isoformat()
|
||||
return (
|
||||
f"{photoinfo.get('cloud_owner_hashed_id')}:"
|
||||
f"{photoinfo.get('original_height')}:"
|
||||
f"{photoinfo.get('original_width')}:"
|
||||
f"{photoinfo.get('isphoto')}:"
|
||||
f"{photoinfo.get('ismovie')}:"
|
||||
f"{date}"
|
||||
)
|
||||
|
||||
|
||||
def _export_db_update_uuid_info(
|
||||
conn: sqlite3.Connection,
|
||||
uuid: str,
|
||||
new_uuid: str,
|
||||
photoinfo: dict[str, Any],
|
||||
photosdb: PhotosDB,
|
||||
dry_run: bool = False,
|
||||
):
|
||||
"""
|
||||
Update the UUID and digest in the export database to match a new UUID
|
||||
|
||||
Args:
|
||||
conn (sqlite3.Connection): connection to export database
|
||||
uuid (str): old UUID
|
||||
new_uuid (str): new UUID
|
||||
photoinfo (dict): photoinfo for old UUID
|
||||
photosdb (PhotosDB): PhotosDB instance for new library
|
||||
dry_run (bool): if True, don't update the database
|
||||
"""
|
||||
if dry_run:
|
||||
return
|
||||
new_digest = compute_photoinfo_digest(photoinfo, photosdb.get_photo(new_uuid))
|
||||
export_db_update_uuid(conn, uuid, new_uuid)
|
||||
export_db_update_digest_for_uuid(conn, new_uuid, new_digest)
|
||||
|
||||
|
||||
def export_db_update_uuid(
|
||||
conn: sqlite3.Connection, uuid: str, new_uuid: str
|
||||
) -> Tuple[bool, str]:
|
||||
"""Update the UUID in the export database
|
||||
|
||||
Args:
|
||||
conn (sqlite3.Connection): connection to export database
|
||||
uuid (str): old UUID
|
||||
new_uuid (str): new UUID
|
||||
|
||||
Returns:
|
||||
(bool, str): (success, error)
|
||||
"""
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute(
|
||||
"UPDATE photoinfo SET uuid=? WHERE uuid=?;",
|
||||
(new_uuid, uuid),
|
||||
)
|
||||
c.execute(
|
||||
"UPDATE export_data SET uuid=? WHERE uuid=?;",
|
||||
(new_uuid, uuid),
|
||||
)
|
||||
conn.commit()
|
||||
return (True, "")
|
||||
except Exception as e:
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def export_db_backup(dbpath: Union[str, pathlib.Path]) -> str:
|
||||
"""Backup export database, returns name of backup file"""
|
||||
dbpath = pathlib.Path(dbpath)
|
||||
# create backup with .bak extension and datestamp in YYYYMMDDHHMMSS format
|
||||
source_file = dbpath.parent / dbpath.name
|
||||
datestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
# first try to copy .db-shm and .db-wal files if they exist
|
||||
for suffix in (".db-shm", ".db-wal"):
|
||||
backup_file = f"{source_file.with_suffix(suffix)}.{datestamp}.bak"
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
FileUtil.copy(source_file, backup_file)
|
||||
backup_file = f"{source_file}.{datestamp}.bak"
|
||||
FileUtil.copy(source_file, backup_file)
|
||||
return backup_file
|
||||
|
||||
|
||||
def export_db_get_last_library(dbpath: Union[str, pathlib.Path]) -> str:
|
||||
"""Return the last library used to export from
|
||||
|
||||
This isn't stored separately in the database but can be extracted from the
|
||||
stored JSON in the photoinfo table. Use the most recent export_data entry
|
||||
to get the UUID of the last exported photo and then use that to get the
|
||||
library name from the photoinfo table.
|
||||
|
||||
Args:
|
||||
dbpath (Union[str, pathlib.Path]): path to export database
|
||||
|
||||
Returns:
|
||||
str: name of library used to export from or "" if not found
|
||||
"""
|
||||
dbpath = pathlib.Path(dbpath)
|
||||
conn = sqlite3.connect(str(dbpath))
|
||||
c = conn.cursor()
|
||||
if results := c.execute(
|
||||
"""
|
||||
SELECT json_extract(photoinfo.photoinfo, '$.library')
|
||||
FROM photoinfo
|
||||
WHERE photoinfo.uuid = (
|
||||
SELECT export_data.uuid
|
||||
FROM export_data
|
||||
WHERE export_data.timestamp = (
|
||||
SELECT MAX(export_data.timestamp)
|
||||
FROM export_data))
|
||||
"""
|
||||
).fetchone():
|
||||
return results[0]
|
||||
return ""
|
||||
|
||||
|
||||
def export_db_update_digest_for_uuid(
|
||||
conn: sqlite3.Connection, uuid: str, digest: str
|
||||
) -> None:
|
||||
"""Update the export_data.digest column for the given UUID"""
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"UPDATE export_data SET digest=? WHERE uuid=?;",
|
||||
(digest, uuid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def compute_photoinfo_digest(photoinfo: dict[str, Any], photo: PhotoInfo) -> str:
|
||||
"""Compute a new digest for a photoinfo dictionary using the UUID and library from photo
|
||||
|
||||
Args:
|
||||
photoinfo (dict[str, Any]): photoinfo dictionary
|
||||
photo (PhotoInfo): PhotoInfo object for the new photo
|
||||
|
||||
Returns:
|
||||
str: new digest
|
||||
"""
|
||||
new_dict = photoinfo.copy()
|
||||
new_dict["uuid"] = photo.uuid
|
||||
new_dict["library"] = photo._db._library_path
|
||||
return hexdigest(json.dumps(new_dict, sort_keys=True))
|
||||
|
||||
@ -70,6 +70,7 @@ __all__ = ["PhotoInfo", "PhotoInfoNone"]
|
||||
|
||||
logger = logging.getLogger("osxphotos")
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
"""
|
||||
Info about a specific photo, contains all the details about the photo
|
||||
@ -1452,6 +1453,16 @@ class PhotoInfo:
|
||||
metadata = plistlib.loads(results[0])
|
||||
return metadata
|
||||
|
||||
@cached_property
|
||||
def cloud_guid(self) -> str:
|
||||
"""Returns the GUID of the photo in iCloud (Photos 5+ only)"""
|
||||
return self._info["cloudMasterGUID"]
|
||||
|
||||
@cached_property
|
||||
def cloud_owner_hashed_id(self) -> str:
|
||||
"""Returns the hashed iCloud ID of the owner of the shared photo (Photos 5+ only)"""
|
||||
return self._info["cloudownerhashedpersonid"]
|
||||
|
||||
@cached_property
|
||||
def fingerprint(self) -> str:
|
||||
"""Returns fingerprint of original photo as a string"""
|
||||
@ -1842,6 +1853,9 @@ class PhotoInfo:
|
||||
"comments": comments,
|
||||
"likes": likes,
|
||||
"search_info": search_info,
|
||||
"fingerprint": self.fingerprint,
|
||||
"cloud_guid": self.cloud_guid,
|
||||
"cloud_owner_hashed_id": self.cloud_owner_hashed_id,
|
||||
}
|
||||
|
||||
def json(self):
|
||||
|
||||
@ -808,10 +808,10 @@ class PhotosDB:
|
||||
"cloudlibrarystate": album[2],
|
||||
"cloudidentifier": album[3],
|
||||
"intrash": False if album[4] == 0 else True,
|
||||
"cloudlocalstate": None, # Photos 5
|
||||
"cloudownerfirstname": None, # Photos 5
|
||||
"cloudownderlastname": None, # Photos 5
|
||||
"cloudownerhashedpersonid": None, # Photos 5
|
||||
"cloudlocalstate": None, # Photos 5+
|
||||
"cloudownerfirstname": None, # Photos 5+
|
||||
"cloudownderlastname": None, # Photos 5+
|
||||
"cloudownerhashedpersonid": None, # Photos 5+
|
||||
"folderUuid": album[5],
|
||||
"albumType": album[6],
|
||||
"albumSubclass": album[7],
|
||||
@ -1152,12 +1152,13 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["momentID"] = row[28]
|
||||
|
||||
# Init cloud details that will be filled in later if cloud asset
|
||||
self._dbphotos[uuid]["cloudAssetGUID"] = None # Photos 5
|
||||
self._dbphotos[uuid]["cloudLocalState"] = None # Photos 5
|
||||
self._dbphotos[uuid]["cloudAssetGUID"] = None # Photos 5+
|
||||
self._dbphotos[uuid]["cloudLocalState"] = None # Photos 5+
|
||||
self._dbphotos[uuid]["cloudLibraryState"] = None
|
||||
self._dbphotos[uuid]["cloudStatus"] = None
|
||||
self._dbphotos[uuid]["cloudAvailable"] = None
|
||||
self._dbphotos[uuid]["incloud"] = None
|
||||
self._dbphotos[uuid]["cloudMasterGUID"] = None # Photos 5+
|
||||
|
||||
# associated RAW image info
|
||||
self._dbphotos[uuid]["has_raw"] = True if row[25] == 7 else False
|
||||
@ -2120,6 +2121,7 @@ class PhotosDB:
|
||||
info["cloudLibraryState"] = None # Photos 4
|
||||
info["cloudStatus"] = None # Photos 4
|
||||
info["cloudAvailable"] = None # Photos 4
|
||||
info["cloudMasterGUID"] = None
|
||||
|
||||
# reverse geolocation info
|
||||
info["reverse_geolocation"] = row[25]
|
||||
@ -2366,7 +2368,8 @@ class PhotosDB:
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZCLOUDMASTER.ZCLOUDLOCALSTATE
|
||||
ZCLOUDMASTER.ZCLOUDLOCALSTATE,
|
||||
ZCLOUDMASTER.ZCLOUDMASTERGUID
|
||||
FROM ZCLOUDMASTER, {asset_table}
|
||||
WHERE {asset_table}.ZMASTER = ZCLOUDMASTER.Z_PK """
|
||||
)
|
||||
@ -2375,6 +2378,7 @@ class PhotosDB:
|
||||
if uuid in self._dbphotos:
|
||||
self._dbphotos[uuid]["cloudLocalState"] = row[1]
|
||||
self._dbphotos[uuid]["incloud"] = True if row[1] == 3 else False
|
||||
self._dbphotos[uuid]["cloudMasterGUID"] = row[2]
|
||||
|
||||
# get information about associted RAW images
|
||||
# RAW images have ZDATASTORESUBTYPE = 17
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
Click>=8.0.4,<9.0s
|
||||
Mako>=1.2.2,<1.3.0
|
||||
PyYAML>=6.0.0,<7.0.0
|
||||
bitmath>=1.3.3.1,<1.4.0.0
|
||||
bpylist2==4.0.1
|
||||
Click>=8.0.4,<9.0
|
||||
Mako>=1.2.2,<1.3.0
|
||||
more-itertools>=8.8.0,<9.0.0
|
||||
objexplore>=1.6.3,<2.0.0
|
||||
osxmetadata>=1.2.0,<2.0.0
|
||||
@ -11,9 +10,9 @@ pathvalidate>=2.4.1,<2.5.0
|
||||
photoscript>=0.3.0,<0.4.0
|
||||
ptpython>=3.0.20,<3.1.0
|
||||
pyobjc-core>=9.0,<10.0
|
||||
pyobjc-framework-AVFoundation>=9.0,<10.0
|
||||
pyobjc-framework-AppleScriptKit>=9.0,<10.0
|
||||
pyobjc-framework-AppleScriptObjC>=9.0,<10.0
|
||||
pyobjc-framework-AVFoundation>=9.0,<10.0
|
||||
pyobjc-framework-Cocoa>=9.0,<10.0
|
||||
pyobjc-framework-CoreServices>=9.0,<10.0
|
||||
pyobjc-framework-Metal>=9.0,<10.0
|
||||
@ -21,9 +20,10 @@ pyobjc-framework-Photos>=9.0,<10.0
|
||||
pyobjc-framework-Quartz>=9.0,<10.0
|
||||
pyobjc-framework-Vision>=9.0,<10.0
|
||||
pytimeparse2==1.4.0
|
||||
PyYAML>=6.0.0,<7.0.0
|
||||
requests>=2.27.1,<3.0.0
|
||||
rich>=11.2.0,<13.0.0
|
||||
rich_theme_manager>=0.11.0
|
||||
rich>=11.2.0,<13.0.0
|
||||
shortuuid==1.0.9
|
||||
strpdatetime>=0.2.0
|
||||
tenacity>=8.0.1,<9.0.0
|
||||
@ -31,4 +31,4 @@ textx>=3.1.1,<4.0.0
|
||||
toml>=0.10.2,<0.11.0
|
||||
wrapt>=1.14.1,<2.0.0
|
||||
wurlitzer>=3.0.2,<4.0.0
|
||||
xdg==5.1.1
|
||||
xdg==5.1.1
|
||||
10
setup.py
10
setup.py
@ -73,11 +73,10 @@ setup(
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
],
|
||||
install_requires=[
|
||||
"Click>=8.1.3,<9.0",
|
||||
"Mako>=1.2.2,<1.3.0",
|
||||
"PyYAML>=6.0.0,<7.0.0",
|
||||
"bitmath>=1.3.3.1,<1.4.0.0",
|
||||
"bpylist2==4.0.1",
|
||||
"Click>=8.1.3,<9.0",
|
||||
"Mako>=1.2.2,<1.3.0",
|
||||
"more-itertools>=8.8.0,<9.0.0",
|
||||
"objexplore>=1.6.3,<2.0.0",
|
||||
"osxmetadata>=1.2.0,<2.0.0",
|
||||
@ -86,9 +85,9 @@ setup(
|
||||
"photoscript>=0.3.0,<0.4.0",
|
||||
"ptpython>=3.0.20,<4.0.0",
|
||||
"pyobjc-core>=9.0,<=10.0",
|
||||
"pyobjc-framework-AVFoundation>=9.0,<10.0",
|
||||
"pyobjc-framework-AppleScriptKit>=9.0,<10.0",
|
||||
"pyobjc-framework-AppleScriptObjC>=9.0,<10.0",
|
||||
"pyobjc-framework-AVFoundation>=9.0,<10.0",
|
||||
"pyobjc-framework-Cocoa>=9.0,<10.0",
|
||||
"pyobjc-framework-CoreServices>=9.0,<10.0",
|
||||
"pyobjc-framework-Metal>=9.0,<10.0",
|
||||
@ -96,9 +95,10 @@ setup(
|
||||
"pyobjc-framework-Quartz>=9.0,<10.0",
|
||||
"pyobjc-framework-Vision>=9.0,<10.0",
|
||||
"pytimeparse2==1.4.0",
|
||||
"PyYAML>=6.0.0,<7.0.0",
|
||||
"requests>=2.27.1,<3.0.0",
|
||||
"rich>=11.2.0,<13.0.0",
|
||||
"rich_theme_manager>=0.11.0",
|
||||
"rich>=11.2.0,<13.0.0",
|
||||
"shortuuid==1.0.9",
|
||||
"strpdatetime>=0.2.0",
|
||||
"tenacity>=8.0.1,<9.0.0",
|
||||
|
||||
@ -10,6 +10,7 @@ UUID_DICT = {
|
||||
"incloud": "FC638F58-84BE-4083-B5DE-F85BDC729062",
|
||||
"shared": "2094984A-21DC-4A6E-88A6-7344F648B92E",
|
||||
"cloudasset": "FC638F58-84BE-4083-B5DE-F85BDC729062",
|
||||
"cloud_guid": "FC638F58-84BE-4083-B5DE-F85BDC729062",
|
||||
}
|
||||
|
||||
|
||||
@ -31,3 +32,16 @@ def test_cloudasset_1(photosdb):
|
||||
def test_path_derivatives(photosdb):
|
||||
photo = photosdb.get_photo(UUID_DICT["shared"])
|
||||
assert photo.path_derivatives
|
||||
|
||||
|
||||
def test_cloud_guid(photosdb):
|
||||
photo = photosdb.get_photo(UUID_DICT["cloud_guid"])
|
||||
assert photo.cloud_guid == "AV6qPsEIU88bFVPKfEUjoyspg+d+"
|
||||
|
||||
|
||||
def test_cloud_owner_hashed_id(photosdb):
|
||||
photo = photosdb.get_photo(UUID_DICT["shared"])
|
||||
assert (
|
||||
photo.cloud_owner_hashed_id
|
||||
== "01cac7cde8a8767251a2cdd183b8ed490fc6e87825f4a495389e824a41a01ba7"
|
||||
)
|
||||
|
||||
@ -13,6 +13,7 @@ UUID_DICT = {
|
||||
"cloudasset": "D11D25FF-5F31-47D2-ABA9-58418878DC15",
|
||||
"not_cloudasset": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4",
|
||||
"shared": "4AD7C8EF-2991-4519-9D3A-7F44A6F031BE",
|
||||
"cloud_guid": "C2BBC7A4-5333-46EE-BAF0-093E72111B39",
|
||||
}
|
||||
|
||||
|
||||
@ -58,3 +59,16 @@ def test_cloudasset_3():
|
||||
def test_path_derivatives(photosdb):
|
||||
photo = photosdb.get_photo(UUID_DICT["shared"])
|
||||
assert photo.path_derivatives
|
||||
|
||||
|
||||
def test_cloud_guid(photosdb):
|
||||
photo = photosdb.get_photo(UUID_DICT["cloud_guid"])
|
||||
assert photo.cloud_guid == "AV6qPsEIU88bFVPKfEUjoyspg+d+"
|
||||
|
||||
|
||||
def test_cloud_owner_hashed_id(photosdb):
|
||||
photo = photosdb.get_photo(UUID_DICT["shared"])
|
||||
assert (
|
||||
photo.cloud_owner_hashed_id
|
||||
== "01cac7cde8a8767251a2cdd183b8ed490fc6e87825f4a495389e824a41a01ba7"
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user