Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65674f57bc | ||
|
|
7af1ccd4ed | ||
|
|
1b6f661e6b | ||
|
|
a57da2346b | ||
|
|
3fe03cd127 | ||
|
|
5cc98c338b | ||
|
|
1c9d4f282b | ||
|
|
1ceda15134 | ||
|
|
a80071111f | ||
|
|
072a8d795e | ||
|
|
b35b071634 | ||
|
|
56a000609f | ||
|
|
54d5d4b7ba | ||
|
|
38137a1351 | ||
|
|
4b29a2e05f | ||
|
|
9be0f849b7 | ||
|
|
ccb5f252d1 | ||
|
|
d8a64c9573 | ||
|
|
81d4e392c3 |
2
.github/workflows/pythonpackage.yml
vendored
2
.github/workflows/pythonpackage.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
max-parallel: 4
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.6, 3.7, 3.8]
|
python-version: [3.8]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
|
|||||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. Dates are d
|
|||||||
|
|
||||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||||
|
|
||||||
|
#### [v0.28.7](https://github.com/RhetTbull/osxphotos/compare/v0.28.6...v0.28.7)
|
||||||
|
|
||||||
|
> 28 April 2020
|
||||||
|
|
||||||
|
- Added --album-keyword and --person-keyword to CLI, closes #61 [`#61`](https://github.com/RhetTbull/osxphotos/issues/61)
|
||||||
|
- Updated test libraries [`54d5d4b`](https://github.com/RhetTbull/osxphotos/commit/54d5d4b7ba99204f58e723231309ab6e306be28c)
|
||||||
|
- Updated CHANGELOG.md [`38137a1`](https://github.com/RhetTbull/osxphotos/commit/38137a1351cdb7ab72393ea03828933dac0b76b0)
|
||||||
|
- Updated tests/README.md [`56a0006`](https://github.com/RhetTbull/osxphotos/commit/56a000609f2f08d0f8800fec49cada2980c3bb9d)
|
||||||
|
|
||||||
|
#### [v0.28.6](https://github.com/RhetTbull/osxphotos/compare/v0.28.5...v0.28.6)
|
||||||
|
|
||||||
|
> 26 April 2020
|
||||||
|
|
||||||
|
- Fixed locale bug in templates, closes #113 [`#113`](https://github.com/RhetTbull/osxphotos/issues/113)
|
||||||
|
- Updated CHANGELOG.md [`81d4e39`](https://github.com/RhetTbull/osxphotos/commit/81d4e392c39f0fe6f967a447c7d0c970bf224032)
|
||||||
|
- Updated test to avoid issue with GitHub workflow [`9be0f84`](https://github.com/RhetTbull/osxphotos/commit/9be0f849b73061d053d30274ff3295b79c88f0b6)
|
||||||
|
- Update pythonpackage.yml to remove older pythons [`ccb5f25`](https://github.com/RhetTbull/osxphotos/commit/ccb5f252d14e9335ae04a2e338a6d527b80c9a93)
|
||||||
|
|
||||||
|
#### [v0.28.5](https://github.com/RhetTbull/osxphotos/compare/0.28.2...v0.28.5)
|
||||||
|
|
||||||
|
> 21 April 2020
|
||||||
|
|
||||||
|
- added __len__ to PhotosDB, closes #44 [`#44`](https://github.com/RhetTbull/osxphotos/issues/44)
|
||||||
|
- Updated use of _PHOTOS_4_VERSION, closes #106 [`#106`](https://github.com/RhetTbull/osxphotos/issues/106)
|
||||||
|
- Updated tests and test library with RAW images [`9b9b54e`](https://github.com/RhetTbull/osxphotos/commit/9b9b54e590e43ae49fb3ae41d493a1f8faec4181)
|
||||||
|
- Updated setup.py to resolve issue with bpylist2 on python < 3.8 [`8e4b88a`](https://github.com/RhetTbull/osxphotos/commit/8e4b88ad1fc18438f941e045bfc8aeac878914f9)
|
||||||
|
- Added cli.py for use with pyinstaller [`cf28cb6`](https://github.com/RhetTbull/osxphotos/commit/cf28cb6452de17f2ef8d80435386e8d5a1aabd34)
|
||||||
|
- added raw_is_original handling [`a337e79`](https://github.com/RhetTbull/osxphotos/commit/a337e79e13802b4824c2f088ce9db1c027d6f3c5)
|
||||||
|
- Updated CHANGELOG.md [`22f1e8f`](https://github.com/RhetTbull/osxphotos/commit/22f1e8f2a6478e0576f6bff53e348aad8680ae69)
|
||||||
|
|
||||||
#### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2)
|
#### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2)
|
||||||
|
|
||||||
> 18 April 2020
|
> 18 April 2020
|
||||||
@@ -285,7 +315,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|||||||
|
|
||||||
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.15.0...v0.15.1)
|
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.15.0...v0.15.1)
|
||||||
|
|
||||||
> 19 April 2020
|
> 28 April 2020
|
||||||
|
|
||||||
#### [v0.15.0](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.0)
|
#### [v0.15.0](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.0)
|
||||||
|
|
||||||
|
|||||||
100
README.md
100
README.md
@@ -16,7 +16,7 @@
|
|||||||
+ [AlbumInfo](#albuminfo)
|
+ [AlbumInfo](#albuminfo)
|
||||||
+ [FolderInfo](#folderinfo)
|
+ [FolderInfo](#folderinfo)
|
||||||
+ [PlaceInfo](#placeinfo)
|
+ [PlaceInfo](#placeinfo)
|
||||||
+ [Template Functions](#template-functions)
|
+ [Template Substitutions](#template-substitutions)
|
||||||
+ [Utility Functions](#utility-functions)
|
+ [Utility Functions](#utility-functions)
|
||||||
* [Examples](#examples)
|
* [Examples](#examples)
|
||||||
* [Related Projects](#related-projects)
|
* [Related Projects](#related-projects)
|
||||||
@@ -33,9 +33,11 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
|
|||||||
|
|
||||||
## Supported operating systems
|
## Supported operating systems
|
||||||
|
|
||||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 / Photos 5.0. Requires python >= 3.6 though if you use `pip` to install, you must use python >= 3.8. See notes [below](#Installation-instructions)
|
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 & 10.15.4 / Photos 5.0.
|
||||||
|
|
||||||
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12
|
Requires python >= 3.6 though if you use `pip` to install, you must use python >= 3.8. See notes [below](#Installation-instructions). I highly recommend running this with python >= 3.8 as I'll eventually drop support for 3.6 and 3.7.
|
||||||
|
|
||||||
|
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12.
|
||||||
|
|
||||||
|
|
||||||
## Installation instructions
|
## Installation instructions
|
||||||
@@ -48,7 +50,7 @@ If you're using python 3.6 or 3.7, you'll need to do this first to get around an
|
|||||||
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
You can also install directly from [pypi](https://pypi.org/) but you must use python >= 3.8 to avoid an error with bpylist2. The package will work fine with python 3.6 or 3.7 but I know of no way to get `pip` to install the right dependencies.
|
You can also install directly from [pypi](https://pypi.org/) but you must use python >= 3.8 to avoid an error with bpylist2. The package currently works fine with python 3.6 or 3.7 but I know of no way to get `pip` to install the right dependencies.
|
||||||
|
|
||||||
pip install osxphotos
|
pip install osxphotos
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ Example: `osxphotos help export`
|
|||||||
|
|
||||||
```
|
```
|
||||||
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
||||||
|
Usage: __main__.py export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
||||||
|
|
||||||
Export photos from the Photos database. Export path DEST is required.
|
Export photos from the Photos database. Export path DEST is required.
|
||||||
Optionally, query the Photos database using 1 or more search options; if
|
Optionally, query the Photos database using 1 or more search options; if
|
||||||
@@ -217,6 +220,22 @@ Options:
|
|||||||
photos if the RAW photo does not have an
|
photos if the RAW photo does not have an
|
||||||
associated jpeg image (e.g. the RAW file was
|
associated jpeg image (e.g. the RAW file was
|
||||||
imported to Photos without a jpeg preview).
|
imported to Photos without a jpeg preview).
|
||||||
|
--person-keyword Use person in image as keyword/tag when
|
||||||
|
exporting metadata.
|
||||||
|
--album-keyword Use album name as keyword/tag when exporting
|
||||||
|
metadata.
|
||||||
|
--keyword-template TEMPLATE For use with --exiftool, --sidecar; specify
|
||||||
|
a template string to use as keyword in the
|
||||||
|
form '{name,DEFAULT}' This is the same
|
||||||
|
format as --directory. For example, if you
|
||||||
|
wanted to add the full path to the folder
|
||||||
|
and album photo is contained in as a keyword
|
||||||
|
when exporting you could specify --keyword-
|
||||||
|
template "{folder_album}" You may specify
|
||||||
|
more than one template, for example
|
||||||
|
--keyword-template "{folder_album}"
|
||||||
|
--keyword-template "{created.year}" See
|
||||||
|
Templating System below.
|
||||||
--current-name Use photo's current filename instead of
|
--current-name Use photo's current filename instead of
|
||||||
original filename for export. Note:
|
original filename for export. Note:
|
||||||
Starting with Photos 5, all photos are
|
Starting with Photos 5, all photos are
|
||||||
@@ -264,7 +283,7 @@ Options:
|
|||||||
|
|
||||||
**Templating System**
|
**Templating System**
|
||||||
|
|
||||||
With the --directory option, you may specify a template for the export
|
With the --directory option you may specify a template for the export
|
||||||
directory. This directory will be appended to the export path specified in
|
directory. This directory will be appended to the export path specified in
|
||||||
the export DEST argument to export. For example, if template is
|
the export DEST argument to export. For example, if template is
|
||||||
'{created.year}/{created.month}', and export desitnation DEST is
|
'{created.year}/{created.month}', and export desitnation DEST is
|
||||||
@@ -272,6 +291,11 @@ the export DEST argument to export. For example, if template is
|
|||||||
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
|
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
|
||||||
2020.
|
2020.
|
||||||
|
|
||||||
|
The templating system may also be used with the --keyword-template option to
|
||||||
|
set keywords on export (with --exiftool or --sidecar), for example, to set a
|
||||||
|
new keyword in format 'folder/subfolder/album' to preserve the folder/album
|
||||||
|
structure, you can use --keyword-template "{folder_album}"
|
||||||
|
|
||||||
In the template, valid template substitutions will be replaced by the
|
In the template, valid template substitutions will be replaced by the
|
||||||
corresponding value from the table below. Invalid substitutions will result
|
corresponding value from the table below. Invalid substitutions will result
|
||||||
in a an error and the script will abort.
|
in a an error and the script will abort.
|
||||||
@@ -296,7 +320,7 @@ I plan to eventually extend the templating system to the exported filename so
|
|||||||
you can specify the filename using a template.
|
you can specify the filename using a template.
|
||||||
|
|
||||||
Substitution Description
|
Substitution Description
|
||||||
{name} Filename of the photo
|
{name} Current filename of the photo
|
||||||
{original_name} Photo's original filename when imported to
|
{original_name} Photo's original filename when imported to
|
||||||
Photos
|
Photos
|
||||||
{title} Title of the photo
|
{title} Title of the photo
|
||||||
@@ -814,6 +838,7 @@ For example, in my library, Photos says I have 19,386 photos and 474 movies. Ho
|
|||||||
>>>
|
>>>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### PhotoInfo
|
### PhotoInfo
|
||||||
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
|
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
|
||||||
|
|
||||||
@@ -958,7 +983,8 @@ Returns True if photo is a panorama, otherwise False.
|
|||||||
#### `json()`
|
#### `json()`
|
||||||
Returns a JSON representation of all photo info
|
Returns a JSON representation of all photo info
|
||||||
|
|
||||||
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)`
|
#### `export()`
|
||||||
|
`export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||||
|
|
||||||
Export photo from the Photos library to another destination on disk.
|
Export photo from the Photos library to another destination on disk.
|
||||||
- dest: must be valid destination path as str (or exception raised).
|
- dest: must be valid destination path as str (or exception raised).
|
||||||
@@ -973,6 +999,8 @@ Export photo from the Photos library to another destination on disk.
|
|||||||
- timeout: (int, default=120) timeout in seconds used with use_photos_export
|
- timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||||
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
|
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
|
||||||
- no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
- no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
||||||
|
- use_albums_as_keywords: (boolean, default = False); if True, will use album names as keywords when exporting metadata with exiftool or sidecar
|
||||||
|
- use_persons_as_keywords: (boolean, default = False); if True, will use person names as keywords when exporting metadata with exiftool or sidecar
|
||||||
|
|
||||||
Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original imaage and the associated .mov file will be exported
|
Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original imaage and the associated .mov file will be exported
|
||||||
|
|
||||||
@@ -994,6 +1022,26 @@ If overwrite=False and increment=False, export will fail if destination file alr
|
|||||||
|
|
||||||
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses `/usr/bin/ditto` to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
|
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses `/usr/bin/ditto` to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
|
||||||
|
|
||||||
|
#### <a name="rendertemplate">`render_template()`</a>
|
||||||
|
|
||||||
|
`render_template(template, none_str = "_", path_sep = None)`
|
||||||
|
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
|
||||||
|
- `template`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
|
||||||
|
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
|
||||||
|
- `path_sep`: optional character to use as path separator, default is os.path.sep
|
||||||
|
|
||||||
|
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
|
||||||
|
|
||||||
|
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
|
||||||
|
|
||||||
|
If you want to include "{" or "}" in the output, use "{{" or "}}"
|
||||||
|
|
||||||
|
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
|
||||||
|
|
||||||
|
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
|
||||||
|
|
||||||
|
See [Template Substitutions](#template-substitutions) for additional details.
|
||||||
|
|
||||||
### AlbumInfo
|
### AlbumInfo
|
||||||
PhotosDB.album_info and PhotoInfo.album_info return a list of AlbumInfo objects. Each AlbumInfo object represents a single album in the Photos library.
|
PhotosDB.album_info and PhotoInfo.album_info return a list of AlbumInfo objects. Each AlbumInfo object represents a single album in the Photos library.
|
||||||
|
|
||||||
@@ -1139,26 +1187,9 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
|
|||||||
'96753'
|
'96753'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Template Functions
|
### Template Substitutions
|
||||||
|
|
||||||
There is a simple template system used by the command line client to specify the output directory using a template. The following are available in `osxphotos.template`.
|
|
||||||
|
|
||||||
#### `render_filepath_template(template, photo, none_str="_")`
|
|
||||||
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
|
|
||||||
- `template`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
|
|
||||||
- `photo`: a [PhotoInfo](#photoinfo) object
|
|
||||||
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
|
|
||||||
|
|
||||||
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
|
|
||||||
|
|
||||||
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
|
|
||||||
|
|
||||||
If you want to include "{" or "}" in the output, use "{{" or "}}"
|
|
||||||
|
|
||||||
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
|
|
||||||
|
|
||||||
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
|
|
||||||
|
|
||||||
|
The following substitutions are availabe for use with `PhotoInfo.render_template()`
|
||||||
|
|
||||||
| Substitution | Description |
|
| Substitution | Description |
|
||||||
|--------------|-------------|
|
|--------------|-------------|
|
||||||
@@ -1198,21 +1229,6 @@ Some substitutions, notably `album`, `keyword`, and `person` could return multip
|
|||||||
|{person}|Person(s) / face(s) in a photo|
|
|{person}|Person(s) / face(s) in a photo|
|
||||||
|
|
||||||
|
|
||||||
#### `DateTimeFormatter(dt)`
|
|
||||||
Class that provides easy access to formatted datetime values.
|
|
||||||
- `dt`: a datetime.datetime object
|
|
||||||
|
|
||||||
Returnes `DateTimeFormater` class.
|
|
||||||
|
|
||||||
Has the following properties:
|
|
||||||
- `date`: Date in ISO format without timezone, e.g. "2020-03-04"
|
|
||||||
- `year`: 4-digit year
|
|
||||||
- `yy`: 2-digit year
|
|
||||||
- `month`: month name in user's locale
|
|
||||||
- `mon`: month abbreviation in user's locale
|
|
||||||
- `mm`: 2-digit month
|
|
||||||
- `doy`: 3-digit day of year (e.g. Julian day)
|
|
||||||
|
|
||||||
### Utility Functions
|
### Utility Functions
|
||||||
|
|
||||||
The following functions are located in osxphotos.utils
|
The following functions are located in osxphotos.utils
|
||||||
@@ -1318,7 +1334,7 @@ Testing against "real world" Photos libraries would be especially helpful. If y
|
|||||||
|
|
||||||
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 400 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
|
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 400 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
|
||||||
|
|
||||||
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101) Note: Alpha version of fix for this bug is implemented in the current version of osxphotos.
|
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101) Note: Beta version of fix for this bug is implemented in the current version of osxphotos.
|
||||||
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75)
|
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75)
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ import osxphotos
|
|||||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
from .exiftool import get_exiftool_path
|
from .exiftool import get_exiftool_path
|
||||||
from .template import (
|
from .template import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||||
render_filepath_template,
|
|
||||||
TEMPLATE_SUBSTITUTIONS,
|
|
||||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
|
||||||
)
|
|
||||||
from .utils import _copy_file, create_path_by_date
|
from .utils import _copy_file, create_path_by_date
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +79,7 @@ class ExportCommand(click.Command):
|
|||||||
formatter.write_text("**Templating System**")
|
formatter.write_text("**Templating System**")
|
||||||
formatter.write("\n")
|
formatter.write("\n")
|
||||||
formatter.write_text(
|
formatter.write_text(
|
||||||
"With the --directory option, you may specify a template for the "
|
"With the --directory option you may specify a template for the "
|
||||||
+ "export directory. This directory will be appended to the export path specified "
|
+ "export directory. This directory will be appended to the export path specified "
|
||||||
+ "in the export DEST argument to export. For example, if template is "
|
+ "in the export DEST argument to export. For example, if template is "
|
||||||
+ "'{created.year}/{created.month}', and export desitnation DEST is "
|
+ "'{created.year}/{created.month}', and export desitnation DEST is "
|
||||||
@@ -92,6 +88,13 @@ class ExportCommand(click.Command):
|
|||||||
+ "if the photo was created in March 2020. "
|
+ "if the photo was created in March 2020. "
|
||||||
)
|
)
|
||||||
formatter.write("\n")
|
formatter.write("\n")
|
||||||
|
formatter.write_text(
|
||||||
|
"The templating system may also be used with the --keyword-template option "
|
||||||
|
+ "to set keywords on export (with --exiftool or --sidecar), "
|
||||||
|
+ "for example, to set a new keyword in format 'folder/subfolder/album' to "
|
||||||
|
+ 'preserve the folder/album structure, you can use --keyword-template "{folder_album}"'
|
||||||
|
)
|
||||||
|
formatter.write("\n")
|
||||||
formatter.write_text(
|
formatter.write_text(
|
||||||
"In the template, valid template substitutions will be replaced by "
|
"In the template, valid template substitutions will be replaced by "
|
||||||
+ "the corresponding value from the table below. Invalid substitutions will result in a "
|
+ "the corresponding value from the table below. Invalid substitutions will result in a "
|
||||||
@@ -893,6 +896,30 @@ def query(
|
|||||||
"Note: this does not skip RAW photos if the RAW photo does not have an associated jpeg image "
|
"Note: this does not skip RAW photos if the RAW photo does not have an associated jpeg image "
|
||||||
"(e.g. the RAW file was imported to Photos without a jpeg preview).",
|
"(e.g. the RAW file was imported to Photos without a jpeg preview).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--person-keyword",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use person in image as keyword/tag when exporting metadata.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--album-keyword",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use album name as keyword/tag when exporting metadata.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--keyword-template",
|
||||||
|
metavar="TEMPLATE",
|
||||||
|
multiple=True,
|
||||||
|
default=None,
|
||||||
|
help="For use with --exiftool, --sidecar; specify a template string to use as "
|
||||||
|
"keyword in the form '{name,DEFAULT}' "
|
||||||
|
"This is the same format as --directory. For example, if you wanted to add "
|
||||||
|
"the full path to the folder and album photo is contained in as a keyword when exporting "
|
||||||
|
'you could specify --keyword-template "{folder_album}" '
|
||||||
|
'You may specify more than one template, for example --keyword-template "{folder_album}" '
|
||||||
|
'--keyword-template "{created.year}" '
|
||||||
|
"See Templating System below.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--current-name",
|
"--current-name",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
@@ -983,6 +1010,9 @@ def export(
|
|||||||
skip_bursts,
|
skip_bursts,
|
||||||
skip_live,
|
skip_live,
|
||||||
skip_raw,
|
skip_raw,
|
||||||
|
person_keyword,
|
||||||
|
album_keyword,
|
||||||
|
keyword_template,
|
||||||
current_name,
|
current_name,
|
||||||
sidecar,
|
sidecar,
|
||||||
only_photos,
|
only_photos,
|
||||||
@@ -1174,6 +1204,9 @@ def export(
|
|||||||
directory,
|
directory,
|
||||||
no_extended_attributes,
|
no_extended_attributes,
|
||||||
export_raw,
|
export_raw,
|
||||||
|
album_keyword,
|
||||||
|
person_keyword,
|
||||||
|
keyword_template,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for p in photos:
|
for p in photos:
|
||||||
@@ -1192,6 +1225,9 @@ def export(
|
|||||||
directory,
|
directory,
|
||||||
no_extended_attributes,
|
no_extended_attributes,
|
||||||
export_raw,
|
export_raw,
|
||||||
|
album_keyword,
|
||||||
|
person_keyword,
|
||||||
|
keyword_template,
|
||||||
)
|
)
|
||||||
if export_paths:
|
if export_paths:
|
||||||
click.echo(f"Exported {p.filename} to {export_paths}")
|
click.echo(f"Exported {p.filename} to {export_paths}")
|
||||||
@@ -1578,6 +1614,9 @@ def export_photo(
|
|||||||
directory,
|
directory,
|
||||||
no_extended_attributes,
|
no_extended_attributes,
|
||||||
export_raw,
|
export_raw,
|
||||||
|
album_keyword,
|
||||||
|
person_keyword,
|
||||||
|
keyword_template,
|
||||||
):
|
):
|
||||||
""" Helper function for export that does the actual export
|
""" Helper function for export that does the actual export
|
||||||
photo: PhotoInfo object
|
photo: PhotoInfo object
|
||||||
@@ -1594,6 +1633,9 @@ def export_photo(
|
|||||||
directory: template used to determine output directory
|
directory: template used to determine output directory
|
||||||
no_extended_attributes: boolean; if True, exports photo without preserving extended attributes
|
no_extended_attributes: boolean; if True, exports photo without preserving extended attributes
|
||||||
export_raw: boolean; if True exports RAW image associate with the photo
|
export_raw: boolean; if True exports RAW image associate with the photo
|
||||||
|
album_keyword: boolean; if True, exports album names as keywords in metadata
|
||||||
|
person_keyword: boolean; if True, exports person names as keywords in metadata
|
||||||
|
keyword_template: list of strings; if provided use rendered template strings as keywords
|
||||||
returns list of path(s) of exported photo or None if photo was missing
|
returns list of path(s) of exported photo or None if photo was missing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1634,7 +1676,7 @@ def export_photo(
|
|||||||
dest_paths = [dest_path]
|
dest_paths = [dest_path]
|
||||||
elif directory:
|
elif directory:
|
||||||
# got a directory template, render it and check results are valid
|
# got a directory template, render it and check results are valid
|
||||||
dirnames, unmatched = render_filepath_template(directory, photo)
|
dirnames, unmatched = photo.render_template(directory)
|
||||||
if unmatched:
|
if unmatched:
|
||||||
raise click.BadOptionUsage(
|
raise click.BadOptionUsage(
|
||||||
"directory",
|
"directory",
|
||||||
@@ -1677,6 +1719,9 @@ def export_photo(
|
|||||||
use_photos_export=use_photos_export,
|
use_photos_export=use_photos_export,
|
||||||
exiftool=exiftool,
|
exiftool=exiftool,
|
||||||
no_xattr=no_extended_attributes,
|
no_xattr=no_extended_attributes,
|
||||||
|
use_albums_as_keywords=album_keyword,
|
||||||
|
use_persons_as_keywords=person_keyword,
|
||||||
|
keyword_template=keyword_template,
|
||||||
)[0]
|
)[0]
|
||||||
photo_paths.append(photo_path)
|
photo_paths.append(photo_path)
|
||||||
|
|
||||||
@@ -1712,6 +1757,9 @@ def export_photo(
|
|||||||
use_photos_export=use_photos_export,
|
use_photos_export=use_photos_export,
|
||||||
exiftool=exiftool,
|
exiftool=exiftool,
|
||||||
no_xattr=no_extended_attributes,
|
no_xattr=no_extended_attributes,
|
||||||
|
use_albums_as_keywords=album_keyword,
|
||||||
|
use_persons_as_keywords=person_keyword,
|
||||||
|
keyword_template=keyword_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
return photo_paths
|
return photo_paths
|
||||||
|
|||||||
@@ -51,3 +51,12 @@ _PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
|
|||||||
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
|
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
|
||||||
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
|
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
|
||||||
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
|
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
|
||||||
|
|
||||||
|
# EXIF related constants
|
||||||
|
# max keyword length for IPTC:Keyword, reference
|
||||||
|
# https://www.iptc.org/std/photometadata/documentation/userguide/
|
||||||
|
_MAX_IPTC_KEYWORD_LEN = 64
|
||||||
|
|
||||||
|
# Sentinel value for detecting if a template in keyword_template doesn't match
|
||||||
|
# If anyone has a keyword matching this, then too bad...
|
||||||
|
_OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.28.5"
|
__version__ = "0.28.13"
|
||||||
|
|||||||
52
osxphotos/datetime_formatter.py
Normal file
52
osxphotos/datetime_formatter.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
""" Simple formatting of datetime.datetime objects """
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DateTimeFormatter:
|
||||||
|
""" provides property access to formatted datetime.datetime strftime values """
|
||||||
|
|
||||||
|
def __init__(self, dt: datetime.datetime):
|
||||||
|
self.dt = dt
|
||||||
|
|
||||||
|
@property
|
||||||
|
def date(self):
|
||||||
|
""" ISO date in form 2020-03-22 """
|
||||||
|
date = self.dt.date().isoformat()
|
||||||
|
return date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def year(self):
|
||||||
|
""" 4 digit year """
|
||||||
|
year = f"{self.dt.year}"
|
||||||
|
return year
|
||||||
|
|
||||||
|
@property
|
||||||
|
def yy(self):
|
||||||
|
""" 2 digit year """
|
||||||
|
yy = f"{self.dt.strftime('%y')}"
|
||||||
|
return yy
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mm(self):
|
||||||
|
""" 2 digit month """
|
||||||
|
mm = f"{self.dt.strftime('%m')}"
|
||||||
|
return mm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def month(self):
|
||||||
|
""" Month as locale's full name """
|
||||||
|
month = f"{self.dt.strftime('%B')}"
|
||||||
|
return month
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mon(self):
|
||||||
|
""" Month as locale's abbreviated name """
|
||||||
|
mon = f"{self.dt.strftime('%b')}"
|
||||||
|
return mon
|
||||||
|
|
||||||
|
@property
|
||||||
|
def doy(self):
|
||||||
|
""" Julian day of year starting from 001 """
|
||||||
|
doy = f"{self.dt.strftime('%j')}"
|
||||||
|
return doy
|
||||||
@@ -7,6 +7,7 @@ PhotosDB.photos() returns a list of PhotoInfo objects
|
|||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
@@ -19,16 +20,25 @@ import yaml
|
|||||||
from mako.template import Template
|
from mako.template import Template
|
||||||
|
|
||||||
from ._constants import (
|
from ._constants import (
|
||||||
|
_MAX_IPTC_KEYWORD_LEN,
|
||||||
_MOVIE_TYPE,
|
_MOVIE_TYPE,
|
||||||
|
_OSXPHOTOS_NONE_SENTINEL,
|
||||||
_PHOTO_TYPE,
|
_PHOTO_TYPE,
|
||||||
_PHOTOS_4_VERSION,
|
_PHOTOS_4_VERSION,
|
||||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||||
_TEMPLATE_DIR,
|
_TEMPLATE_DIR,
|
||||||
|
_UNKNOWN_PERSON,
|
||||||
_XMP_TEMPLATE_NAME,
|
_XMP_TEMPLATE_NAME,
|
||||||
)
|
)
|
||||||
|
from .albuminfo import AlbumInfo
|
||||||
|
from .datetime_formatter import DateTimeFormatter
|
||||||
from .exiftool import ExifTool
|
from .exiftool import ExifTool
|
||||||
from .placeinfo import PlaceInfo4, PlaceInfo5
|
from .placeinfo import PlaceInfo4, PlaceInfo5
|
||||||
from .albuminfo import AlbumInfo
|
from .template import (
|
||||||
|
MULTI_VALUE_SUBSTITUTIONS,
|
||||||
|
TEMPLATE_SUBSTITUTIONS,
|
||||||
|
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||||
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
_copy_file,
|
_copy_file,
|
||||||
_export_photo_uuid_applescript,
|
_export_photo_uuid_applescript,
|
||||||
@@ -304,9 +314,7 @@ class PhotoInfo:
|
|||||||
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
|
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
|
||||||
# that are missing do not always trigger is_missing = True as happens
|
# that are missing do not always trigger is_missing = True as happens
|
||||||
# in earlier version so it's possible for this check to fail, if so, return None
|
# in earlier version so it's possible for this check to fail, if so, return None
|
||||||
logging.debug(
|
logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
|
||||||
f"Error getting path to RAW file: {filepath}/{glob_str}"
|
|
||||||
)
|
|
||||||
photopath = None
|
photopath = None
|
||||||
else:
|
else:
|
||||||
photopath = os.path.join(filepath, raw_file[0])
|
photopath = os.path.join(filepath, raw_file[0])
|
||||||
@@ -635,6 +643,9 @@ class PhotoInfo:
|
|||||||
timeout=120,
|
timeout=120,
|
||||||
exiftool=False,
|
exiftool=False,
|
||||||
no_xattr=False,
|
no_xattr=False,
|
||||||
|
use_albums_as_keywords=False,
|
||||||
|
use_persons_as_keywords=False,
|
||||||
|
keyword_template=None,
|
||||||
):
|
):
|
||||||
""" export photo
|
""" export photo
|
||||||
dest: must be valid destination path (or exception raised)
|
dest: must be valid destination path (or exception raised)
|
||||||
@@ -660,7 +671,13 @@ class PhotoInfo:
|
|||||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||||
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
||||||
returns list of full paths to the exported files """
|
returns list of full paths to the exported files
|
||||||
|
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
|
||||||
|
when exporting metadata with exiftool or sidecar
|
||||||
|
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
|
||||||
|
when exporting metadata with exiftool or sidecar
|
||||||
|
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
|
||||||
|
"""
|
||||||
|
|
||||||
# list of all files exported during this call to export
|
# list of all files exported during this call to export
|
||||||
exported_files = []
|
exported_files = []
|
||||||
@@ -864,7 +881,11 @@ class PhotoInfo:
|
|||||||
if sidecar_json:
|
if sidecar_json:
|
||||||
logging.debug("writing exiftool_json_sidecar")
|
logging.debug("writing exiftool_json_sidecar")
|
||||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json")
|
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json")
|
||||||
sidecar_str = self._exiftool_json_sidecar()
|
sidecar_str = self._exiftool_json_sidecar(
|
||||||
|
use_albums_as_keywords=use_albums_as_keywords,
|
||||||
|
use_persons_as_keywords=use_persons_as_keywords,
|
||||||
|
keyword_template=keyword_template,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -874,7 +895,11 @@ class PhotoInfo:
|
|||||||
if sidecar_xmp:
|
if sidecar_xmp:
|
||||||
logging.debug("writing xmp_sidecar")
|
logging.debug("writing xmp_sidecar")
|
||||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp")
|
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp")
|
||||||
sidecar_str = self._xmp_sidecar()
|
sidecar_str = self._xmp_sidecar(
|
||||||
|
use_albums_as_keywords=use_albums_as_keywords,
|
||||||
|
use_persons_as_keywords=use_persons_as_keywords,
|
||||||
|
keyword_template=keyword_template,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -884,17 +909,367 @@ class PhotoInfo:
|
|||||||
# if exiftool, write the metadata
|
# if exiftool, write the metadata
|
||||||
if exiftool and exported_files:
|
if exiftool and exported_files:
|
||||||
for exported_file in exported_files:
|
for exported_file in exported_files:
|
||||||
self._write_exif_data(exported_file)
|
self._write_exif_data(
|
||||||
|
exported_file,
|
||||||
|
use_albums_as_keywords=use_albums_as_keywords,
|
||||||
|
use_persons_as_keywords=use_persons_as_keywords,
|
||||||
|
keyword_template=keyword_template,
|
||||||
|
)
|
||||||
|
|
||||||
return exported_files
|
return exported_files
|
||||||
|
|
||||||
def _write_exif_data(self, filepath):
|
def render_template(self, template, none_str="_", path_sep=None):
|
||||||
|
""" render a filename or directory template
|
||||||
|
template: str template
|
||||||
|
none_str: str to use default for None values, default is '_'
|
||||||
|
path_sep: optional character to use as path separator, default is os.path.sep """
|
||||||
|
|
||||||
|
if path_sep is None:
|
||||||
|
path_sep = os.path.sep
|
||||||
|
elif path_sep is not None and len(path_sep) != 1:
|
||||||
|
raise ValueError(f"path_sep must be single character: {path_sep}")
|
||||||
|
|
||||||
|
# the rendering happens in two phases:
|
||||||
|
# phase 1: handle all the single-value template substitutions
|
||||||
|
# results in a single string with all the template fields replaced
|
||||||
|
# phase 2: loop through all the multi-value template substitutions
|
||||||
|
# could result in multiple strings
|
||||||
|
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
|
||||||
|
# there would be 6 possible renderings (2 albums x 3 persons)
|
||||||
|
|
||||||
|
# regex to find {template_field,optional_default} in strings
|
||||||
|
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
||||||
|
# pylint: disable=anomalous-backslash-in-string
|
||||||
|
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}"
|
||||||
|
|
||||||
|
if type(template) is not str:
|
||||||
|
raise TypeError(f"template must be type str, not {type(template)}")
|
||||||
|
|
||||||
|
def make_subst_function(self, none_str, get_func=self.get_template_value):
|
||||||
|
""" returns: substitution function for use in re.sub
|
||||||
|
photo: a PhotoInfo object
|
||||||
|
none_str: value to use if substitution lookup is None and no default provided
|
||||||
|
get_func: function that gets the substitution value for a given template field
|
||||||
|
default is get_template_value which handles the single-value fields """
|
||||||
|
|
||||||
|
# closure to capture photo, none_str in subst
|
||||||
|
def subst(matchobj):
|
||||||
|
groups = len(matchobj.groups())
|
||||||
|
if groups == 4:
|
||||||
|
try:
|
||||||
|
val = get_func(matchobj.group(1))
|
||||||
|
except KeyError:
|
||||||
|
return matchobj.group(0)
|
||||||
|
|
||||||
|
if val is None:
|
||||||
|
return (
|
||||||
|
matchobj.group(3)
|
||||||
|
if matchobj.group(3) is not None
|
||||||
|
else none_str
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return val
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected number of groups: expected 4, got {groups}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return subst
|
||||||
|
|
||||||
|
subst_func = make_subst_function(self, none_str)
|
||||||
|
|
||||||
|
# do the replacements
|
||||||
|
rendered = re.sub(regex, subst_func, template)
|
||||||
|
|
||||||
|
# do multi-valued placements
|
||||||
|
# start with the single string from phase 1 above then loop through all
|
||||||
|
# multi-valued fields and all values for each of those fields
|
||||||
|
# rendered_strings will be updated as each field is processed
|
||||||
|
# for example: if two albums, two keywords, and one person and template is:
|
||||||
|
# "{created.year}/{album}/{keyword}/{person}"
|
||||||
|
# rendered strings would do the following:
|
||||||
|
# start (created.year filled in phase 1)
|
||||||
|
# ['2011/{album}/{keyword}/{person}']
|
||||||
|
# after processing albums:
|
||||||
|
# ['2011/Album1/{keyword}/{person}',
|
||||||
|
# '2011/Album2/{keyword}/{person}',]
|
||||||
|
# after processing keywords:
|
||||||
|
# ['2011/Album1/keyword1/{person}',
|
||||||
|
# '2011/Album1/keyword2/{person}',
|
||||||
|
# '2011/Album2/keyword1/{person}',
|
||||||
|
# '2011/Album2/keyword2/{person}',]
|
||||||
|
# after processing person:
|
||||||
|
# ['2011/Album1/keyword1/person1',
|
||||||
|
# '2011/Album1/keyword2/person1',
|
||||||
|
# '2011/Album2/keyword1/person1',
|
||||||
|
# '2011/Album2/keyword2/person1',]
|
||||||
|
|
||||||
|
rendered_strings = set([rendered])
|
||||||
|
for field in MULTI_VALUE_SUBSTITUTIONS:
|
||||||
|
if field == "album":
|
||||||
|
values = self.albums
|
||||||
|
elif field == "keyword":
|
||||||
|
values = self.keywords
|
||||||
|
elif field == "person":
|
||||||
|
values = self.persons
|
||||||
|
# remove any _UNKNOWN_PERSON values
|
||||||
|
values = [val for val in values if val != _UNKNOWN_PERSON]
|
||||||
|
elif field == "folder_album":
|
||||||
|
values = []
|
||||||
|
# photos must be in an album to be in a folder
|
||||||
|
for album in self.album_info:
|
||||||
|
if album.folder_names:
|
||||||
|
# album in folder
|
||||||
|
folder = path_sep.join(album.folder_names)
|
||||||
|
folder += path_sep + album.title
|
||||||
|
values.append(folder)
|
||||||
|
else:
|
||||||
|
# album not in folder
|
||||||
|
values.append(album.title)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unhandleded template value: {field}")
|
||||||
|
|
||||||
|
# If no values, insert None so code below will substite none_str for None
|
||||||
|
values = values or [None]
|
||||||
|
|
||||||
|
# Build a regex that matches only the field being processed
|
||||||
|
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
|
||||||
|
regex_multi = re.compile(re_str)
|
||||||
|
|
||||||
|
# holds each of the new rendered_strings, set() to avoid duplicates
|
||||||
|
new_strings = set()
|
||||||
|
|
||||||
|
for str_template in rendered_strings:
|
||||||
|
for val in values:
|
||||||
|
|
||||||
|
def get_template_value_multi(lookup_value):
|
||||||
|
""" Closure passed to make_subst_function get_func
|
||||||
|
Capture val and field in the closure
|
||||||
|
Allows make_subst_function to be re-used w/o modification """
|
||||||
|
if lookup_value == field:
|
||||||
|
return val
|
||||||
|
else:
|
||||||
|
raise KeyError(f"Unexpected value: {lookup_value}")
|
||||||
|
|
||||||
|
subst = make_subst_function(
|
||||||
|
self, none_str, get_func=get_template_value_multi
|
||||||
|
)
|
||||||
|
new_string = regex_multi.sub(subst, str_template)
|
||||||
|
new_strings.add(new_string)
|
||||||
|
|
||||||
|
# update rendered_strings for the next field to process
|
||||||
|
rendered_strings = new_strings
|
||||||
|
|
||||||
|
# find any {fields} that weren't replaced
|
||||||
|
unmatched = []
|
||||||
|
for rendered_str in rendered_strings:
|
||||||
|
unmatched.extend(
|
||||||
|
[
|
||||||
|
no_match[0]
|
||||||
|
for no_match in re.findall(regex, rendered_str)
|
||||||
|
if no_match[0] not in unmatched
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# fix any escaped curly braces
|
||||||
|
rendered_strings = [
|
||||||
|
rendered_str.replace("{{", "{").replace("}}", "}")
|
||||||
|
for rendered_str in rendered_strings
|
||||||
|
]
|
||||||
|
|
||||||
|
return rendered_strings, unmatched
|
||||||
|
|
||||||
|
def get_template_value(self, lookup):
|
||||||
|
""" lookup template value (single-value template substitutions) for use in make_subst_function
|
||||||
|
lookup: value to find a match for
|
||||||
|
returns: either the matching template value (which may be None)
|
||||||
|
raises: KeyError if no rule exists for lookup """
|
||||||
|
|
||||||
|
# must be a valid keyword
|
||||||
|
if lookup == "name":
|
||||||
|
return pathlib.Path(self.filename).stem
|
||||||
|
|
||||||
|
if lookup == "original_name":
|
||||||
|
return pathlib.Path(self.original_filename).stem
|
||||||
|
|
||||||
|
if lookup == "title":
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
if lookup == "descr":
|
||||||
|
return self.description
|
||||||
|
|
||||||
|
if lookup == "created.date":
|
||||||
|
return DateTimeFormatter(self.date).date
|
||||||
|
|
||||||
|
if lookup == "created.year":
|
||||||
|
return DateTimeFormatter(self.date).year
|
||||||
|
|
||||||
|
if lookup == "created.yy":
|
||||||
|
return DateTimeFormatter(self.date).yy
|
||||||
|
|
||||||
|
if lookup == "created.mm":
|
||||||
|
return DateTimeFormatter(self.date).mm
|
||||||
|
|
||||||
|
if lookup == "created.month":
|
||||||
|
return DateTimeFormatter(self.date).month
|
||||||
|
|
||||||
|
if lookup == "created.mon":
|
||||||
|
return DateTimeFormatter(self.date).mon
|
||||||
|
|
||||||
|
if lookup == "created.doy":
|
||||||
|
return DateTimeFormatter(self.date).doy
|
||||||
|
|
||||||
|
if lookup == "modified.date":
|
||||||
|
return (
|
||||||
|
DateTimeFormatter(self.date_modified).date
|
||||||
|
if self.date_modified
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "modified.year":
|
||||||
|
return (
|
||||||
|
DateTimeFormatter(self.date_modified).year
|
||||||
|
if self.date_modified
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "modified.yy":
|
||||||
|
return (
|
||||||
|
DateTimeFormatter(self.date_modified).yy if self.date_modified else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "modified.mm":
|
||||||
|
return (
|
||||||
|
DateTimeFormatter(self.date_modified).mm if self.date_modified else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "modified.month":
|
||||||
|
return (
|
||||||
|
DateTimeFormatter(self.date_modified).month
|
||||||
|
if self.date_modified
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "modified.mon":
|
||||||
|
return (
|
||||||
|
DateTimeFormatter(self.date_modified).mon
|
||||||
|
if self.date_modified
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "modified.doy":
|
||||||
|
return (
|
||||||
|
DateTimeFormatter(self.date_modified).doy
|
||||||
|
if self.date_modified
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.name":
|
||||||
|
return self.place.name if self.place else None
|
||||||
|
|
||||||
|
if lookup == "place.country_code":
|
||||||
|
return self.place.country_code if self.place else None
|
||||||
|
|
||||||
|
if lookup == "place.name.country":
|
||||||
|
return (
|
||||||
|
self.place.names.country[0]
|
||||||
|
if self.place and self.place.names.country
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.name.state_province":
|
||||||
|
return (
|
||||||
|
self.place.names.state_province[0]
|
||||||
|
if self.place and self.place.names.state_province
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.name.city":
|
||||||
|
return (
|
||||||
|
self.place.names.city[0]
|
||||||
|
if self.place and self.place.names.city
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.name.area_of_interest":
|
||||||
|
return (
|
||||||
|
self.place.names.area_of_interest[0]
|
||||||
|
if self.place and self.place.names.area_of_interest
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.address":
|
||||||
|
return (
|
||||||
|
self.place.address_str
|
||||||
|
if self.place and self.place.address_str
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.address.street":
|
||||||
|
return (
|
||||||
|
self.place.address.street
|
||||||
|
if self.place and self.place.address.street
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.address.city":
|
||||||
|
return (
|
||||||
|
self.place.address.city
|
||||||
|
if self.place and self.place.address.city
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.address.state_province":
|
||||||
|
return (
|
||||||
|
self.place.address.state_province
|
||||||
|
if self.place and self.place.address.state_province
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.address.postal_code":
|
||||||
|
return (
|
||||||
|
self.place.address.postal_code
|
||||||
|
if self.place and self.place.address.postal_code
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.address.country":
|
||||||
|
return (
|
||||||
|
self.place.address.country
|
||||||
|
if self.place and self.place.address.country
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if lookup == "place.address.country_code":
|
||||||
|
return (
|
||||||
|
self.place.address.iso_country_code
|
||||||
|
if self.place and self.place.address.iso_country_code
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# if here, didn't get a match
|
||||||
|
raise KeyError(f"No rule for processing {lookup}")
|
||||||
|
|
||||||
|
def _write_exif_data(
|
||||||
|
self,
|
||||||
|
filepath,
|
||||||
|
use_albums_as_keywords=False,
|
||||||
|
use_persons_as_keywords=False,
|
||||||
|
keyword_template=None,
|
||||||
|
):
|
||||||
""" write exif data to image file at filepath
|
""" write exif data to image file at filepath
|
||||||
filepath: full path to the image file """
|
filepath: full path to the image file """
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
raise FileNotFoundError(f"Could not find file {filepath}")
|
raise FileNotFoundError(f"Could not find file {filepath}")
|
||||||
exiftool = ExifTool(filepath)
|
exiftool = ExifTool(filepath)
|
||||||
exif_info = json.loads(self._exiftool_json_sidecar())[0]
|
exif_info = json.loads(
|
||||||
|
self._exiftool_json_sidecar(
|
||||||
|
use_albums_as_keywords=use_albums_as_keywords,
|
||||||
|
use_persons_as_keywords=use_persons_as_keywords,
|
||||||
|
keyword_template=keyword_template,
|
||||||
|
)
|
||||||
|
)[0]
|
||||||
for exiftag, val in exif_info.items():
|
for exiftag, val in exif_info.items():
|
||||||
if type(val) == list:
|
if type(val) == list:
|
||||||
# more than one, set first value the add additional values
|
# more than one, set first value the add additional values
|
||||||
@@ -905,16 +1280,24 @@ class PhotoInfo:
|
|||||||
else:
|
else:
|
||||||
exiftool.setvalue(exiftag, val)
|
exiftool.setvalue(exiftag, val)
|
||||||
|
|
||||||
def _exiftool_json_sidecar(self):
|
def _exiftool_json_sidecar(
|
||||||
|
self,
|
||||||
|
use_albums_as_keywords=False,
|
||||||
|
use_persons_as_keywords=False,
|
||||||
|
keyword_template=None,
|
||||||
|
):
|
||||||
""" return json string of EXIF details in exiftool sidecar format
|
""" return json string of EXIF details in exiftool sidecar format
|
||||||
Does not include all the EXIF fields as those are likely already in the image
|
Does not include all the EXIF fields as those are likely already in the image
|
||||||
|
use_albums_as_keywords: treat album names as keywords
|
||||||
|
use_persons_as_keywords: treat person names as keywords
|
||||||
|
keyword_template: (list of strings); list of template strings to render as keywords
|
||||||
Exports the following:
|
Exports the following:
|
||||||
FileName
|
FileName
|
||||||
ImageDescription
|
ImageDescription
|
||||||
Description
|
Description
|
||||||
Title
|
Title
|
||||||
TagsList
|
TagsList
|
||||||
Keywords
|
Keywords (may include album name, person name, or template)
|
||||||
Subject
|
Subject
|
||||||
PersonInImage
|
PersonInImage
|
||||||
GPSLatitude, GPSLongitude
|
GPSLatitude, GPSLongitude
|
||||||
@@ -934,18 +1317,64 @@ class PhotoInfo:
|
|||||||
if self.title:
|
if self.title:
|
||||||
exif["XMP:Title"] = self.title
|
exif["XMP:Title"] = self.title
|
||||||
|
|
||||||
|
keyword_list = []
|
||||||
if self.keywords:
|
if self.keywords:
|
||||||
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords)
|
keyword_list.extend(self.keywords)
|
||||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
|
||||||
exif["XMP:Subject"] = list(self.keywords)
|
|
||||||
|
|
||||||
|
person_list = []
|
||||||
if self.persons:
|
if self.persons:
|
||||||
exif["XMP:PersonInImage"] = self.persons
|
# filter out _UNKNOWN_PERSON
|
||||||
|
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
|
||||||
|
|
||||||
|
if use_persons_as_keywords and person_list:
|
||||||
|
keyword_list.extend(person_list)
|
||||||
|
|
||||||
|
if use_albums_as_keywords and self.albums:
|
||||||
|
keyword_list.extend(self.albums)
|
||||||
|
|
||||||
|
if keyword_template:
|
||||||
|
rendered_keywords = []
|
||||||
|
for template_str in keyword_template:
|
||||||
|
rendered, unmatched = self.render_template(
|
||||||
|
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||||
|
)
|
||||||
|
if unmatched:
|
||||||
|
logging.warning(
|
||||||
|
f"Unmatched template substitution for template: {template_str} {unmatched}"
|
||||||
|
)
|
||||||
|
rendered_keywords.extend(rendered)
|
||||||
|
|
||||||
|
# filter out any template values that didn't match by looking for sentinel
|
||||||
|
rendered_keywords = [
|
||||||
|
keyword
|
||||||
|
for keyword in rendered_keywords
|
||||||
|
if _OSXPHOTOS_NONE_SENTINEL not in keyword
|
||||||
|
]
|
||||||
|
|
||||||
|
# check to see if any keywords too long
|
||||||
|
long_keywords = [
|
||||||
|
long_str
|
||||||
|
for long_str in rendered_keywords
|
||||||
|
if len(long_str) > _MAX_IPTC_KEYWORD_LEN
|
||||||
|
]
|
||||||
|
if long_keywords:
|
||||||
|
logging.warning(
|
||||||
|
f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.debug(f"rendered_keywords: {rendered_keywords}")
|
||||||
|
keyword_list.extend(rendered_keywords)
|
||||||
|
|
||||||
|
if keyword_list:
|
||||||
|
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list
|
||||||
|
|
||||||
|
if person_list:
|
||||||
|
exif["XMP:PersonInImage"] = person_list
|
||||||
|
|
||||||
|
if self.keywords or person_list:
|
||||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||||
if "XMP:Subject" in exif:
|
# only use Photos' keywords for subject
|
||||||
exif["XMP:Subject"].extend(self.persons)
|
exif["XMP:Subject"] = list(self.keywords) + person_list
|
||||||
else:
|
|
||||||
exif["XMP:Subject"] = self.persons
|
|
||||||
|
|
||||||
# if self.favorite():
|
# if self.favorite():
|
||||||
# exif["Rating"] = 5
|
# exif["Rating"] = 5
|
||||||
@@ -979,14 +1408,86 @@ class PhotoInfo:
|
|||||||
json_str = json.dumps([exif])
|
json_str = json.dumps([exif])
|
||||||
return json_str
|
return json_str
|
||||||
|
|
||||||
def _xmp_sidecar(self):
|
def _xmp_sidecar(
|
||||||
""" returns string for XMP sidecar """
|
self,
|
||||||
|
use_albums_as_keywords=False,
|
||||||
|
use_persons_as_keywords=False,
|
||||||
|
keyword_template=None,
|
||||||
|
):
|
||||||
|
""" returns string for XMP sidecar
|
||||||
|
use_albums_as_keywords: treat album names as keywords
|
||||||
|
use_persons_as_keywords: treat person names as keywords
|
||||||
|
keyword_template: (list of strings); list of template strings to render as keywords """
|
||||||
|
|
||||||
# TODO: add additional fields to XMP file?
|
# TODO: add additional fields to XMP file?
|
||||||
|
|
||||||
xmp_template = Template(
|
xmp_template = Template(
|
||||||
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
|
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
|
||||||
)
|
)
|
||||||
xmp_str = xmp_template.render(photo=self)
|
|
||||||
|
keyword_list = []
|
||||||
|
if self.keywords:
|
||||||
|
keyword_list.extend(self.keywords)
|
||||||
|
|
||||||
|
# TODO: keyword handling in this and _exiftool_json_sidecar is
|
||||||
|
# good candidate for pulling out in a function
|
||||||
|
|
||||||
|
person_list = []
|
||||||
|
if self.persons:
|
||||||
|
# filter out _UNKNOWN_PERSON
|
||||||
|
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
|
||||||
|
|
||||||
|
if use_persons_as_keywords and person_list:
|
||||||
|
keyword_list.extend(person_list)
|
||||||
|
|
||||||
|
if use_albums_as_keywords and self.albums:
|
||||||
|
keyword_list.extend(self.albums)
|
||||||
|
|
||||||
|
if keyword_template:
|
||||||
|
rendered_keywords = []
|
||||||
|
for template_str in keyword_template:
|
||||||
|
rendered, unmatched = self.render_template(
|
||||||
|
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||||
|
)
|
||||||
|
if unmatched:
|
||||||
|
logging.warning(
|
||||||
|
f"Unmatched template substitution for template: {template_str} {unmatched}"
|
||||||
|
)
|
||||||
|
rendered_keywords.extend(rendered)
|
||||||
|
|
||||||
|
# filter out any template values that didn't match by looking for sentinel
|
||||||
|
rendered_keywords = [
|
||||||
|
keyword
|
||||||
|
for keyword in rendered_keywords
|
||||||
|
if _OSXPHOTOS_NONE_SENTINEL not in keyword
|
||||||
|
]
|
||||||
|
|
||||||
|
# check to see if any keywords too long
|
||||||
|
long_keywords = [
|
||||||
|
long_str
|
||||||
|
for long_str in rendered_keywords
|
||||||
|
if len(long_str) > _MAX_IPTC_KEYWORD_LEN
|
||||||
|
]
|
||||||
|
if long_keywords:
|
||||||
|
logging.warning(
|
||||||
|
f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.debug(f"rendered_keywords: {rendered_keywords}")
|
||||||
|
keyword_list.extend(rendered_keywords)
|
||||||
|
|
||||||
|
subject_list = []
|
||||||
|
if self.keywords or person_list:
|
||||||
|
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||||
|
subject_list = list(self.keywords) + person_list
|
||||||
|
|
||||||
|
xmp_str = xmp_template.render(
|
||||||
|
photo=self,
|
||||||
|
keywords=keyword_list,
|
||||||
|
persons=person_list,
|
||||||
|
subjects=subject_list,
|
||||||
|
)
|
||||||
|
|
||||||
# remove extra lines that mako inserts from template
|
# remove extra lines that mako inserts from template
|
||||||
xmp_str = "\n".join(
|
xmp_str = "\n".join(
|
||||||
[line for line in xmp_str.split("\n") if line.strip() != ""]
|
[line for line in xmp_str.split("\n") if line.strip() != ""]
|
||||||
|
|||||||
@@ -19,17 +19,18 @@ from ._constants import (
|
|||||||
_MOVIE_TYPE,
|
_MOVIE_TYPE,
|
||||||
_PHOTO_TYPE,
|
_PHOTO_TYPE,
|
||||||
_PHOTOS_3_VERSION,
|
_PHOTOS_3_VERSION,
|
||||||
|
_PHOTOS_4_ALBUM_KIND,
|
||||||
|
_PHOTOS_4_ROOT_FOLDER,
|
||||||
|
_PHOTOS_4_TOP_LEVEL_ALBUM,
|
||||||
_PHOTOS_4_VERSION,
|
_PHOTOS_4_VERSION,
|
||||||
|
_PHOTOS_5_ALBUM_KIND,
|
||||||
|
_PHOTOS_5_FOLDER_KIND,
|
||||||
|
_PHOTOS_5_ROOT_FOLDER_KIND,
|
||||||
|
_PHOTOS_5_SHARED_ALBUM_KIND,
|
||||||
_PHOTOS_5_VERSION,
|
_PHOTOS_5_VERSION,
|
||||||
_TESTED_DB_VERSIONS,
|
_TESTED_DB_VERSIONS,
|
||||||
_TESTED_OS_VERSIONS,
|
_TESTED_OS_VERSIONS,
|
||||||
_UNKNOWN_PERSON,
|
_UNKNOWN_PERSON,
|
||||||
_PHOTOS_4_ALBUM_KIND,
|
|
||||||
_PHOTOS_4_TOP_LEVEL_ALBUM,
|
|
||||||
_PHOTOS_5_ROOT_FOLDER_KIND,
|
|
||||||
_PHOTOS_5_FOLDER_KIND,
|
|
||||||
_PHOTOS_5_ALBUM_KIND,
|
|
||||||
_PHOTOS_5_SHARED_ALBUM_KIND,
|
|
||||||
)
|
)
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
from .albuminfo import AlbumInfo, FolderInfo
|
from .albuminfo import AlbumInfo, FolderInfo
|
||||||
@@ -77,6 +78,12 @@ class PhotosDB:
|
|||||||
|
|
||||||
# set up the data structures used to store all the Photo database info
|
# set up the data structures used to store all the Photo database info
|
||||||
|
|
||||||
|
# if True, will treat persons as keywords when exporting metadata
|
||||||
|
self.use_persons_as_keywords = False
|
||||||
|
|
||||||
|
# if True, will treat albums as keywords when exporting metadata
|
||||||
|
self.use_albums_as_keywords = False
|
||||||
|
|
||||||
# Path to the Photos library database file
|
# Path to the Photos library database file
|
||||||
# photos.db in the photos library database/ directory
|
# photos.db in the photos library database/ directory
|
||||||
self._dbfile = None
|
self._dbfile = None
|
||||||
@@ -655,8 +662,11 @@ class PhotosDB:
|
|||||||
# build folder hierarchy
|
# build folder hierarchy
|
||||||
for album, details in self._dbalbum_details.items():
|
for album, details in self._dbalbum_details.items():
|
||||||
parent_folder = details["folderUuid"]
|
parent_folder = details["folderUuid"]
|
||||||
if parent_folder != _PHOTOS_4_TOP_LEVEL_ALBUM:
|
if details[
|
||||||
# logging.warning(f"album = {details['title']}, parent = {parent_folder}")
|
"albumSubclass"
|
||||||
|
] == _PHOTOS_4_ALBUM_KIND and parent_folder not in [
|
||||||
|
_PHOTOS_4_TOP_LEVEL_ALBUM
|
||||||
|
]:
|
||||||
folder_hierarchy = self._build_album_folder_hierarchy_4(parent_folder)
|
folder_hierarchy = self._build_album_folder_hierarchy_4(parent_folder)
|
||||||
self._dbalbum_folders[album] = folder_hierarchy
|
self._dbalbum_folders[album] = folder_hierarchy
|
||||||
else:
|
else:
|
||||||
@@ -1214,17 +1224,24 @@ class PhotosDB:
|
|||||||
|
|
||||||
def _build_album_folder_hierarchy_4(self, uuid, folders=None):
|
def _build_album_folder_hierarchy_4(self, uuid, folders=None):
|
||||||
""" recursively build folder/album hierarchy
|
""" recursively build folder/album hierarchy
|
||||||
uuid: uuid of the album/folder being processed
|
uuid: parent uuid of the album being processed
|
||||||
folders: dict holding the folder hierarchy """
|
(parent uuid is a folder in RKFolders)
|
||||||
|
folders: dict holding the folder hierarchy
|
||||||
|
NOTE: This implementation is different than _build_album_folder_hierarchy_5
|
||||||
|
which takes the uuid of the album being processed. Here uuid is the parent uuid
|
||||||
|
of the parent folder album because in Photos <=4, folders are in RKFolders and
|
||||||
|
albums in RKAlbums. In Photos 5, folders are just special albums
|
||||||
|
with kind = _PHOTOS_5_FOLDER_KIND """
|
||||||
|
|
||||||
parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"]
|
parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"]
|
||||||
|
|
||||||
# logging.warning(f"uuid = {uuid}, parent = {parent_uuid}, folders = {folders}")
|
|
||||||
|
|
||||||
if parent_uuid is None:
|
if parent_uuid is None:
|
||||||
return folders
|
return folders
|
||||||
|
|
||||||
if parent_uuid == _PHOTOS_4_TOP_LEVEL_ALBUM:
|
if parent_uuid == _PHOTOS_4_TOP_LEVEL_ALBUM:
|
||||||
|
if not folders:
|
||||||
|
# this is a top-level folder with no sub-folders
|
||||||
|
folders = {uuid: None}
|
||||||
# at top of hierarchy, we're done
|
# at top of hierarchy, we're done
|
||||||
return folders
|
return folders
|
||||||
|
|
||||||
@@ -1607,8 +1624,8 @@ class PhotosDB:
|
|||||||
info["momentID"] = row[26]
|
info["momentID"] = row[26]
|
||||||
|
|
||||||
# original resource choice (e.g. RAW or jpeg)
|
# original resource choice (e.g. RAW or jpeg)
|
||||||
# for images part of a RAW/jpeg pair,
|
# for images part of a RAW/jpeg pair,
|
||||||
# ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
|
# ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
|
||||||
# = 0 if jpeg is selected as "original" in Photos (the default)
|
# = 0 if jpeg is selected as "original" in Photos (the default)
|
||||||
# = 1 if RAW is selected as "original" in Photos
|
# = 1 if RAW is selected as "original" in Photos
|
||||||
info["original_resource_choice"] = row[27]
|
info["original_resource_choice"] = row[27]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" Custom template system for osxphotos """
|
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
|
||||||
|
|
||||||
# Rolled my own template system because:
|
# Rolled my own template system because:
|
||||||
# 1. Needed to handle multiple values (e.g. album, keyword)
|
# 1. Needed to handle multiple values (e.g. album, keyword)
|
||||||
@@ -9,18 +9,14 @@
|
|||||||
#
|
#
|
||||||
# This code isn't elegant but it seems to work well. PRs gladly accepted.
|
# This code isn't elegant but it seems to work well. PRs gladly accepted.
|
||||||
|
|
||||||
import datetime
|
import locale
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
from typing import Tuple, List # pylint: disable=syntax-error
|
|
||||||
|
|
||||||
from .photoinfo import PhotoInfo
|
# ensure locale set to user's locale
|
||||||
from ._constants import _UNKNOWN_PERSON
|
locale.setlocale(locale.LC_ALL, "")
|
||||||
|
|
||||||
# Permitted substitutions (each of these returns a single value or None)
|
# Permitted substitutions (each of these returns a single value or None)
|
||||||
TEMPLATE_SUBSTITUTIONS = {
|
TEMPLATE_SUBSTITUTIONS = {
|
||||||
"{name}": "Filename of the photo",
|
"{name}": "Current filename of the photo",
|
||||||
"{original_name}": "Photo's original filename when imported to Photos",
|
"{original_name}": "Photo's original filename when imported to Photos",
|
||||||
"{title}": "Title of the photo",
|
"{title}": "Title of the photo",
|
||||||
"{descr}": "Description of the photo",
|
"{descr}": "Description of the photo",
|
||||||
@@ -66,374 +62,3 @@ MULTI_VALUE_SUBSTITUTIONS = [
|
|||||||
field.replace("{", "").replace("}", "")
|
field.replace("{", "").replace("}", "")
|
||||||
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
|
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_template_value(lookup, photo):
|
|
||||||
""" lookup template value (single-value template substitutions) for use in make_subst_function
|
|
||||||
lookup: value to find a match for
|
|
||||||
photo: PhotoInfo object whose data will be used for value substitutions
|
|
||||||
returns: either the matching template value (which may be None)
|
|
||||||
raises: KeyError if no rule exists for lookup """
|
|
||||||
|
|
||||||
# must be a valid keyword
|
|
||||||
if lookup == "name":
|
|
||||||
return pathlib.Path(photo.filename).stem
|
|
||||||
|
|
||||||
if lookup == "original_name":
|
|
||||||
return pathlib.Path(photo.original_filename).stem
|
|
||||||
|
|
||||||
if lookup == "title":
|
|
||||||
return photo.title
|
|
||||||
|
|
||||||
if lookup == "descr":
|
|
||||||
return photo.description
|
|
||||||
|
|
||||||
if lookup == "created.date":
|
|
||||||
return DateTimeFormatter(photo.date).date
|
|
||||||
|
|
||||||
if lookup == "created.year":
|
|
||||||
return DateTimeFormatter(photo.date).year
|
|
||||||
|
|
||||||
if lookup == "created.yy":
|
|
||||||
return DateTimeFormatter(photo.date).yy
|
|
||||||
|
|
||||||
if lookup == "created.mm":
|
|
||||||
return DateTimeFormatter(photo.date).mm
|
|
||||||
|
|
||||||
if lookup == "created.month":
|
|
||||||
return DateTimeFormatter(photo.date).month
|
|
||||||
|
|
||||||
if lookup == "created.mon":
|
|
||||||
return DateTimeFormatter(photo.date).mon
|
|
||||||
|
|
||||||
if lookup == "created.doy":
|
|
||||||
return DateTimeFormatter(photo.date).doy
|
|
||||||
|
|
||||||
if lookup == "modified.date":
|
|
||||||
return (
|
|
||||||
DateTimeFormatter(photo.date_modified).date if photo.date_modified else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "modified.year":
|
|
||||||
return (
|
|
||||||
DateTimeFormatter(photo.date_modified).year if photo.date_modified else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "modified.yy":
|
|
||||||
return (
|
|
||||||
DateTimeFormatter(photo.date_modified).yy if photo.date_modified else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "modified.mm":
|
|
||||||
return (
|
|
||||||
DateTimeFormatter(photo.date_modified).mm if photo.date_modified else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "modified.month":
|
|
||||||
return (
|
|
||||||
DateTimeFormatter(photo.date_modified).month
|
|
||||||
if photo.date_modified
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "modified.mon":
|
|
||||||
return (
|
|
||||||
DateTimeFormatter(photo.date_modified).mon if photo.date_modified else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "modified.doy":
|
|
||||||
return (
|
|
||||||
DateTimeFormatter(photo.date_modified).doy if photo.date_modified else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.name":
|
|
||||||
return photo.place.name if photo.place else None
|
|
||||||
|
|
||||||
if lookup == "place.country_code":
|
|
||||||
return photo.place.country_code if photo.place else None
|
|
||||||
|
|
||||||
if lookup == "place.name.country":
|
|
||||||
return (
|
|
||||||
photo.place.names.country[0]
|
|
||||||
if photo.place and photo.place.names.country
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.name.state_province":
|
|
||||||
return (
|
|
||||||
photo.place.names.state_province[0]
|
|
||||||
if photo.place and photo.place.names.state_province
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.name.city":
|
|
||||||
return (
|
|
||||||
photo.place.names.city[0]
|
|
||||||
if photo.place and photo.place.names.city
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.name.area_of_interest":
|
|
||||||
return (
|
|
||||||
photo.place.names.area_of_interest[0]
|
|
||||||
if photo.place and photo.place.names.area_of_interest
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.address":
|
|
||||||
return (
|
|
||||||
photo.place.address_str if photo.place and photo.place.address_str else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.address.street":
|
|
||||||
return (
|
|
||||||
photo.place.address.street
|
|
||||||
if photo.place and photo.place.address.street
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.address.city":
|
|
||||||
return (
|
|
||||||
photo.place.address.city
|
|
||||||
if photo.place and photo.place.address.city
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.address.state_province":
|
|
||||||
return (
|
|
||||||
photo.place.address.state_province
|
|
||||||
if photo.place and photo.place.address.state_province
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.address.postal_code":
|
|
||||||
return (
|
|
||||||
photo.place.address.postal_code
|
|
||||||
if photo.place and photo.place.address.postal_code
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.address.country":
|
|
||||||
return (
|
|
||||||
photo.place.address.country
|
|
||||||
if photo.place and photo.place.address.country
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if lookup == "place.address.country_code":
|
|
||||||
return (
|
|
||||||
photo.place.address.iso_country_code
|
|
||||||
if photo.place and photo.place.address.iso_country_code
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# if here, didn't get a match
|
|
||||||
raise KeyError(f"No rule for processing {lookup}")
|
|
||||||
|
|
||||||
|
|
||||||
def render_filepath_template(template, photo, none_str="_"):
|
|
||||||
""" render a filename or directory template
|
|
||||||
template: str template
|
|
||||||
photo: PhotoInfo object
|
|
||||||
none_str: str to use default for None values, default is '_' """
|
|
||||||
|
|
||||||
# the rendering happens in two phases:
|
|
||||||
# phase 1: handle all the single-value template substitutions
|
|
||||||
# results in a single string with all the template fields replaced
|
|
||||||
# phase 2: loop through all the multi-value template substitutions
|
|
||||||
# could result in multiple strings
|
|
||||||
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
|
|
||||||
# there would be 6 possible renderings (2 albums x 3 persons)
|
|
||||||
|
|
||||||
# regex to find {template_field,optional_default} in strings
|
|
||||||
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
|
||||||
# pylint: disable=anomalous-backslash-in-string
|
|
||||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}"
|
|
||||||
|
|
||||||
if type(template) is not str:
|
|
||||||
raise TypeError(f"template must be type str, not {type(template)}")
|
|
||||||
|
|
||||||
if type(photo) is not PhotoInfo:
|
|
||||||
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
|
|
||||||
|
|
||||||
def make_subst_function(photo, none_str, get_func=get_template_value):
|
|
||||||
""" returns: substitution function for use in re.sub
|
|
||||||
photo: a PhotoInfo object
|
|
||||||
none_str: value to use if substitution lookup is None and no default provided
|
|
||||||
get_func: function that gets the substitution value for a given template field
|
|
||||||
default is get_template_value which handles the single-value fields """
|
|
||||||
|
|
||||||
# closure to capture photo, none_str in subst
|
|
||||||
def subst(matchobj):
|
|
||||||
groups = len(matchobj.groups())
|
|
||||||
if groups == 4:
|
|
||||||
try:
|
|
||||||
val = get_func(matchobj.group(1), photo)
|
|
||||||
except KeyError:
|
|
||||||
return matchobj.group(0)
|
|
||||||
|
|
||||||
if val is None:
|
|
||||||
return (
|
|
||||||
matchobj.group(3) if matchobj.group(3) is not None else none_str
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return val
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected number of groups: expected 4, got {groups}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return subst
|
|
||||||
|
|
||||||
subst_func = make_subst_function(photo, none_str)
|
|
||||||
|
|
||||||
# do the replacements
|
|
||||||
rendered = re.sub(regex, subst_func, template)
|
|
||||||
|
|
||||||
# do multi-valued placements
|
|
||||||
# start with the single string from phase 1 above then loop through all
|
|
||||||
# multi-valued fields and all values for each of those fields
|
|
||||||
# rendered_strings will be updated as each field is processed
|
|
||||||
# for example: if two albums, two keywords, and one person and template is:
|
|
||||||
# "{created.year}/{album}/{keyword}/{person}"
|
|
||||||
# rendered strings would do the following:
|
|
||||||
# start (created.year filled in phase 1)
|
|
||||||
# ['2011/{album}/{keyword}/{person}']
|
|
||||||
# after processing albums:
|
|
||||||
# ['2011/Album1/{keyword}/{person}',
|
|
||||||
# '2011/Album2/{keyword}/{person}',]
|
|
||||||
# after processing keywords:
|
|
||||||
# ['2011/Album1/keyword1/{person}',
|
|
||||||
# '2011/Album1/keyword2/{person}',
|
|
||||||
# '2011/Album2/keyword1/{person}',
|
|
||||||
# '2011/Album2/keyword2/{person}',]
|
|
||||||
# after processing person:
|
|
||||||
# ['2011/Album1/keyword1/person1',
|
|
||||||
# '2011/Album1/keyword2/person1',
|
|
||||||
# '2011/Album2/keyword1/person1',
|
|
||||||
# '2011/Album2/keyword2/person1',]
|
|
||||||
|
|
||||||
rendered_strings = set([rendered])
|
|
||||||
for field in MULTI_VALUE_SUBSTITUTIONS:
|
|
||||||
if field == "album":
|
|
||||||
values = photo.albums
|
|
||||||
elif field == "keyword":
|
|
||||||
values = photo.keywords
|
|
||||||
elif field == "person":
|
|
||||||
values = photo.persons
|
|
||||||
# remove any _UNKNOWN_PERSON values
|
|
||||||
values = [val for val in values if val != _UNKNOWN_PERSON]
|
|
||||||
elif field == "folder_album":
|
|
||||||
values = []
|
|
||||||
# photos must be in an album to be in a folder
|
|
||||||
for album in photo.album_info:
|
|
||||||
if album.folder_names:
|
|
||||||
# album in folder
|
|
||||||
folder = os.path.sep.join(album.folder_names)
|
|
||||||
folder += os.path.sep + album.title
|
|
||||||
values.append(folder)
|
|
||||||
else:
|
|
||||||
# album not in folder
|
|
||||||
values.append(album.title)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unhandleded template value: {field}")
|
|
||||||
|
|
||||||
# If no values, insert None so code below will substite none_str for None
|
|
||||||
values = values or [None]
|
|
||||||
|
|
||||||
# Build a regex that matches only the field being processed
|
|
||||||
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
|
|
||||||
regex_multi = re.compile(re_str)
|
|
||||||
|
|
||||||
# holds each of the new rendered_strings, set() to avoid duplicates
|
|
||||||
new_strings = set()
|
|
||||||
|
|
||||||
for str_template in rendered_strings:
|
|
||||||
for val in values:
|
|
||||||
|
|
||||||
def get_template_value_multi(lookup_value, photo):
|
|
||||||
""" Closure passed to make_subst_function get_func
|
|
||||||
Capture val and field in the closure
|
|
||||||
Allows make_subst_function to be re-used w/o modification """
|
|
||||||
if lookup_value == field:
|
|
||||||
return val
|
|
||||||
else:
|
|
||||||
raise KeyError(f"Unexpected value: {lookup_value}")
|
|
||||||
|
|
||||||
subst = make_subst_function(
|
|
||||||
photo, none_str, get_func=get_template_value_multi
|
|
||||||
)
|
|
||||||
new_string = regex_multi.sub(subst, str_template)
|
|
||||||
new_strings.add(new_string)
|
|
||||||
|
|
||||||
# update rendered_strings for the next field to process
|
|
||||||
rendered_strings = new_strings
|
|
||||||
|
|
||||||
# find any {fields} that weren't replaced
|
|
||||||
unmatched = []
|
|
||||||
for rendered_str in rendered_strings:
|
|
||||||
unmatched.extend(
|
|
||||||
[
|
|
||||||
no_match[0]
|
|
||||||
for no_match in re.findall(regex, rendered_str)
|
|
||||||
if no_match[0] not in unmatched
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# fix any escaped curly braces
|
|
||||||
rendered_strings = [
|
|
||||||
rendered_str.replace("{{", "{").replace("}}", "}")
|
|
||||||
for rendered_str in rendered_strings
|
|
||||||
]
|
|
||||||
|
|
||||||
return rendered_strings, unmatched
|
|
||||||
|
|
||||||
|
|
||||||
class DateTimeFormatter:
|
|
||||||
""" provides property access to formatted datetime.datetime strftime values """
|
|
||||||
|
|
||||||
def __init__(self, dt: datetime.datetime):
|
|
||||||
self.dt = dt
|
|
||||||
|
|
||||||
@property
|
|
||||||
def date(self):
|
|
||||||
""" ISO date in form 2020-03-22 """
|
|
||||||
date = self.dt.date().isoformat()
|
|
||||||
return date
|
|
||||||
|
|
||||||
@property
|
|
||||||
def year(self):
|
|
||||||
""" 4 digit year """
|
|
||||||
year = f"{self.dt.year}"
|
|
||||||
return year
|
|
||||||
|
|
||||||
@property
|
|
||||||
def yy(self):
|
|
||||||
""" 2 digit year """
|
|
||||||
yy = f"{self.dt.strftime('%y')}"
|
|
||||||
return yy
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mm(self):
|
|
||||||
""" 2 digit month """
|
|
||||||
mm = f"{self.dt.strftime('%m')}"
|
|
||||||
return mm
|
|
||||||
|
|
||||||
@property
|
|
||||||
def month(self):
|
|
||||||
""" Month as locale's full name """
|
|
||||||
month = f"{self.dt.strftime('%B')}"
|
|
||||||
return month
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mon(self):
|
|
||||||
""" Month as locale's abbreviated name """
|
|
||||||
mon = f"{self.dt.strftime('%b')}"
|
|
||||||
return mon
|
|
||||||
|
|
||||||
@property
|
|
||||||
def doy(self):
|
|
||||||
""" Julian day of year starting from 001 """
|
|
||||||
doy = f"{self.dt.strftime('%j')}"
|
|
||||||
return doy
|
|
||||||
|
|||||||
@@ -79,16 +79,16 @@
|
|||||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||||
${dc_description(photo.description)}
|
${dc_description(photo.description)}
|
||||||
${dc_title(photo.title)}
|
${dc_title(photo.title)}
|
||||||
${dc_subject(photo.keywords + photo.persons)}
|
${dc_subject(subjects)}
|
||||||
${dc_datecreated(photo.date)}
|
${dc_datecreated(photo.date)}
|
||||||
</rdf:Description>
|
</rdf:Description>
|
||||||
<rdf:Description rdf:about=''
|
<rdf:Description rdf:about=''
|
||||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||||
${iptc_personinimage(photo.persons)}
|
${iptc_personinimage(persons)}
|
||||||
</rdf:Description>
|
</rdf:Description>
|
||||||
<rdf:Description rdf:about=''
|
<rdf:Description rdf:about=''
|
||||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||||
${dk_tagslist(photo.keywords)}
|
${dk_tagslist(keywords)}
|
||||||
</rdf:Description>
|
</rdf:Description>
|
||||||
<rdf:Description rdf:about=''
|
<rdf:Description rdf:about=''
|
||||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ To run the tests, do the following from the main source folder:
|
|||||||
|
|
||||||
Running the tests this way allows the library to be tested without installing it.
|
Running the tests this way allows the library to be tested without installing it.
|
||||||
|
|
||||||
|
## Skipped Tests ##
|
||||||
|
A few tests will look for certain environment variables to determine if they should run.
|
||||||
|
|
||||||
|
Some of the export tests rely on photos in my local library and will look for `OSXPHOTOS_TEST_EXPORT=1` to determine if they should run.
|
||||||
|
|
||||||
|
One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable.
|
||||||
|
|
||||||
## Attribution ##
|
## Attribution ##
|
||||||
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
|
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,8 +8,11 @@
|
|||||||
<array/>
|
<array/>
|
||||||
<key>ExpandedSidebarItemIdentifiers</key>
|
<key>ExpandedSidebarItemIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>obfeGcvoT1auxoh2Tu86OQ</string>
|
||||||
<string>TopLevelAlbums</string>
|
<string>TopLevelAlbums</string>
|
||||||
<string>TopLevelSlideshows</string>
|
<string>TopLevelSlideshows</string>
|
||||||
|
<string>MBS8+gBrQCWQxmcav+C8HQ</string>
|
||||||
|
<string>cHwwVoUiQ8a2nZNXgVsnCA</string>
|
||||||
</array>
|
</array>
|
||||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -23,11 +26,11 @@
|
|||||||
<key>key</key>
|
<key>key</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
<key>lastKnownDisplayName</key>
|
<key>lastKnownDisplayName</key>
|
||||||
<string>September 28, 2018</string>
|
<string>Pumpkin Farm (1)</string>
|
||||||
<key>type</key>
|
<key>type</key>
|
||||||
<string>album</string>
|
<string>album</string>
|
||||||
<key>uuid</key>
|
<key>uuid</key>
|
||||||
<string>+Ep8CrNRRhea9eVA618FMg</string>
|
<string>AU8Gp8bwRlOvngZFgwXBdg</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>lastKnownItemCounts</key>
|
<key>lastKnownItemCounts</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||||
<date>2019-07-28T01:23:52Z</date>
|
<date>2020-04-30T14:09:38Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||||
<date>2019-07-28T01:23:52Z</date>
|
<date>2020-05-01T04:27:48Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,11 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>ProcessedInQuiescentState</key>
|
||||||
|
<true/>
|
||||||
<key>SuggestedMeIdentifier</key>
|
<key>SuggestedMeIdentifier</key>
|
||||||
<string></string>
|
<string></string>
|
||||||
|
<key>Version</key>
|
||||||
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>LithiumMessageTracer</key>
|
<key>LithiumMessageTracer</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>LastReportedDate</key>
|
<key>LastReportedDate</key>
|
||||||
<date>2019-07-27T12:01:15Z</date>
|
<date>2020-05-01T03:34:50Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -11,6 +11,6 @@
|
|||||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||||
<date>2019-07-26T20:15:18Z</date>
|
<date>2020-04-30T12:51:41Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>LastHistoryRowId</key>
|
<key>LastHistoryRowId</key>
|
||||||
<integer>615</integer>
|
<integer>670</integer>
|
||||||
<key>LibraryBuildTag</key>
|
<key>LibraryBuildTag</key>
|
||||||
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
|
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
|
||||||
<key>LibrarySchemaVersion</key>
|
<key>LibrarySchemaVersion</key>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<key>HistoricalMarker</key>
|
<key>HistoricalMarker</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>LastHistoryRowId</key>
|
<key>LastHistoryRowId</key>
|
||||||
<integer>615</integer>
|
<integer>670</integer>
|
||||||
<key>LibraryBuildTag</key>
|
<key>LibraryBuildTag</key>
|
||||||
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
|
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
|
||||||
<key>LibrarySchemaVersion</key>
|
<key>LibrarySchemaVersion</key>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<key>SnapshotCompletedDate</key>
|
<key>SnapshotCompletedDate</key>
|
||||||
<date>2019-07-26T20:15:17Z</date>
|
<date>2019-07-26T20:15:17Z</date>
|
||||||
<key>SnapshotLastValidated</key>
|
<key>SnapshotLastValidated</key>
|
||||||
<date>2019-07-27T12:01:15Z</date>
|
<date>2020-05-01T03:34:50Z</date>
|
||||||
<key>SnapshotTables</key>
|
<key>SnapshotTables</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,8 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||||
<date>2020-04-18T18:01:02Z</date>
|
<date>2020-04-25T23:54:43Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||||
<date>2020-04-18T17:22:55Z</date>
|
<date>2020-04-26T06:26:10Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,6 @@
|
|||||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||||
<date>2020-04-17T17:49:52Z</date>
|
<date>2020-04-25T23:54:29Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<key>SnapshotCompletedDate</key>
|
<key>SnapshotCompletedDate</key>
|
||||||
<date>2019-07-27T13:16:43Z</date>
|
<date>2019-07-27T13:16:43Z</date>
|
||||||
<key>SnapshotLastValidated</key>
|
<key>SnapshotLastValidated</key>
|
||||||
<date>2020-04-17T17:51:16Z</date>
|
<date>2020-04-25T23:56:35Z</date>
|
||||||
<key>SnapshotTables</key>
|
<key>SnapshotTables</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
|||||||
<key>hostuuid</key>
|
<key>hostuuid</key>
|
||||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||||
<key>pid</key>
|
<key>pid</key>
|
||||||
<integer>900</integer>
|
<integer>725</integer>
|
||||||
<key>processname</key>
|
<key>processname</key>
|
||||||
<string>photolibraryd</string>
|
<string>photolibraryd</string>
|
||||||
<key>uid</key>
|
<key>uid</key>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,24 +3,24 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>BackgroundHighlightCollection</key>
|
<key>BackgroundHighlightCollection</key>
|
||||||
<date>2020-04-17T14:33:32Z</date>
|
<date>2020-05-01T23:03:12Z</date>
|
||||||
<key>BackgroundHighlightEnrichment</key>
|
<key>BackgroundHighlightEnrichment</key>
|
||||||
<date>2020-04-17T14:33:32Z</date>
|
<date>2020-05-01T23:03:11Z</date>
|
||||||
<key>BackgroundJobAssetRevGeocode</key>
|
<key>BackgroundJobAssetRevGeocode</key>
|
||||||
<date>2020-04-17T14:33:33Z</date>
|
<date>2020-05-02T01:35:19Z</date>
|
||||||
<key>BackgroundJobSearch</key>
|
<key>BackgroundJobSearch</key>
|
||||||
<date>2020-04-17T14:33:33Z</date>
|
<date>2020-05-01T23:03:12Z</date>
|
||||||
<key>BackgroundPeopleSuggestion</key>
|
<key>BackgroundPeopleSuggestion</key>
|
||||||
<date>2020-04-17T14:33:31Z</date>
|
<date>2020-05-01T23:03:11Z</date>
|
||||||
<key>BackgroundUserBehaviorProcessor</key>
|
<key>BackgroundUserBehaviorProcessor</key>
|
||||||
<date>2020-04-17T07:32:04Z</date>
|
<date>2020-05-01T23:03:13Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||||
<date>2020-04-17T14:33:37Z</date>
|
<date>2020-05-02T01:35:36Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||||
<date>2020-04-17T07:32:00Z</date>
|
<date>2020-05-01T23:03:11Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||||
<date>2020-04-17T14:33:34Z</date>
|
<date>2020-05-02T01:35:19Z</date>
|
||||||
<key>SiriPortraitDonation</key>
|
<key>SiriPortraitDonation</key>
|
||||||
<date>2020-04-17T07:32:04Z</date>
|
<date>2020-05-01T23:03:13Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,8 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>FaceIDModelLastGenerationKey</key>
|
<key>FaceIDModelLastGenerationKey</key>
|
||||||
<date>2020-04-17T07:32:07Z</date>
|
<date>2020-05-01T23:03:14Z</date>
|
||||||
<key>LastContactClassificationKey</key>
|
<key>LastContactClassificationKey</key>
|
||||||
<date>2020-04-17T07:32:12Z</date>
|
<date>2020-05-01T23:03:18Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Binary file not shown.
229
tests/test_albums_folders_high_sierra_10_13_6.py
Normal file
229
tests/test_albums_folders_high_sierra_10_13_6.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from osxphotos._constants import _UNKNOWN_PERSON
|
||||||
|
|
||||||
|
PHOTOS_DB = "./tests/Test-10.13.6.photoslibrary/database/photos.db"
|
||||||
|
|
||||||
|
TOP_LEVEL_FOLDERS = ["Folder1", "TestFolder"]
|
||||||
|
|
||||||
|
TOP_LEVEL_CHILDREN = ["SubFolder1", "SubFolder2"]
|
||||||
|
|
||||||
|
FOLDER_ALBUM_DICT = {
|
||||||
|
"Folder1": [],
|
||||||
|
"SubFolder1": [],
|
||||||
|
"SubFolder2": ["AlbumInFolder"],
|
||||||
|
"TestFolder": ["TestAlbum"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ALBUM_NAMES = ["Pumpkin Farm", "AlbumInFolder", "TestAlbum"]
|
||||||
|
|
||||||
|
ALBUM_PARENT_DICT = {
|
||||||
|
"Pumpkin Farm": None,
|
||||||
|
"AlbumInFolder": "SubFolder2",
|
||||||
|
"TestAlbum": "TestFolder",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALBUM_FOLDER_NAMES_DICT = {
|
||||||
|
"Pumpkin Farm": [],
|
||||||
|
"AlbumInFolder": ["Folder1", "SubFolder2"],
|
||||||
|
"TestAlbum": ["TestFolder"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ALBUM_LEN_DICT = {"Pumpkin Farm": 3, "AlbumInFolder": 1, "TestAlbum": 1}
|
||||||
|
|
||||||
|
ALBUM_PHOTO_UUID_DICT = {
|
||||||
|
"Pumpkin Farm": [
|
||||||
|
"vAZGdUK1QdGfWPgC+KsJag",
|
||||||
|
"NlY8CklESxGpaKsTVHB3HQ",
|
||||||
|
"RWmFYiDjSyKjeK8Pfna0Eg",
|
||||||
|
],
|
||||||
|
"AlbumInFolder": ["RWmFYiDjSyKjeK8Pfna0Eg"],
|
||||||
|
"TestAlbum": ["NlY8CklESxGpaKsTVHB3HQ"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
######### Test FolderInfo ##########
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_1():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
folders = photosdb.folders
|
||||||
|
|
||||||
|
# top level folders
|
||||||
|
folders = photosdb.folder_info
|
||||||
|
assert len(folders) == len(TOP_LEVEL_FOLDERS)
|
||||||
|
|
||||||
|
# check folder names
|
||||||
|
folder_names = [f.title for f in folders]
|
||||||
|
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_names():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
# check folder names
|
||||||
|
folder_names = photosdb.folders
|
||||||
|
assert folder_names == TOP_LEVEL_FOLDERS
|
||||||
|
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_len():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
# top level folders
|
||||||
|
folders = photosdb.folder_info
|
||||||
|
assert len(folders[0]) == len(TOP_LEVEL_CHILDREN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_children():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
# top level folders
|
||||||
|
folders = photosdb.folder_info
|
||||||
|
|
||||||
|
# children of top level folder
|
||||||
|
children = folders[0].subfolders
|
||||||
|
children_names = [f.title for f in children]
|
||||||
|
assert sorted(children_names) == sorted(TOP_LEVEL_CHILDREN)
|
||||||
|
|
||||||
|
for child in folders[0].subfolders:
|
||||||
|
# check valid children FolderInfo
|
||||||
|
assert child.parent
|
||||||
|
assert child.parent.uuid == folders[0].uuid
|
||||||
|
|
||||||
|
# check folder names
|
||||||
|
folder_names = [f.title for f in folders]
|
||||||
|
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_parent():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
# top level folders
|
||||||
|
folders = photosdb.folder_info
|
||||||
|
|
||||||
|
# parent of top level folder should be none
|
||||||
|
for folder in folders:
|
||||||
|
assert folder.parent is None
|
||||||
|
for child in folder.subfolders:
|
||||||
|
# children's parent uuid should match folder uuid
|
||||||
|
assert child.parent
|
||||||
|
assert child.parent.uuid == folder.uuid
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_albums():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
# top level folders
|
||||||
|
folders = photosdb.folder_info
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
name = folder.title
|
||||||
|
albums = [a.title for a in folder.album_info]
|
||||||
|
assert sorted(albums) == sorted(FOLDER_ALBUM_DICT[name])
|
||||||
|
for child in folder.subfolders:
|
||||||
|
name = child.title
|
||||||
|
albums = [a.title for a in child.album_info]
|
||||||
|
assert sorted(albums) == sorted(FOLDER_ALBUM_DICT[name])
|
||||||
|
|
||||||
|
|
||||||
|
########## Test AlbumInfo ##########
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_1():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
albums = photosdb.album_info
|
||||||
|
assert len(albums) == len(ALBUM_NAMES)
|
||||||
|
|
||||||
|
# check names
|
||||||
|
album_names = [a.title for a in albums]
|
||||||
|
assert sorted(album_names) == sorted(ALBUM_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_parent():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
albums = photosdb.album_info
|
||||||
|
|
||||||
|
for album in albums:
|
||||||
|
parent = album.parent.title if album.parent else None
|
||||||
|
assert parent == ALBUM_PARENT_DICT[album.title]
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_folder_names():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
albums = photosdb.album_info
|
||||||
|
|
||||||
|
for album in albums:
|
||||||
|
folder_names = album.folder_names
|
||||||
|
assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_folders():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
albums = photosdb.album_info
|
||||||
|
|
||||||
|
for album in albums:
|
||||||
|
folders = album.folder_list
|
||||||
|
folder_names = [f.title for f in folders]
|
||||||
|
assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_len():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
albums = photosdb.album_info
|
||||||
|
|
||||||
|
for album in albums:
|
||||||
|
assert len(album) == ALBUM_LEN_DICT[album.title]
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_photos():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
|
||||||
|
albums = photosdb.album_info
|
||||||
|
|
||||||
|
for album in albums:
|
||||||
|
photos = album.photos
|
||||||
|
assert len(photos) == ALBUM_LEN_DICT[album.title]
|
||||||
|
assert len(photos) == len(album)
|
||||||
|
for photo in photos:
|
||||||
|
assert photo.uuid in ALBUM_PHOTO_UUID_DICT[album.title]
|
||||||
|
|
||||||
|
|
||||||
|
def test_photoinfo_albums():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=ALBUM_PHOTO_UUID_DICT["Pumpkin Farm"])
|
||||||
|
|
||||||
|
albums = photos[0].albums
|
||||||
|
assert "Pumpkin Farm" in albums
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
@@ -71,6 +72,12 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
|
|||||||
"2018/September/Pumkins1.jpg",
|
"2018/September/Pumkins1.jpg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE = [
|
||||||
|
"2019/September/IMG_9975.JPEG",
|
||||||
|
"2020/Februar/IMG_1064.JPEG",
|
||||||
|
"2016/März/IMG_3984.JPEG",
|
||||||
|
]
|
||||||
|
|
||||||
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [
|
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [
|
||||||
"Multi Keyword/wedding.jpg",
|
"Multi Keyword/wedding.jpg",
|
||||||
"_/Tulips.jpg",
|
"_/Tulips.jpg",
|
||||||
@@ -325,6 +332,7 @@ def test_export_sidecar():
|
|||||||
"-V",
|
"-V",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
files = glob.glob("*.*")
|
files = glob.glob("*.*")
|
||||||
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
|
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
|
||||||
|
|
||||||
@@ -595,6 +603,49 @@ def test_export_directory_template_album_2():
|
|||||||
assert os.path.isfile(os.path.join(workdir, filepath))
|
assert os.path.isfile(os.path.join(workdir, filepath))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
"OSXPHOTOS_TEST_LOCALE" not in os.environ,
|
||||||
|
reason="Skip if running in Github actions",
|
||||||
|
)
|
||||||
|
def test_export_directory_template_locale():
|
||||||
|
# test export using directory template in user locale non-US
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import locale
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
import osxphotos
|
||||||
|
from osxphotos.__main__ import export
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
# set locale environment
|
||||||
|
os.environ["LANG"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_COLLATE"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_CTYPE"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_MESSAGES"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_MONETARY"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_NUMERIC"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_TIME"] = "de_DE.UTF-8"
|
||||||
|
locale.setlocale(locale.LC_ALL, "")
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[
|
||||||
|
os.path.join(cwd, PLACES_PHOTOS_DB),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--directory",
|
||||||
|
"{created.year}/{created.month}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
workdir = os.getcwd()
|
||||||
|
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE:
|
||||||
|
assert os.path.isfile(os.path.join(workdir, filepath))
|
||||||
|
|
||||||
|
|
||||||
def test_places():
|
def test_places():
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -805,3 +856,70 @@ def test_no_folder_1_14():
|
|||||||
json_got = json.loads(result.output)
|
json_got = json.loads(result.output)
|
||||||
assert len(json_got) == 1 # single element
|
assert len(json_got) == 1 # single element
|
||||||
assert json_got[0]["uuid"] == "15uNd7%8RguTEgNPKHfTWw"
|
assert json_got[0]["uuid"] == "15uNd7%8RguTEgNPKHfTWw"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_sidecar_keyword_template():
|
||||||
|
import json
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
from osxphotos.__main__ import cli
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"export",
|
||||||
|
"--db",
|
||||||
|
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||||
|
".",
|
||||||
|
"--sidecar=json",
|
||||||
|
"--sidecar=xmp",
|
||||||
|
"--keyword-template",
|
||||||
|
"{folder_album}",
|
||||||
|
f"--uuid={CLI_EXPORT_UUID}",
|
||||||
|
"-V",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
files = glob.glob("*.*")
|
||||||
|
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
|
||||||
|
|
||||||
|
json_expected = json.loads(
|
||||||
|
"""
|
||||||
|
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
||||||
|
"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||||
|
"XMP:Description": "Girl holding pumpkin",
|
||||||
|
"XMP:Title": "I found one!",
|
||||||
|
"XMP:TagsList": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
|
||||||
|
"IPTC:Keywords": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
|
||||||
|
"XMP:PersonInImage": ["Katie"],
|
||||||
|
"XMP:Subject": ["Kids", "Katie"],
|
||||||
|
"EXIF:DateTimeOriginal": "2018:09:28 16:07:07",
|
||||||
|
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||||
|
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
json_file = open("Pumkins2.json", "r")
|
||||||
|
json_got = json.load(json_file)[0]
|
||||||
|
json_file.close()
|
||||||
|
|
||||||
|
# some gymnastics to account for different sort order in different pythons
|
||||||
|
for k, v in json_got.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_expected[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_expected[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|||||||
@@ -468,6 +468,85 @@ def test_exiftool_json_sidecar():
|
|||||||
json_got = json.loads(json_got)[0]
|
json_got = json.loads(json_got)[0]
|
||||||
|
|
||||||
# some gymnastics to account for different sort order in different pythons
|
# some gymnastics to account for different sort order in different pythons
|
||||||
|
for k, v in json_got.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_expected[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_expected[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|
||||||
|
|
||||||
|
def test_exiftool_json_sidecar_use_persons_keyword():
|
||||||
|
import osxphotos
|
||||||
|
import json
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||||
|
|
||||||
|
json_expected = json.loads(
|
||||||
|
"""
|
||||||
|
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
||||||
|
"EXIF:ImageDescription": "Girls with pumpkins",
|
||||||
|
"XMP:Description": "Girls with pumpkins",
|
||||||
|
"XMP:Title": "Can we carry this?",
|
||||||
|
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
|
||||||
|
"IPTC:Keywords": ["Kids", "Suzy", "Katie"],
|
||||||
|
"XMP:PersonInImage": ["Suzy", "Katie"],
|
||||||
|
"XMP:Subject": ["Kids", "Suzy", "Katie"],
|
||||||
|
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
|
||||||
|
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||||
|
"EXIF:ModifyDate": "2019:11:24 13:09:17"}]
|
||||||
|
"""
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
json_got = photos[0]._exiftool_json_sidecar(use_persons_as_keywords=True)
|
||||||
|
json_got = json.loads(json_got)[0]
|
||||||
|
|
||||||
|
# some gymnastics to account for different sort order in different pythons
|
||||||
|
for k, v in json_got.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_expected[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_expected[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|
||||||
|
|
||||||
|
def test_exiftool_json_sidecar_use_albums_keyword():
|
||||||
|
import osxphotos
|
||||||
|
import json
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||||
|
|
||||||
|
json_expected = json.loads(
|
||||||
|
"""
|
||||||
|
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
||||||
|
"EXIF:ImageDescription": "Girls with pumpkins",
|
||||||
|
"XMP:Description": "Girls with pumpkins",
|
||||||
|
"XMP:Title": "Can we carry this?",
|
||||||
|
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
|
||||||
|
"IPTC:Keywords": ["Kids", "Pumpkin Farm", "Test Album"],
|
||||||
|
"XMP:PersonInImage": ["Suzy", "Katie"],
|
||||||
|
"XMP:Subject": ["Kids", "Suzy", "Katie"],
|
||||||
|
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
|
||||||
|
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||||
|
"EXIF:ModifyDate": "2019:11:24 13:09:17"}]
|
||||||
|
"""
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
json_got = photos[0]._exiftool_json_sidecar(use_albums_as_keywords=True)
|
||||||
|
json_got = json.loads(json_got)[0]
|
||||||
|
|
||||||
# some gymnastics to account for different sort order in different pythons
|
# some gymnastics to account for different sort order in different pythons
|
||||||
for k, v in json_got.items():
|
for k, v in json_got.items():
|
||||||
if type(v) in (list, tuple):
|
if type(v) in (list, tuple):
|
||||||
@@ -530,12 +609,140 @@ def test_xmp_sidecar():
|
|||||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||||
</rdf:Description>
|
</rdf:Description>
|
||||||
</rdf:RDF>
|
</rdf:RDF>
|
||||||
</x:xmpmeta>"""
|
</x:xmpmeta>"""
|
||||||
|
|
||||||
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||||
|
|
||||||
xmp_got = photos[0]._xmp_sidecar()
|
xmp_got = photos[0]._xmp_sidecar()
|
||||||
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
|
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
|
||||||
|
|
||||||
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
|
for line_expected, line_got in zip(
|
||||||
|
sorted(xmp_expected_lines), sorted(xmp_got_lines)
|
||||||
|
):
|
||||||
|
assert line_expected == line_got
|
||||||
|
|
||||||
|
|
||||||
|
def test_xmp_sidecar_use_persons_keyword():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||||
|
|
||||||
|
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
|
||||||
|
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about=""
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||||
|
<dc:description>Girls with pumpkins</dc:description>
|
||||||
|
<dc:title>Can we carry this?</dc:title>
|
||||||
|
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||||
|
<dc:subject>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</dc:subject>
|
||||||
|
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||||
|
<Iptc4xmpExt:PersonInImage>
|
||||||
|
<rdf:Bag>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Bag>
|
||||||
|
</Iptc4xmpExt:PersonInImage>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||||
|
<digiKam:TagsList>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</digiKam:TagsList>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||||
|
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||||
|
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>"""
|
||||||
|
|
||||||
|
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||||
|
|
||||||
|
xmp_got = photos[0]._xmp_sidecar(use_persons_as_keywords=True)
|
||||||
|
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
|
||||||
|
|
||||||
|
for line_expected, line_got in zip(
|
||||||
|
sorted(xmp_expected_lines), sorted(xmp_got_lines)
|
||||||
|
):
|
||||||
|
assert line_expected == line_got
|
||||||
|
|
||||||
|
|
||||||
|
def test_xmp_sidecar_use_albums_keyword():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||||
|
|
||||||
|
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
|
||||||
|
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about=""
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||||
|
<dc:description>Girls with pumpkins</dc:description>
|
||||||
|
<dc:title>Can we carry this?</dc:title>
|
||||||
|
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||||
|
<dc:subject>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</dc:subject>
|
||||||
|
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||||
|
<Iptc4xmpExt:PersonInImage>
|
||||||
|
<rdf:Bag>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Bag>
|
||||||
|
</Iptc4xmpExt:PersonInImage>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||||
|
<digiKam:TagsList>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Pumpkin Farm</rdf:li>
|
||||||
|
<rdf:li>Test Album</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</digiKam:TagsList>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||||
|
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||||
|
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>"""
|
||||||
|
|
||||||
|
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||||
|
|
||||||
|
xmp_got = photos[0]._xmp_sidecar(use_albums_as_keywords=True)
|
||||||
|
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
|
||||||
|
|
||||||
|
for line_expected, line_got in zip(
|
||||||
|
sorted(xmp_expected_lines), sorted(xmp_got_lines)
|
||||||
|
):
|
||||||
assert line_expected == line_got
|
assert line_expected == line_got
|
||||||
|
|||||||
@@ -155,4 +155,3 @@ def test_export_edited_no_edit(photosdb):
|
|||||||
with pytest.raises(Exception) as e:
|
with pytest.raises(Exception) as e:
|
||||||
assert photos[0].export(dest, use_photos_export=True, edited=True)
|
assert photos[0].export(dest, use_photos_export=True, edited=True)
|
||||||
assert e.type == ValueError
|
assert e.type == ValueError
|
||||||
|
|
||||||
|
|||||||
219
tests/test_export_keyword_template_catalina_10_15_4.py
Normal file
219
tests/test_export_keyword_template_catalina_10_15_4.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from osxphotos._constants import _UNKNOWN_PERSON
|
||||||
|
|
||||||
|
PHOTOS_DB = "./tests/Test-10.15.4.photoslibrary/database/photos.db"
|
||||||
|
|
||||||
|
TOP_LEVEL_FOLDERS = ["Folder1"]
|
||||||
|
|
||||||
|
TOP_LEVEL_CHILDREN = ["SubFolder1", "SubFolder2"]
|
||||||
|
|
||||||
|
FOLDER_ALBUM_DICT = {"Folder1": [], "SubFolder1": [], "SubFolder2": ["AlbumInFolder"]}
|
||||||
|
|
||||||
|
ALBUM_NAMES = ["Pumpkin Farm", "AlbumInFolder", "Test Album", "Test Album"]
|
||||||
|
|
||||||
|
ALBUM_PARENT_DICT = {
|
||||||
|
"Pumpkin Farm": None,
|
||||||
|
"AlbumInFolder": "SubFolder2",
|
||||||
|
"Test Album": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
ALBUM_FOLDER_NAMES_DICT = {
|
||||||
|
"Pumpkin Farm": [],
|
||||||
|
"AlbumInFolder": ["Folder1", "SubFolder2"],
|
||||||
|
"Test Album": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
ALBUM_LEN_DICT = {"Pumpkin Farm": 3, "AlbumInFolder": 2, "Test Album": 1}
|
||||||
|
|
||||||
|
ALBUM_PHOTO_UUID_DICT = {
|
||||||
|
"Pumpkin Farm": [
|
||||||
|
"F12384F6-CD17-4151-ACBA-AE0E3688539E",
|
||||||
|
"D79B8D77-BFFC-460B-9312-034F2877D35B",
|
||||||
|
"1EB2B765-0765-43BA-A90C-0D0580E6172C",
|
||||||
|
],
|
||||||
|
"Test Album": [
|
||||||
|
"F12384F6-CD17-4151-ACBA-AE0E3688539E",
|
||||||
|
"D79B8D77-BFFC-460B-9312-034F2877D35B",
|
||||||
|
],
|
||||||
|
"AlbumInFolder": [
|
||||||
|
"3DD2C897-F19E-4CA6-8C22-B027D5A71907",
|
||||||
|
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID_DICT = {
|
||||||
|
"two_albums": "F12384F6-CD17-4151-ACBA-AE0E3688539E",
|
||||||
|
"in_album": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
|
||||||
|
"xmp": "F12384F6-CD17-4151-ACBA-AE0E3688539E",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_exiftool_json_sidecar_keyword_template_long(caplog):
|
||||||
|
import osxphotos
|
||||||
|
from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN
|
||||||
|
import json
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["in_album"]])
|
||||||
|
|
||||||
|
json_expected = json.loads(
|
||||||
|
"""
|
||||||
|
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
||||||
|
"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
|
"XMP:Description": "Bride Wedding day",
|
||||||
|
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||||
|
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||||
|
"XMP:PersonInImage": ["Maria"],
|
||||||
|
"XMP:Subject": ["wedding", "Maria"],
|
||||||
|
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||||
|
"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:11:24 13:09:17"}]
|
||||||
|
"""
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1)
|
||||||
|
json_got = photos[0]._exiftool_json_sidecar(keyword_template=[long_str])
|
||||||
|
json_got = json.loads(json_got)[0]
|
||||||
|
|
||||||
|
assert "Some keywords exceed max IPTC Keyword length" in caplog.text
|
||||||
|
# some gymnastics to account for different sort order in different pythons
|
||||||
|
for k, v in json_got.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_expected[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_expected[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|
||||||
|
|
||||||
|
def test_exiftool_json_sidecar_keyword_template():
|
||||||
|
import osxphotos
|
||||||
|
import json
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["in_album"]])
|
||||||
|
|
||||||
|
json_expected = json.loads(
|
||||||
|
"""
|
||||||
|
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
||||||
|
"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
|
"XMP:Description": "Bride Wedding day",
|
||||||
|
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder"],
|
||||||
|
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder"],
|
||||||
|
"XMP:PersonInImage": ["Maria"],
|
||||||
|
"XMP:Subject": ["wedding", "Maria"],
|
||||||
|
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||||
|
"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:11:24 13:09:17"}]
|
||||||
|
"""
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
json_got = photos[0]._exiftool_json_sidecar(keyword_template=["{folder_album}"])
|
||||||
|
json_got = json.loads(json_got)[0]
|
||||||
|
|
||||||
|
# some gymnastics to account for different sort order in different pythons
|
||||||
|
for k, v in json_got.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_expected[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_expected[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|
||||||
|
# some gymnastics to account for different sort order in different pythons
|
||||||
|
for k, v in json_got.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_expected[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_expected[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|
||||||
|
for k, v in json_expected.items():
|
||||||
|
if type(v) in (list, tuple):
|
||||||
|
assert sorted(json_got[k]) == sorted(v)
|
||||||
|
else:
|
||||||
|
assert json_got[k] == v
|
||||||
|
|
||||||
|
|
||||||
|
def test_xmp_sidecar_keyword_template():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||||
|
|
||||||
|
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
|
||||||
|
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about=""
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||||
|
<dc:description>Girls with pumpkins</dc:description>
|
||||||
|
<dc:title>Can we carry this?</dc:title>
|
||||||
|
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||||
|
<dc:subject>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</dc:subject>
|
||||||
|
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||||
|
<Iptc4xmpExt:PersonInImage>
|
||||||
|
<rdf:Bag>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Bag>
|
||||||
|
</Iptc4xmpExt:PersonInImage>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||||
|
<digiKam:TagsList>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Pumpkin Farm</rdf:li>
|
||||||
|
<rdf:li>Test Album</rdf:li>
|
||||||
|
<rdf:li>2018</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</digiKam:TagsList>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||||
|
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||||
|
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>"""
|
||||||
|
|
||||||
|
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||||
|
|
||||||
|
xmp_got = photos[0]._xmp_sidecar(
|
||||||
|
keyword_template=["{created.year}", "{folder_album}"]
|
||||||
|
)
|
||||||
|
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
|
||||||
|
|
||||||
|
for line_expected, line_got in zip(
|
||||||
|
sorted(xmp_expected_lines), sorted(xmp_got_lines)
|
||||||
|
):
|
||||||
|
assert line_expected == line_got
|
||||||
@@ -454,7 +454,7 @@ def test_xmp_sidecar():
|
|||||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||||
</rdf:Description>
|
</rdf:Description>
|
||||||
</rdf:RDF>
|
</rdf:RDF>
|
||||||
</x:xmpmeta>"""
|
</x:xmpmeta>"""
|
||||||
|
|
||||||
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||||
|
|
||||||
@@ -464,3 +464,68 @@ def test_xmp_sidecar():
|
|||||||
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
|
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
|
||||||
assert line_expected == line_got
|
assert line_expected == line_got
|
||||||
|
|
||||||
|
|
||||||
|
def test_xmp_sidecar_keyword_template():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||||
|
|
||||||
|
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
|
||||||
|
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about=""
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||||
|
<dc:description>Girls with pumpkins</dc:description>
|
||||||
|
<dc:title>Can we carry this?</dc:title>
|
||||||
|
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||||
|
<dc:subject>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</dc:subject>
|
||||||
|
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||||
|
<Iptc4xmpExt:PersonInImage>
|
||||||
|
<rdf:Bag>
|
||||||
|
<rdf:li>Suzy</rdf:li>
|
||||||
|
<rdf:li>Katie</rdf:li>
|
||||||
|
</rdf:Bag>
|
||||||
|
</Iptc4xmpExt:PersonInImage>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||||
|
<digiKam:TagsList>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Kids</rdf:li>
|
||||||
|
<rdf:li>Test Album</rdf:li>
|
||||||
|
<rdf:li>Pumpkin Farm</rdf:li>
|
||||||
|
<rdf:li>2018</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</digiKam:TagsList>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about=''
|
||||||
|
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||||
|
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||||
|
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>"""
|
||||||
|
|
||||||
|
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||||
|
|
||||||
|
xmp_got = photos[0]._xmp_sidecar(
|
||||||
|
keyword_template=["{folder_album}", "{created.year}"]
|
||||||
|
)
|
||||||
|
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
|
||||||
|
|
||||||
|
for line_expected, line_got in zip(
|
||||||
|
sorted(xmp_expected_lines), sorted(xmp_got_lines)
|
||||||
|
):
|
||||||
|
assert line_expected == line_got
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ KEYWORDS = [
|
|||||||
"United Kingdom",
|
"United Kingdom",
|
||||||
]
|
]
|
||||||
PERSONS = ["Katie", "Suzy", "Maria"]
|
PERSONS = ["Katie", "Suzy", "Maria"]
|
||||||
ALBUMS = ["Pumpkin Farm"]
|
ALBUMS = ["Pumpkin Farm", "AlbumInFolder", "TestAlbum"]
|
||||||
KEYWORDS_DICT = {
|
KEYWORDS_DICT = {
|
||||||
"Kids": 4,
|
"Kids": 4,
|
||||||
"wedding": 2,
|
"wedding": 2,
|
||||||
@@ -28,7 +28,7 @@ KEYWORDS_DICT = {
|
|||||||
"United Kingdom": 1,
|
"United Kingdom": 1,
|
||||||
}
|
}
|
||||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
|
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
|
||||||
ALBUM_DICT = {"Pumpkin Farm": 3}
|
ALBUM_DICT = {"Pumpkin Farm": 3, "TestAlbum": 1, "AlbumInFolder": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_init():
|
def test_init():
|
||||||
@@ -123,7 +123,7 @@ def test_attributes():
|
|||||||
)
|
)
|
||||||
assert p.description == "Girl holding pumpkin"
|
assert p.description == "Girl holding pumpkin"
|
||||||
assert p.title == "I found one!"
|
assert p.title == "I found one!"
|
||||||
assert p.albums == ["Pumpkin Farm"]
|
assert sorted(p.albums) == ["AlbumInFolder", "Pumpkin Farm"]
|
||||||
assert p.persons == ["Katie"]
|
assert p.persons == ["Katie"]
|
||||||
assert p.path.endswith(
|
assert p.path.endswith(
|
||||||
"/tests/Test-10.13.6.photoslibrary/Masters/2019/07/26/20190726-203227/Pumkins2.jpg"
|
"/tests/Test-10.13.6.photoslibrary/Masters/2019/07/26/20190726-203227/Pumkins2.jpg"
|
||||||
|
|||||||
@@ -53,22 +53,53 @@ TEMPLATE_VALUES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE_VALUES_DEU = {
|
||||||
|
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
|
||||||
|
"{original_name}": "IMG_1064",
|
||||||
|
"{title}": "Glen Ord",
|
||||||
|
"{descr}": "Jack Rose Dining Saloon",
|
||||||
|
"{created.date}": "2020-02-04",
|
||||||
|
"{created.year}": "2020",
|
||||||
|
"{created.yy}": "20",
|
||||||
|
"{created.mm}": "02",
|
||||||
|
"{created.month}": "Februar",
|
||||||
|
"{created.mon}": "Feb",
|
||||||
|
"{created.doy}": "035",
|
||||||
|
"{modified.date}": "2020-03-21",
|
||||||
|
"{modified.year}": "2020",
|
||||||
|
"{modified.yy}": "20",
|
||||||
|
"{modified.mm}": "03",
|
||||||
|
"{modified.month}": "März",
|
||||||
|
"{modified.mon}": "Mär",
|
||||||
|
"{modified.doy}": "081",
|
||||||
|
"{place.name}": "Washington, District of Columbia, United States",
|
||||||
|
"{place.country_code}": "US",
|
||||||
|
"{place.name.country}": "United States",
|
||||||
|
"{place.name.state_province}": "District of Columbia",
|
||||||
|
"{place.name.city}": "Washington",
|
||||||
|
"{place.name.area_of_interest}": "_",
|
||||||
|
"{place.address}": "2038 18th St NW, Washington, DC 20009, United States",
|
||||||
|
"{place.address.street}": "2038 18th St NW",
|
||||||
|
"{place.address.city}": "Washington",
|
||||||
|
"{place.address.state_province}": "DC",
|
||||||
|
"{place.address.postal_code}": "20009",
|
||||||
|
"{place.address.country}": "United States",
|
||||||
|
"{place.address.country_code}": "US",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_lookup():
|
def test_lookup():
|
||||||
""" Test that a lookup is returned for every possible value """
|
""" Test that a lookup is returned for every possible value """
|
||||||
import re
|
import re
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import (
|
from osxphotos.template import TEMPLATE_SUBSTITUTIONS
|
||||||
get_template_value,
|
|
||||||
render_filepath_template,
|
|
||||||
TEMPLATE_SUBSTITUTIONS,
|
|
||||||
)
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
for subst in TEMPLATE_SUBSTITUTIONS:
|
for subst in TEMPLATE_SUBSTITUTIONS:
|
||||||
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
|
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
|
||||||
lookup = get_template_value(lookup_str, photo)
|
lookup = photo.get_template_value(lookup_str)
|
||||||
assert lookup or lookup is None
|
assert lookup or lookup is None
|
||||||
|
|
||||||
|
|
||||||
@@ -76,29 +107,67 @@ def test_subst():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
import locale
|
import locale
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, "en_US")
|
locale.setlocale(locale.LC_ALL, "en_US")
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
for template in TEMPLATE_VALUES:
|
for template in TEMPLATE_VALUES:
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert rendered[0] == TEMPLATE_VALUES[template]
|
assert rendered[0] == TEMPLATE_VALUES[template]
|
||||||
|
|
||||||
|
|
||||||
|
def test_subst_locale_1():
|
||||||
|
""" Test that substitutions are correct in user locale"""
|
||||||
|
import locale
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
# osxphotos.template sets local on load so set the environment first
|
||||||
|
# set locale to DE
|
||||||
|
locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
|
for template in TEMPLATE_VALUES_DEU:
|
||||||
|
rendered, _ = photo.render_template(template)
|
||||||
|
assert rendered[0] == TEMPLATE_VALUES_DEU[template]
|
||||||
|
|
||||||
|
|
||||||
|
def test_subst_locale_2():
|
||||||
|
""" Test that substitutions are correct in user locale"""
|
||||||
|
import locale
|
||||||
|
import os
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
# osxphotos.template sets local on load so set the environment first
|
||||||
|
os.environ["LANG"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_COLLATE"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_CTYPE"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_MESSAGES"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_MONETARY"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_NUMERIC"] = "de_DE.UTF-8"
|
||||||
|
os.environ["LC_TIME"] = "de_DE.UTF-8"
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
|
for template in TEMPLATE_VALUES_DEU:
|
||||||
|
rendered, _ = photo.render_template(template)
|
||||||
|
assert rendered[0] == TEMPLATE_VALUES_DEU[template]
|
||||||
|
|
||||||
|
|
||||||
def test_subst_default_val():
|
def test_subst_default_val():
|
||||||
""" Test substitution with default value specified """
|
""" Test substitution with default value specified """
|
||||||
import locale
|
import locale
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, "en_US")
|
locale.setlocale(locale.LC_ALL, "en_US")
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
template = "{place.name.area_of_interest,UNKNOWN}"
|
template = "{place.name.area_of_interest,UNKNOWN}"
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert rendered[0] == "UNKNOWN"
|
assert rendered[0] == "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
@@ -106,14 +175,13 @@ def test_subst_default_val_2():
|
|||||||
""" Test substitution with ',' but no default value """
|
""" Test substitution with ',' but no default value """
|
||||||
import locale
|
import locale
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, "en_US")
|
locale.setlocale(locale.LC_ALL, "en_US")
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
template = "{place.name.area_of_interest,}"
|
template = "{place.name.area_of_interest,}"
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert rendered[0] == "_"
|
assert rendered[0] == "_"
|
||||||
|
|
||||||
|
|
||||||
@@ -121,32 +189,30 @@ def test_subst_unknown_val():
|
|||||||
""" Test substitution with unknown value specified """
|
""" Test substitution with unknown value specified """
|
||||||
import locale
|
import locale
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, "en_US")
|
locale.setlocale(locale.LC_ALL, "en_US")
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
template = "{created.year}/{foo}"
|
template = "{created.year}/{foo}"
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert rendered[0] == "2020/{foo}"
|
assert rendered[0] == "2020/{foo}"
|
||||||
assert unknown == ["foo"]
|
assert unknown == ["foo"]
|
||||||
|
|
||||||
template = "{place.name.area_of_interest,}"
|
template = "{place.name.area_of_interest,}"
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert rendered[0] == "_"
|
assert rendered[0] == "_"
|
||||||
|
|
||||||
|
|
||||||
def test_subst_double_brace():
|
def test_subst_double_brace():
|
||||||
""" Test substitution with double brace {{ which should be ignored """
|
""" Test substitution with double brace {{ which should be ignored """
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
template = "{created.year}/{{foo}}"
|
template = "{created.year}/{{foo}}"
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert rendered[0] == "2020/{foo}"
|
assert rendered[0] == "2020/{foo}"
|
||||||
assert not unknown
|
assert not unknown
|
||||||
|
|
||||||
@@ -155,14 +221,13 @@ def test_subst_unknown_val_with_default():
|
|||||||
""" Test substitution with unknown value specified """
|
""" Test substitution with unknown value specified """
|
||||||
import locale
|
import locale
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, "en_US")
|
locale.setlocale(locale.LC_ALL, "en_US")
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
template = "{created.year}/{foo,bar}"
|
template = "{created.year}/{foo,bar}"
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert rendered[0] == "2020/{foo,bar}"
|
assert rendered[0] == "2020/{foo,bar}"
|
||||||
assert unknown == ["foo"]
|
assert unknown == ["foo"]
|
||||||
|
|
||||||
@@ -171,14 +236,13 @@ def test_subst_multi_1_1_2():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||||
|
|
||||||
template = "{created.year}/{album}/{keyword}/{person}"
|
template = "{created.year}/{album}/{keyword}/{person}"
|
||||||
expected = ["2018/Pumpkin Farm/Kids/Katie", "2018/Pumpkin Farm/Kids/Suzy"]
|
expected = ["2018/Pumpkin Farm/Kids/Katie", "2018/Pumpkin Farm/Kids/Suzy"]
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +250,6 @@ def test_subst_multi_2_1_1():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# 2 albums, 1 keyword, 1 person
|
# 2 albums, 1 keyword, 1 person
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
@@ -198,7 +261,7 @@ def test_subst_multi_2_1_1():
|
|||||||
"2018/Test Album/Kids/Katie",
|
"2018/Test Album/Kids/Katie",
|
||||||
"2018/Multi Keyword/Kids/Katie",
|
"2018/Multi Keyword/Kids/Katie",
|
||||||
]
|
]
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +269,6 @@ def test_subst_multi_2_1_1_single():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# 2 albums, 1 keyword, 1 person but only do keywords
|
# 2 albums, 1 keyword, 1 person but only do keywords
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
@@ -214,7 +276,7 @@ def test_subst_multi_2_1_1_single():
|
|||||||
|
|
||||||
template = "{keyword}"
|
template = "{keyword}"
|
||||||
expected = ["Kids"]
|
expected = ["Kids"]
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
@@ -222,7 +284,6 @@ def test_subst_multi_0_2_0():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# 0 albums, 2 keywords, 0 persons
|
# 0 albums, 2 keywords, 0 persons
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
@@ -230,7 +291,7 @@ def test_subst_multi_0_2_0():
|
|||||||
|
|
||||||
template = "{created.year}/{album}/{keyword}/{person}"
|
template = "{created.year}/{album}/{keyword}/{person}"
|
||||||
expected = ["2019/_/wedding/_", "2019/_/flowers/_"]
|
expected = ["2019/_/wedding/_", "2019/_/flowers/_"]
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
@@ -238,7 +299,6 @@ def test_subst_multi_0_2_0_single():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# 0 albums, 2 keywords, 0 persons, but only do albums
|
# 0 albums, 2 keywords, 0 persons, but only do albums
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
@@ -246,7 +306,7 @@ def test_subst_multi_0_2_0_single():
|
|||||||
|
|
||||||
template = "{created.year}/{album}"
|
template = "{created.year}/{album}"
|
||||||
expected = ["2019/_"]
|
expected = ["2019/_"]
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
@@ -254,7 +314,6 @@ def test_subst_multi_0_2_0_default_val():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# 0 albums, 2 keywords, 0 persons, default vals provided
|
# 0 albums, 2 keywords, 0 persons, default vals provided
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
@@ -262,7 +321,7 @@ def test_subst_multi_0_2_0_default_val():
|
|||||||
|
|
||||||
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person,NOPERSON}"
|
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person,NOPERSON}"
|
||||||
expected = ["2019/NOALBUM/wedding/NOPERSON", "2019/NOALBUM/flowers/NOPERSON"]
|
expected = ["2019/NOALBUM/wedding/NOPERSON", "2019/NOALBUM/flowers/NOPERSON"]
|
||||||
rendered, _ = render_filepath_template(template, photo)
|
rendered, _ = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
@@ -270,7 +329,6 @@ def test_subst_multi_0_2_0_default_val_unknown_val():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
|
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
@@ -283,7 +341,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val():
|
|||||||
"2019/NOALBUM/wedding/_/{foo}/{baz}",
|
"2019/NOALBUM/wedding/_/{foo}/{baz}",
|
||||||
"2019/NOALBUM/flowers/_/{foo}/{baz}",
|
"2019/NOALBUM/flowers/_/{foo}/{baz}",
|
||||||
]
|
]
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
assert unknown == ["foo"]
|
assert unknown == ["foo"]
|
||||||
|
|
||||||
@@ -292,7 +350,6 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2():
|
|||||||
""" Test that substitutions are correct """
|
""" Test that substitutions are correct """
|
||||||
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
|
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||||
# one album, one keyword, two persons
|
# one album, one keyword, two persons
|
||||||
@@ -303,7 +360,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2():
|
|||||||
"2019/NOALBUM/wedding/_/{foo,bar}/{baz,bar}",
|
"2019/NOALBUM/wedding/_/{foo,bar}/{baz,bar}",
|
||||||
"2019/NOALBUM/flowers/_/{foo,bar}/{baz,bar}",
|
"2019/NOALBUM/flowers/_/{foo,bar}/{baz,bar}",
|
||||||
]
|
]
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
assert unknown == ["foo"]
|
assert unknown == ["foo"]
|
||||||
|
|
||||||
@@ -311,7 +368,6 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2():
|
|||||||
def test_subst_multi_folder_albums_1():
|
def test_subst_multi_folder_albums_1():
|
||||||
""" Test substitutions for folder_album are correct """
|
""" Test substitutions for folder_album are correct """
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
|
||||||
|
|
||||||
@@ -319,7 +375,7 @@ def test_subst_multi_folder_albums_1():
|
|||||||
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0]
|
||||||
template = "{folder_album}"
|
template = "{folder_album}"
|
||||||
expected = ["Folder1/SubFolder2/AlbumInFolder"]
|
expected = ["Folder1/SubFolder2/AlbumInFolder"]
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
assert unknown == []
|
assert unknown == []
|
||||||
|
|
||||||
@@ -327,7 +383,6 @@ def test_subst_multi_folder_albums_1():
|
|||||||
def test_subst_multi_folder_albums_2():
|
def test_subst_multi_folder_albums_2():
|
||||||
""" Test substitutions for folder_album are correct """
|
""" Test substitutions for folder_album are correct """
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
|
||||||
|
|
||||||
@@ -335,15 +390,14 @@ def test_subst_multi_folder_albums_2():
|
|||||||
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_no_folder"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_no_folder"]])[0]
|
||||||
template = "{folder_album}"
|
template = "{folder_album}"
|
||||||
expected = ["Pumpkin Farm", "Test Album"]
|
expected = ["Pumpkin Farm", "Test Album"]
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
assert unknown == []
|
assert unknown == []
|
||||||
|
|
||||||
|
|
||||||
def test_subst_multi_folder_albums_3(caplog):
|
def test_subst_multi_folder_albums_3():
|
||||||
""" Test substitutions for folder_album on < Photos 5 """
|
""" Test substitutions for folder_album on < Photos 5 """
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.template import render_filepath_template
|
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_14_6)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_14_6)
|
||||||
|
|
||||||
@@ -351,6 +405,6 @@ def test_subst_multi_folder_albums_3(caplog):
|
|||||||
photo = photosdb.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
|
photo = photosdb.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
|
||||||
template = "{folder_album}"
|
template = "{folder_album}"
|
||||||
expected = ["Folder1/SubFolder2/AlbumInFolder", "Pumpkin Farm", "Test Album (1)"]
|
expected = ["Folder1/SubFolder2/AlbumInFolder", "Pumpkin Farm", "Test Album (1)"]
|
||||||
rendered, unknown = render_filepath_template(template, photo)
|
rendered, unknown = photo.render_template(template)
|
||||||
assert sorted(rendered) == sorted(expected)
|
assert sorted(rendered) == sorted(expected)
|
||||||
assert unknown == []
|
assert unknown == []
|
||||||
|
|||||||
Reference in New Issue
Block a user