Compare commits

..

35 Commits

Author SHA1 Message Date
Rhet Turnbull
25eacc7cad Added --missing to export, see issue #277 2020-11-29 15:30:45 -08:00
Rhet Turnbull
d9dcf0917a Catch errors in export_photo 2020-11-28 20:00:10 -08:00
Rhet Turnbull
4f36c7c948 Updated CHANGELOG.md 2020-11-28 09:27:12 -08:00
Rhet Turnbull
d22eaf39ed Added --report option to CLI, implements #253 2020-11-28 09:24:16 -08:00
Rhet Turnbull
adf2ba7678 Updated CHANGELOG.md 2020-11-27 17:00:53 -08:00
Rhet Turnbull
af827d7a57 Updated template values 2020-11-27 16:58:11 -08:00
Rhet Turnbull
48acb42631 Added {exiftool} template, implements issue #259 2020-11-27 16:43:48 -08:00
Rhet Turnbull
eba661acf7 Updated CHANGELOG.md 2020-11-26 19:53:35 -08:00
Rhet Turnbull
399d432a66 Added --original-suffix for issue #263 2020-11-26 18:36:17 -08:00
Rhet Turnbull
4cebc57d60 Updated CHANGELOG.md 2020-11-26 15:26:54 -08:00
Rhet Turnbull
489fea56e9 Added tests for issue #265 2020-11-26 13:21:40 -08:00
Rhet Turnbull
0632a97f55 Simplified sidecar table in export_db 2020-11-26 10:42:10 -08:00
Rhet Turnbull
d5a9f76719 More work on issue #265 2020-11-26 10:15:09 -08:00
Rhet Turnbull
382fca3f92 Initial implementation for issue #265 2020-11-26 09:08:26 -08:00
Rhet Turnbull
a807894095 Removed debug code from _photoinfo_export.py 2020-11-25 21:42:27 -08:00
Rhet Turnbull
559350f71d Updated CHANGELOG.md 2020-11-25 20:55:15 -08:00
Rhet Turnbull
b5195f9d2b version bump 2020-11-25 20:32:36 -08:00
Rhet Turnbull
fa332186ab Fix for missing original_filename, issue #267 2020-11-25 20:31:19 -08:00
Rhet Turnbull
aa2ebf55bb Updated test 2020-11-25 19:04:36 -08:00
Rhet Turnbull
d1fbb9fe86 Updated CHANGELOG.md 2020-11-25 18:58:48 -08:00
Rhet Turnbull
116cb662fb Added test for missing original_filename 2020-11-25 18:32:12 -08:00
Rhet Turnbull
db68defc44 version bump 2020-11-25 17:55:07 -08:00
Rhet Turnbull
7460bc88fc Add @jstrine as a contributor 2020-11-25 17:54:53 -08:00
Rhet Turnbull
dbbbbf10a8 Merge pull request #272 from jstrine/fix_xml_escaping
Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix!
2020-11-25 17:52:22 -08:00
Rhet Turnbull
0633814ab2 Merge pull request #270 from jstrine/fix_gps_xmp
Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix!
2020-11-25 17:51:57 -08:00
Rhet Turnbull
df7d45659a Merge pull request #268 from jstrine/fix_path_none
Continue even if the original filename is None, thanks to @jstrine for the fix!
2020-11-25 17:51:38 -08:00
Jonathan Strine
cec266bba4 Fix tests again
Third times the charm to fix a find-replace error this time.
2020-11-25 19:51:09 -05:00
Jonathan Strine
d0d2e80800 Fix tests for apostrophe
Previous commit didn't reflect all locations and had a copy/paste error.
2020-11-25 19:45:21 -05:00
Jonathan Strine
aafdbea564 Fix test to accomodate for escaped apostrophe 2020-11-25 19:36:09 -05:00
Jonathan Strine
c42050a10c Escape characters which cause XML parsing issues 2020-11-25 19:31:51 -05:00
Jonathan Strine
c27cfb1223 Fix test for XMP sidecar with GPS info 2020-11-25 19:24:56 -05:00
Jonathan Strine
ad144da8a0 Use GPSCoordinate format for geolocation 2020-11-25 18:09:38 -05:00
Jonathan Strine
5352aec3b9 Continue even if the original filename is None
Some photos seemed to be missing the original_filename (returning None).
This fix prevents the traceback.
2020-11-25 17:00:22 -05:00
Rhet Turnbull
e951e5361e Exposed --use-photos-export and --use-photokit 2020-11-25 09:15:16 -08:00
Rhet Turnbull
f7bd1376e1 Updated CHANGELOG.md 2020-11-24 06:50:52 -08:00
16 changed files with 1490 additions and 381 deletions

View File

@@ -100,6 +100,15 @@
"contributions": [
"code"
]
},
{
"login": "jstrine",
"name": "Jonathan Strine",
"avatar_url": "https://avatars1.githubusercontent.com/u/33943447?v=4",
"profile": "https://github.com/jstrine",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7

View File

@@ -4,6 +4,80 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
> 28 November 2020
- Added --report option to CLI, implements #253 [`d22eaf3`](https://github.com/RhetTbull/osxphotos/commit/d22eaf39edc8b0b489b011d6d21345dcedcc8dff)
- Updated template values [`af827d7`](https://github.com/RhetTbull/osxphotos/commit/af827d7a5769f41579d300a7cc511251d86b7eed)
#### [v0.37.0](https://github.com/RhetTbull/osxphotos/compare/v0.36.25...v0.37.0)
> 28 November 2020
- Added {exiftool} template, implements issue #259 [`48acb42`](https://github.com/RhetTbull/osxphotos/commit/48acb42631226a71bfc636eea2d3151f1b7165f4)
#### [v0.36.25](https://github.com/RhetTbull/osxphotos/compare/v0.36.24...v0.36.25)
> 27 November 2020
- Added --original-suffix for issue #263 [`399d432`](https://github.com/RhetTbull/osxphotos/commit/399d432a66354b9c235f30d10c6985fbde1b7e4f)
#### [v0.36.24](https://github.com/RhetTbull/osxphotos/compare/v0.36.23...v0.36.24)
> 26 November 2020
- Initial implementation for issue #265 [`382fca3`](https://github.com/RhetTbull/osxphotos/commit/382fca3f92a3c251c12426dd0dc6d7dc21b691cf)
- More work on issue #265 [`d5a9f76`](https://github.com/RhetTbull/osxphotos/commit/d5a9f767199d25ebd9d5925d05ee39ea7e51ac26)
- Simplified sidecar table in export_db [`0632a97`](https://github.com/RhetTbull/osxphotos/commit/0632a97f55af67c7e5265b0d3283155c7c087e89)
#### [v0.36.23](https://github.com/RhetTbull/osxphotos/compare/v0.36.22...v0.36.23)
> 26 November 2020
- Fix for missing original_filename, issue #267 [`fa33218`](https://github.com/RhetTbull/osxphotos/commit/fa332186ab3cdbe1bfd6496ff29b652ef984a5f8)
- version bump [`b5195f9`](https://github.com/RhetTbull/osxphotos/commit/b5195f9d2b81cf6737b65e3cd3793ea9b0da13eb)
- Updated test [`aa2ebf5`](https://github.com/RhetTbull/osxphotos/commit/aa2ebf55bb50eec14f86a532334b376e407f4bbc)
#### [v0.36.22](https://github.com/RhetTbull/osxphotos/compare/v0.36.21...v0.36.22)
> 26 November 2020
- Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix! [`#272`](https://github.com/RhetTbull/osxphotos/pull/272)
- Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix! [`#270`](https://github.com/RhetTbull/osxphotos/pull/270)
- Continue even if the original filename is None, thanks to @jstrine for the fix! [`#268`](https://github.com/RhetTbull/osxphotos/pull/268)
- Added test for missing original_filename [`116cb66`](https://github.com/RhetTbull/osxphotos/commit/116cb662fbddf9153f6858c6ea97dc7f65c77705)
- Add @jstrine as a contributor [`7460bc8`](https://github.com/RhetTbull/osxphotos/commit/7460bc88fcc5e1e7435c9b9bcdf7ec9c7c5e39ea)
- Escape characters which cause XML parsing issues [`c42050a`](https://github.com/RhetTbull/osxphotos/commit/c42050a10cac40b0b5ac70c587e07f257a9b50dd)
- Fix tests for apostrophe [`d0d2e80`](https://github.com/RhetTbull/osxphotos/commit/d0d2e8080096bf66f93a830386800ce713680c51)
- Fix test for XMP sidecar with GPS info [`c27cfb1`](https://github.com/RhetTbull/osxphotos/commit/c27cfb1223fa82b9e5549b93c283e9444693270a)
#### [v0.36.21](https://github.com/RhetTbull/osxphotos/compare/v0.36.20...v0.36.21)
> 25 November 2020
- Exposed --use-photos-export and --use-photokit [`e951e53`](https://github.com/RhetTbull/osxphotos/commit/e951e5361e59060229787bb1ea3fc4e088ffff99)
#### [v0.36.20](https://github.com/RhetTbull/osxphotos/compare/v0.36.19...v0.36.20)
> 23 November 2020
- Added photokit export as hidden --use-photokit option [`26f96d5`](https://github.com/RhetTbull/osxphotos/commit/26f96d582c01ce9816b1f54f0e74c8570f133f7c)
#### [v0.36.19](https://github.com/RhetTbull/osxphotos/compare/v0.36.18...v0.36.19)
> 19 November 2020
- Removed debug statement in _photoinfo_export [`8cb15d1`](https://github.com/RhetTbull/osxphotos/commit/8cb15d15551094dcaf1b0ef32d6ac0273be7fd37)
#### [v0.36.18](https://github.com/RhetTbull/osxphotos/compare/v0.36.17...v0.36.18)
> 14 November 2020
- Moved AppleScript to photoscript [`3c85f26`](https://github.com/RhetTbull/osxphotos/commit/3c85f26f901645ce297685ccd639792757fbc995)
- Fixed missing data file for photoscript [`2d9429c`](https://github.com/RhetTbull/osxphotos/commit/2d9429c8eefabe6233fc580f65511c48ee6c01e5)
- Version bump, updated requirements [`3b6dd08`](https://github.com/RhetTbull/osxphotos/commit/3b6dd08d2bb2b20a55064bf24fe7ce788e7268ef)
#### [v0.36.17](https://github.com/RhetTbull/osxphotos/compare/v0.36.15...v0.36.17)
> 12 November 2020

View File

@@ -3,7 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos)
@@ -227,6 +227,9 @@ Options:
--no-comment Search for photos with no comments.
--has-likes Search for photos that have likes.
--no-likes Search for photos with no likes.
--missing Export only photos missing from the Photos
library; must be used with --download-
missing.
--deleted Include photos from the 'Recently Deleted'
folder.
--deleted-only Include only photos from the 'Recently
@@ -234,7 +237,8 @@ Options:
--update Only export new or updated files. See notes
below on export and --update.
--dry-run Dry run (test) the export but don't actually
export any files; most useful with --verbose
export any files; most useful with
--verbose.
--export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates
copies of the files with embedded EXIF data.
@@ -351,11 +355,30 @@ Options:
photo would be named
'photoname_bearbeiten.ext'. The default
suffix is '_edited'.
--original-suffix SUFFIX Optional suffix for naming original photos.
Default name for original photos is in form
'filename.ext'. For example, with '--
original-suffix _original', the original
photo would be named
'filename_original.ext'. The default suffix
is '' (no suffix).
--no-extended-attributes Don't copy extended attributes when
exporting. You only need this if exporting
to a filesystem that doesn't support Mac OS
extended attributes. Only use this if you
get an error while exporting.
--use-photos-export Force the use of AppleScript or PhotoKit to
export even if not missing (see also '--
download-missing' and '--use-photokit').
--use-photokit Use with '--download-missing' or '--use-
photos-export' to use direct Photos
interface instead of AppleScript to export.
Highly experimental alpha feature; does not
work with iTerm2 (use with Terminal.app).
This is faster and more reliable than the
default AppleScript interface.
--report REPORTNAME.CSV Write a CSV formatted report of all files
that were exported.
-h, --help Show this message and exit.
** Export **
@@ -619,18 +642,26 @@ exported, one to each directory. For example: --directory
of the following directories if the photos were created in 2019 and were in
albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family
Substitution Description
{album} Album(s) photo is contained in
{folder_album} Folder path + album photo is contained in. e.g.
'Folder/Subfolder/Album' or just 'Album' if no enclosing
folder
{keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo
(Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only)
{comment} Comment(s) on shared Photos; format is 'Person name:
comment text' (Photos 5 only)
Substitution Description
{album} Album(s) photo is contained in
{folder_album} Folder path + album photo is contained in. e.g.
'Folder/Subfolder/Album' or just 'Album' if no
enclosing folder
{keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo
(Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only)
{comment} Comment(s) on shared Photos; format is 'Person
name: comment text' (Photos 5 only)
{exiftool:GROUP:TAGNAME} Use exiftool (https://exiftool.org) to extract
metadata, in form GROUP:TAGNAME, from image. E.g.
'{exiftool:EXIF:Make}' to get camera make, or
{exiftool:IPTC:Keywords} to extract keywords. See
https://exiftool.org/TagNames/ for list of valid
tag names. You must specify group (e.g. EXIF,
IPTC, etc) as used in `exiftool -G`. exiftool must
be installed in the path to use this template.
```
Example: export all photos to ~/Desktop/export group in folders by date created
@@ -2028,6 +2059,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{label}|Image categorization label associated with a photo (Photos 5 only)|
|{label_normalized}|All lower case version of 'label' (Photos 5 only)|
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)|
|{exiftool:GROUP:TAGNAME}|Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
### Utility Functions
@@ -2149,6 +2181,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jonathan Strine</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jstrine" title="Code">💻</a></td>
</tr>
</table>

View File

@@ -28,6 +28,7 @@ from .export_db import ExportDB, ExportDBInMemory
from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
# global variable to control verbose output
@@ -97,7 +98,7 @@ class DateTimeISO8601(click.ParamType):
def convert(self, value, param, ctx):
try:
return datetime.datetime.fromisoformat(value)
except:
except Exception:
self.fail(
f"Invalid value for --{param.name}: invalid datetime format {value}. "
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
@@ -650,7 +651,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_):
val = getattr(photosdb, attr)
print(f"{attr}:")
pprint.pprint(val)
except:
except Exception:
print(f"Did not find attribute {attr} in PhotosDB")
@@ -845,12 +846,12 @@ def places(ctx, cli_obj, db, json_, photos_library):
if photo.place:
try:
place_names[photo.place.name] += 1
except:
except Exception:
place_names[photo.place.name] = 1
else:
try:
place_names[_UNKNOWN_PLACE] += 1
except:
except Exception:
place_names[_UNKNOWN_PLACE] = 1
# sort by place count
@@ -1198,6 +1199,11 @@ def query(
@DB_OPTION
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
@query_options
@click.option(
"--missing",
is_flag=True,
help="Export only photos missing from the Photos library; must be used with --download-missing.",
)
@deleted_options
@click.option(
"--update",
@@ -1207,7 +1213,7 @@ def query(
@click.option(
"--dry-run",
is_flag=True,
help="Dry run (test) the export but don't actually export any files; most useful with --verbose",
help="Dry run (test) the export but don't actually export any files; most useful with --verbose.",
)
@click.option(
"--export-as-hardlink",
@@ -1382,6 +1388,14 @@ def query(
"'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo "
"would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.",
)
@click.option(
"--original-suffix",
metavar="SUFFIX",
default="",
help="Optional suffix for naming original photos. Default name for original photos is in form "
"'filename.ext'. For example, with '--original-suffix _original', the original photo "
"would be named 'filename_original.ext'. The default suffix is '' (no suffix).",
)
@click.option(
"--no-extended-attributes",
is_flag=True,
@@ -1394,15 +1408,21 @@ def query(
"--use-photos-export",
is_flag=True,
default=False,
hidden=True,
help="Force the use of AppleScript to export even if not missing (see also --download-missing).",
help="Force the use of AppleScript or PhotoKit to export even if not missing (see also '--download-missing' and '--use-photokit').",
)
@click.option(
"--use-photokit",
is_flag=True,
default=False,
hidden=True,
help="Use PhotoKit interface instead of AppleScript to export. Highly experimental alpha feature.",
help="Use with '--download-missing' or '--use-photos-export' to use direct Photos interface instead of AppleScript to export. "
"Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
"This is faster and more reliable than the default AppleScript interface.",
)
@click.option(
"--report",
metavar="REPORTNAME.CSV",
help="Write a CSV formatted report of all files that were exported.",
type=click.Path(),
)
@DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True))
@@ -1436,6 +1456,7 @@ def export(
from_date,
to_date,
verbose_,
missing,
update,
dry_run,
export_as_hardlink,
@@ -1483,6 +1504,7 @@ def export(
directory,
filename_template,
edited_suffix,
original_suffix,
place,
no_place,
has_comment,
@@ -1495,6 +1517,7 @@ def export(
deleted_only,
use_photos_export,
use_photokit,
report,
):
""" Export photos from the Photos database.
Export path DEST is required.
@@ -1512,7 +1535,12 @@ def export(
VERBOSE = bool(verbose_)
if not os.path.isdir(dest):
sys.exit(f"DEST {dest} must be valid path")
click.echo(f"DEST {dest} must be valid path", err=True)
raise click.Abort()
if report and os.path.isdir(report):
click.echo(f"report is a directory, must be file name", err=True)
raise click.Abort()
# sanity check input args
exclusive = [
@@ -1545,6 +1573,32 @@ def export(
click.echo(cli.commands["export"].get_help(ctx), err=True)
return
if export_as_hardlink and download_missing:
click.echo(
"Incompatible export options: --export-as-hardlink is not compatible with --download-missing",
err=True,
)
raise click.Abort()
if missing and not download_missing:
click.echo(
"Incompatible export options: --missing must be used with --download-missing",
err=True,
)
raise click.Abort()
if use_photokit and not check_photokit_authorization():
click.echo(
"Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
)
request_photokit_authorization()
click.confirm("Have you granted access?")
if not check_photokit_authorization():
click.echo(
"Failed to get access to the Photos library which is needed with `--use-photokit`."
)
return
# initialize export flags
# by default, will export all versions of photos unless skip flag is set
(export_edited, export_bursts, export_live, export_raw) = [
@@ -1640,7 +1694,7 @@ def export(
not_favorite=not_favorite,
hidden=hidden,
not_hidden=not_hidden,
missing=None, # missing -- won't export these but will warn user
missing=missing,
not_missing=None,
shared=shared,
not_shared=not_shared,
@@ -1707,6 +1761,11 @@ def export(
results_skipped = []
results_exif_updated = []
results_touched = []
results_converted = []
results_sidecar_json_written = []
results_sidecar_json_skipped = []
results_sidecar_xmp_written = []
results_sidecar_xmp_skipped = []
if verbose_:
for p in photos:
results = export_photo(
@@ -1737,6 +1796,7 @@ def export(
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
@@ -1749,6 +1809,11 @@ def export(
results_skipped.extend(results.skipped)
results_exif_updated.extend(results.exif_updated)
results_touched.extend(results.touched)
results_converted.extend(results.converted_to_jpeg)
results_sidecar_json_written.extend(results.sidecar_json_written)
results_sidecar_json_skipped.extend(results.sidecar_json_skipped)
results_sidecar_xmp_written.extend(results.sidecar_xmp_written)
results_sidecar_xmp_skipped.extend(results.sidecar_xmp_skipped)
# if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg":
# for photo_file in set(
@@ -1788,6 +1853,7 @@ def export(
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
@@ -1800,9 +1866,41 @@ def export(
results_skipped.extend(results.skipped)
results_exif_updated.extend(results.exif_updated)
results_touched.extend(results.touched)
results_converted.extend(results.converted_to_jpeg)
results_sidecar_json_written.extend(results.sidecar_json_written)
results_sidecar_json_skipped.extend(results.sidecar_json_skipped)
results_sidecar_xmp_written.extend(results.sidecar_xmp_written)
results_sidecar_xmp_skipped.extend(results.sidecar_xmp_skipped)
stop_time = time.perf_counter()
# print summary results
# print(f"results_exported: {results_exported}")
# print(f"results_new: {results_new}")
# print(f"results_updated: {results_updated}")
# print(f"results_skipped: {results_skipped}")
# print(f"results_exif_updated: {results_exif_updated}")
# print(f"results_touched: {results_touched}")
# print(f"results_converted: {results_converted}")
# print(f"results_sidecar_json: {results_sidecar_json}")
# print(f"results_sidecar_xmp: {results_sidecar_xmp}")
if report:
verbose(f"Writing export report to {report}")
write_export_report(
report,
results_exported=results_exported,
results_new=results_new,
results_updated=results_updated,
results_skipped=results_skipped,
results_exif_updated=results_exif_updated,
results_touched=results_touched,
results_converted=results_converted,
results_sidecar_json_written=results_sidecar_json_written,
results_sidecar_json_skipped=results_sidecar_json_skipped,
results_sidecar_xmp_written=results_sidecar_xmp_written,
results_sidecar_xmp_skipped=results_sidecar_xmp_skipped,
)
if update:
photo_str_new = "photos" if len(results_new) != 1 else "photo"
photo_str_updated = "photos" if len(results_updated) != 1 else "photo"
@@ -2296,6 +2394,7 @@ def export_photo(
dry_run=None,
touch_file=None,
edited_suffix="_edited",
original_suffix="",
use_photos_export=False,
convert_to_jpeg=False,
jpeg_quality=1.0,
@@ -2351,19 +2450,19 @@ def export_photo(
if photo.ismissing:
space = " " if not verbose_ else ""
verbose(f"{space}Skipping missing photo {photo.original_filename}")
return ExportResults([], [], [], [], [], [])
return ExportResults([], [], [], [], [], [], [], [], [], [], [])
elif photo.path is None:
space = " " if not verbose_ else ""
verbose(
f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, "
f"skipping {photo.original_filename}"
)
return ExportResults([], [], [], [], [], [])
return ExportResults([], [], [], [], [], [], [], [], [], [], [])
elif photo.ismissing and not photo.iscloudasset and not photo.incloud:
verbose(
f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud"
)
return ExportResults([], [], [], [], [], [])
return ExportResults([], [], [], [], [], [], [], [], [], [], [])
results_exported = []
results_new = []
@@ -2371,6 +2470,11 @@ def export_photo(
results_skipped = []
results_exif_updated = []
results_touched = []
results_converted = []
results_sidecar_json_written = []
results_sidecar_json_skipped = []
results_sidecar_xmp_written = []
results_sidecar_xmp_skipped = []
export_original = not (skip_original_if_edited and photo.hasadjustments)
@@ -2392,7 +2496,19 @@ def export_photo(
filenames = get_filenames_from_template(photo, filename_template, original_name)
for filename in filenames:
verbose(f"Exporting {photo.original_filename} ({photo.filename}) as {filename}")
if original_suffix:
original_filename = pathlib.Path(filename)
original_filename = (
original_filename.parent
/ f"{original_filename.stem}{original_suffix}{original_filename.suffix}"
)
original_filename = str(original_filename)
else:
original_filename = filename
verbose(
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}"
)
dest_paths = get_dirnames_from_template(
photo, directory, export_by_date, dest, dry_run
@@ -2418,81 +2534,17 @@ def export_photo(
# export the photo to each path in dest_paths
for dest_path in dest_paths:
if not export_original:
verbose(f"Skipping original version of {photo.original_filename}")
else:
export_results = photo.export2(
dest_path,
filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
live_photo=export_live,
raw_photo=export_raw,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
use_photos_export=use_photos_export,
exiftool=exiftool,
no_xattr=no_extended_attributes,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
update=update,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
)
results_exported.extend(export_results.exported)
results_new.extend(export_results.new)
results_updated.extend(export_results.updated)
results_skipped.extend(export_results.skipped)
results_exif_updated.extend(export_results.exif_updated)
results_touched.extend(export_results.touched)
if verbose_:
for exported in export_results.exported:
verbose(f"Exported {exported}")
for new in export_results.new:
verbose(f"Exported new file {new}")
for updated in export_results.updated:
verbose(f"Exported updated file {updated}")
for skipped in export_results.skipped:
verbose(f"Skipped up to date file {skipped}")
for touched in export_results.touched:
verbose(f"Touched date on file {touched}")
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
if not download_missing and photo.path_edited is None:
verbose(f"Skipping missing edited photo for {filename}")
else:
edited_name = pathlib.Path(filename)
# check for correct edited suffix
if photo.path_edited is not None:
edited_ext = pathlib.Path(photo.path_edited).suffix
else:
# use filename suffix which might be wrong,
# will be corrected by use_photos_export
edited_ext = pathlib.Path(photo.filename).suffix
edited_name = f"{edited_name.stem}{edited_suffix}{edited_ext}"
verbose(f"Exporting edited version of {filename} as {edited_name}")
export_results_edited = photo.export2(
if export_original:
try:
export_results = photo.export2(
dest_path,
edited_name,
original_filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
live_photo=export_live,
raw_photo=export_raw,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
edited=True,
use_photos_export=use_photos_export,
exiftool=exiftool,
no_xattr=no_extended_attributes,
@@ -2509,26 +2561,136 @@ def export_photo(
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
verbose=verbose,
)
results_exported.extend(export_results_edited.exported)
results_new.extend(export_results_edited.new)
results_updated.extend(export_results_edited.updated)
results_skipped.extend(export_results_edited.skipped)
results_exif_updated.extend(export_results_edited.exif_updated)
results_touched.extend(export_results_edited.touched)
results_exported.extend(export_results.exported)
results_new.extend(export_results.new)
results_updated.extend(export_results.updated)
results_skipped.extend(export_results.skipped)
results_exif_updated.extend(export_results.exif_updated)
results_touched.extend(export_results.touched)
results_converted.extend(export_results.converted_to_jpeg)
results_sidecar_json_written.extend(
export_results.sidecar_json_written
)
results_sidecar_json_skipped.extend(
export_results.sidecar_json_skipped
)
results_sidecar_xmp_written.extend(
export_results.sidecar_xmp_written
)
results_sidecar_xmp_skipped.extend(
export_results.sidecar_xmp_skipped
)
if verbose_:
for exported in export_results_edited.exported:
for exported in export_results.exported:
verbose(f"Exported {exported}")
for new in export_results_edited.new:
for new in export_results.new:
verbose(f"Exported new file {new}")
for updated in export_results_edited.updated:
for updated in export_results.updated:
verbose(f"Exported updated file {updated}")
for skipped in export_results_edited.skipped:
for skipped in export_results.skipped:
verbose(f"Skipped up to date file {skipped}")
for touched in export_results_edited.touched:
for touched in export_results.touched:
verbose(f"Touched date on file {touched}")
except Exception:
click.echo(
f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}",
err=True,
)
else:
verbose(f"Skipping original version of {photo.original_filename}")
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
if not download_missing and photo.path_edited is None:
verbose(f"Skipping missing edited photo for {filename}")
else:
edited_filename = pathlib.Path(filename)
# check for correct edited suffix
if photo.path_edited is not None:
edited_ext = pathlib.Path(photo.path_edited).suffix
else:
# use filename suffix which might be wrong,
# will be corrected by use_photos_export
edited_ext = pathlib.Path(photo.filename).suffix
edited_filename = (
f"{edited_filename.stem}{edited_suffix}{edited_ext}"
)
verbose(
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
)
try:
export_results_edited = photo.export2(
dest_path,
edited_filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
edited=True,
use_photos_export=use_photos_export,
exiftool=exiftool,
no_xattr=no_extended_attributes,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
update=update,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
verbose=verbose,
)
results_exported.extend(export_results_edited.exported)
results_new.extend(export_results_edited.new)
results_updated.extend(export_results_edited.updated)
results_skipped.extend(export_results_edited.skipped)
results_exif_updated.extend(export_results_edited.exif_updated)
results_touched.extend(export_results_edited.touched)
results_converted.extend(
export_results_edited.converted_to_jpeg
)
results_sidecar_json_written.extend(
export_results_edited.sidecar_json_written
)
results_sidecar_json_skipped.extend(
export_results_edited.sidecar_json_skipped
)
results_sidecar_xmp_written.extend(
export_results_edited.sidecar_xmp_written
)
results_sidecar_xmp_skipped.extend(
export_results_edited.sidecar_xmp_skipped
)
if verbose_:
for exported in export_results_edited.exported:
verbose(f"Exported {exported}")
for new in export_results_edited.new:
verbose(f"Exported new file {new}")
for updated in export_results_edited.updated:
verbose(f"Exported updated file {updated}")
for skipped in export_results_edited.skipped:
verbose(f"Skipped up to date file {skipped}")
for touched in export_results_edited.touched:
verbose(f"Touched date on file {touched}")
except Exception:
click.echo(
f"Error exporting photo {filename} as {edited_filename}",
err=True,
)
return ExportResults(
results_exported,
@@ -2537,6 +2699,11 @@ def export_photo(
results_skipped,
results_exif_updated,
results_touched,
results_converted,
results_sidecar_json_written,
results_sidecar_json_skipped,
results_sidecar_xmp_written,
results_sidecar_xmp_skipped,
)
@@ -2566,7 +2733,11 @@ def get_filenames_from_template(photo, filename_template, original_name):
)
filenames = [f"{file_}{photo_ext}" for file_ in filenames]
else:
filenames = [photo.original_filename] if original_name else [photo.filename]
filenames = (
[photo.original_filename]
if (original_name and (photo.original_filename is not None))
else [photo.filename]
)
filenames = [sanitize_filename(filename) for filename in filenames]
return filenames
@@ -2684,5 +2855,111 @@ def load_uuid_from_file(filename):
return uuid
def write_export_report(
report_file,
results_exported,
results_new,
results_updated,
results_skipped,
results_exif_updated,
results_touched,
results_converted,
results_sidecar_json_written,
results_sidecar_json_skipped,
results_sidecar_xmp_written,
results_sidecar_xmp_skipped,
):
""" write CSV report with results from export """
# Collect results for reporting
# TODO: pull this in a separate write_report function
all_results = {
result: {
"filename": result,
"exported": 0,
"new": 0,
"updated": 0,
"skipped": 0,
"exif_updated": 0,
"touched": 0,
"converted_to_jpeg": 0,
"sidecar_xmp": 0,
"sidecar_json": 0,
}
for result in results_exported
+ results_new
+ results_updated
+ results_skipped
+ results_exif_updated
+ results_touched
+ results_converted
+ results_sidecar_json_written
+ results_sidecar_json_skipped
+ results_sidecar_xmp_written
+ results_sidecar_xmp_skipped
}
for result in results_exported:
all_results[result]["exported"] = 1
for result in results_new:
all_results[result]["new"] = 1
for result in results_updated:
all_results[result]["updated"] = 1
for result in results_skipped:
all_results[result]["skipped"] = 1
for result in results_exif_updated:
all_results[result]["exif_updated"] = 1
for result in results_touched:
all_results[result]["touched"] = 1
for result in results_converted:
all_results[result]["converted_to_jpeg"] = 1
for result in results_sidecar_xmp_written:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["exported"] = 1
for result in results_sidecar_xmp_skipped:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["skipped"] = 1
for result in results_sidecar_json_written:
all_results[result]["sidecar_json"] = 1
all_results[result]["exported"] = 1
for result in results_sidecar_json_skipped:
all_results[result]["sidecar_json"] = 1
all_results[result]["skipped"] = 1
report_columns = [
"filename",
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_xmp",
"sidecar_json",
]
try:
with open(report_file, "w") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=report_columns)
writer.writeheader()
for data in [result for result in all_results.values()]:
writer.writerow(data)
except IOError:
click.echo("Could not open output file for writing", err=True)
raise click.Abort()
if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter

View File

@@ -1,4 +1,4 @@
""" version info """
__version__ = "0.36.20"
__version__ = "0.37.2"

View File

@@ -14,7 +14,7 @@ from sqlite3 import Error
from ._version import __version__
OSXPHOTOS_EXPORTDB_VERSION = "2.0"
OSXPHOTOS_EXPORTDB_VERSION = "3.2"
class ExportDB_ABC(ABC):
@@ -76,6 +76,14 @@ class ExportDB_ABC(ABC):
def set_exifdata_for_file(self, uuid, exifdata):
pass
@abstractmethod
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
@abstractmethod
def get_sidecar_for_file(self, filename):
pass
@abstractmethod
def set_data(
self,
@@ -141,6 +149,12 @@ class ExportDBNoOp(ExportDB_ABC):
def set_exifdata_for_file(self, uuid, exifdata):
pass
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
def get_sidecar_for_file(self, filename):
return None, (None, None, None)
def set_data(
self,
filename,
@@ -379,6 +393,48 @@ class ExportDB(ExportDB_ABC):
except Error as e:
logging.warning(e)
def get_sidecar_for_file(self, filename):
""" returns the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT sidecar_data, mode, size, mtime FROM sidecar WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
if results:
sidecar_data = results[0]
sidecar_sig = (
results[1],
results[2],
int(results[3]) if results[3] is not None else None,
)
else:
sidecar_data = None
sidecar_sig = (None, None, None)
except Error as e:
logging.warning(e)
sidecar_data = None
sidecar_sig = (None, None, None)
return sidecar_data, sidecar_sig
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
""" sets the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO sidecar(filepath_normalized, sidecar_data, mode, size, mtime) VALUES (?, ?, ?, ?, ?);",
(filename, sidecar_data, *sidecar_sig),
)
conn.commit()
except Error as e:
logging.warning(e)
def set_data(
self,
filename,
@@ -479,13 +535,11 @@ class ExportDB(ExportDB_ABC):
if not os.path.isfile(dbfile):
conn = self._get_db_connection(dbfile)
if conn:
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
else:
if not conn:
raise Exception("Error getting connection to database {dbfile}")
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
else:
conn = self._get_db_connection(dbfile)
self.was_created = False
@@ -495,8 +549,7 @@ class ExportDB(ExportDB_ABC):
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
else:
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
self.version = OSXPHOTOS_EXPORTDB_VERSION
return conn
def _get_db_connection(self, dbfile):
@@ -570,11 +623,20 @@ class ExportDB(ExportDB_ABC):
size INTEGER,
mtime REAL
); """,
"sql_sidecar_table": """ CREATE TABLE IF NOT EXISTS sidecar (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
sidecar_data TEXT,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
"sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
"sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
"sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
"sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
"sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
"sql_sidecar_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
}
try:
c = conn.cursor()

View File

@@ -13,6 +13,7 @@
# TODO: should this be its own PhotoExporter class?
import glob
import hashlib
import json
import logging
import os
@@ -41,14 +42,34 @@ from ..photokit import (
PhotoLibrary,
PhotoKitFetchFailed,
)
from ..utils import dd_to_dms_str, findfiles
from ..utils import dd_to_dms_str, findfiles, noop
ExportResults = namedtuple(
"ExportResults",
["exported", "new", "updated", "skipped", "exif_updated", "touched"],
[
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
],
)
# hexdigest is not a class method, don't import this into PhotoInfo
def hexdigest(strval):
""" hexdigest of a string, using blake2b """
h = hashlib.blake2b(digest_size=20)
h.update(bytes(strval, "utf-8"))
return h.hexdigest()
# _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo
def _export_photo_uuid_applescript(
uuid,
@@ -149,11 +170,9 @@ def _export_photo_uuid_applescript(
and path.suffix.lower() == ".mov"
):
# it's the .mov part of live photo but not requested, so don't export
logging.debug(f"Skipping live photo file {path}")
continue
if len(exported_files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for
logging.debug(f"Skipping burst photo file {path}")
continue
if filestem:
# rename the file based on filestem, keeping original extension
@@ -161,7 +180,6 @@ def _export_photo_uuid_applescript(
else:
# use the name Photos provided
dest_new = dest / path.name
logging.debug(f"exporting {path} to dest_new: {dest_new}")
if not dry_run:
FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new))
@@ -324,6 +342,7 @@ def export2(
jpeg_quality=1.0,
ignore_date_modified=False,
use_photokit=False,
verbose=None,
):
""" export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -367,6 +386,7 @@ def export2(
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths
@@ -383,6 +403,12 @@ def export2(
if export_db is None:
export_db = ExportDBNoOp()
if verbose is None:
verbose = noop
elif not callable(verbose):
raise TypeError("verbose must be callable")
self._verbose = verbose
# suffix to add to edited files
# e.g. name will be filename_edited.jpg
edited_identifier = "_edited"
@@ -402,6 +428,9 @@ def export2(
# list of all files with utime touched (touch_file = True)
touched_files = []
# list of all files convereted to jpeg
converted_to_jpeg_files = []
# check edited and raise exception trying to export edited version of
# photo that hasn't been edited
if edited and not self.hasadjustments:
@@ -488,11 +517,6 @@ def export2(
f"Cannot export edited photo if path_edited is None"
)
else:
if self.ismissing:
logging.debug(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is not None:
src = self.path
else:
@@ -501,32 +525,22 @@ def export2(
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
if not _check_export_suffix(src, dest, edited):
logging.debug(
f"Invalid destination suffix: {dest.suffix} for {self.path}, "
+ f"edited={edited}, path_edited={self.path_edited}, "
+ f"original_filename={self.original_filename}, filename={self.filename}"
)
# found source now try to find right destination
if update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
logging.debug(
f"Found matching file with blank uuid: {self.uuid}, {dest}"
)
dest_uuid = self.uuid
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
(None, None, None),
(None, None, None),
self.json(),
None,
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
@@ -545,14 +559,14 @@ def export2(
dest = pathlib.Path(file_)
found_match = True
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
(None, None, None),
(None, None, None),
self.json(),
None,
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
break
@@ -589,6 +603,7 @@ def export2(
update_updated_files = results.updated
update_skipped_files = results.skipped
touched_files = results.touched
converted_to_jpeg_files = results.converted_to_jpeg
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
@@ -596,9 +611,6 @@ def export2(
src_live = self.path_live_photo
if src_live is not None:
logging.debug(
f"Exporting live photo video of {filename} as {live_name.name}"
)
results = self._export_photo(
src_live,
live_name,
@@ -617,8 +629,7 @@ def export2(
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
else:
logging.debug(f"Skipping missing live movie for {filename}")
converted_to_jpeg_files.extend(results.converted_to_jpeg)
# copy associated RAW image if requested
if raw_photo and self.has_raw:
@@ -626,7 +637,6 @@ def export2(
raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
if raw_path is not None:
logging.debug(f"Exporting RAW photo of {filename} as {raw_name.name}")
results = self._export_photo(
raw_path,
raw_name,
@@ -646,8 +656,7 @@ def export2(
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
else:
logging.debug(f"Skipping missing RAW photo for {filename}")
converted_to_jpeg_files.extend(results.converted_to_jpeg)
else:
# use_photo_export
exported = []
@@ -748,8 +757,9 @@ def export2(
)
# export metadata
sidecar_json_files_skipped = []
sidecar_json_files_written = []
if sidecar_json:
logging.debug("writing exiftool_json_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
@@ -758,15 +768,36 @@ def export2(
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
if not dry_run:
try:
sidecar_digest = hexdigest(sidecar_str)
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
)
write_sidecar = (
not update
or (update and not sidecar_filename.exists())
or (
update
and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
)
)
if write_sidecar:
verbose(f"Writing exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
export_db.set_sidecar_for_file(
sidecar_filename,
sidecar_digest,
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_skipped.append(str(sidecar_filename))
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
if sidecar_xmp:
logging.debug("writing xmp_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
@@ -775,12 +806,32 @@ def export2(
description_template=description_template,
extension=dest.suffix[1:] if dest.suffix else None,
)
if not dry_run:
try:
sidecar_digest = hexdigest(sidecar_str)
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
)
write_sidecar = (
not update
or (update and not sidecar_filename.exists())
or (
update
and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
)
)
if write_sidecar:
verbose(f"Writing XMP sidecar {sidecar_filename}")
sidecar_xmp_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e
export_db.set_sidecar_for_file(
sidecar_filename,
sidecar_digest,
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
sidecar_xmp_files_skipped.append(str(sidecar_filename))
# if exiftool, write the metadata
if update:
@@ -791,7 +842,6 @@ def export2(
exif_files_updated = []
if exiftool and update and exif_files:
for exported_file in exif_files:
logging.debug(f"checking exif for {exported_file}")
files_are_different = False
old_data = export_db.get_exifdata_for_file(exported_file)
if old_data is not None:
@@ -811,6 +861,7 @@ def export2(
if old_data is None or files_are_different:
# didn't have old data, assume we need to write it
# or files were different
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run:
self._write_exif_data(
exported_file,
@@ -834,8 +885,11 @@ def export2(
exported_file, fileutil.file_sig(exported_file)
)
exif_files_updated.append(exported_file)
else:
verbose(f"Skipped up to date exiftool metadata for {exported_file}")
elif exiftool and exif_files:
for exported_file in exif_files:
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run:
self._write_exif_data(
exported_file,
@@ -863,6 +917,7 @@ def export2(
if touch_file:
for exif_file in exif_files_updated:
verbose(f"Updating file modification time for {exif_file}")
touched_files.append(exif_file)
ts = int(self.date.timestamp())
fileutil.utime(exif_file, (ts, ts))
@@ -876,6 +931,11 @@ def export2(
update_skipped_files,
exif_files_updated,
touched_files,
converted_to_jpeg_files,
sidecar_json_files_written,
sidecar_json_files_skipped,
sidecar_xmp_files_written,
sidecar_xmp_files_skipped,
)
return results
@@ -932,6 +992,7 @@ def _export_photo(
update_new_files = []
update_skipped_files = []
touched_files = []
converted_to_jpeg_files = []
dest_str = str(dest)
dest_exists = dest.exists()
@@ -998,13 +1059,11 @@ def _export_photo(
else:
# update, destination doesn't exist (new file)
logging.debug(f"Update: exporting new file with {op_desc} {src} {dest}")
update_new_files.append(dest_str)
if touch_file:
touched_files.append(dest_str)
else:
# not update, export the file
logging.debug(f"Exporting file with {op_desc} {src} {dest}")
exported_files.append(dest_str)
if touch_file:
sig = fileutil.file_sig(src)
@@ -1016,9 +1075,6 @@ def _export_photo(
edited_stat = fileutil.file_sig(src) if edited else (None, None, None)
if dest_exists and (update or overwrite):
# need to remove the destination first
logging.debug(
f"Update: removing existing file prior to {op_desc} {src} {dest}"
)
fileutil.unlink(dest)
if export_as_hardlink:
fileutil.hardlink(src, dest)
@@ -1026,18 +1082,19 @@ def _export_photo(
# use convert_to_jpeg to export the file
fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
converted_stat = fileutil.file_sig(dest_str)
converted_to_jpeg_files.append(dest_str)
else:
fileutil.copy(src, dest_str, norsrc=no_xattr)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
converted_stat,
edited_stat,
self.json(),
None,
filename=dest_str,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest_str),
exif_stat=(None, None, None),
converted_stat=converted_stat,
edited_stat=edited_stat,
info_json=self.json(),
exif_json=None,
)
if touched_files:
@@ -1051,6 +1108,11 @@ def _export_photo(
update_skipped_files,
[],
touched_files,
converted_to_jpeg_files,
[],
[],
[],
[],
)
@@ -1121,9 +1183,9 @@ def _exiftool_dict(
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
@@ -1283,9 +1345,9 @@ def _exiftool_json_sidecar(
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate

View File

@@ -91,9 +91,10 @@ class PhotoInfo:
and self.raw_original
):
# return the JPEG version as that's what Photos 5+ does
return self._info["raw_pair_info"]["originalFilename"]
original_name = self._info["raw_pair_info"]["originalFilename"]
else:
return self._info["originalFilename"]
original_name = self._info["originalFilename"]
return original_name or self.filename
@property
def date(self):

View File

@@ -80,6 +80,58 @@ def path_to_NSURL(path):
return url
def check_photokit_authorization():
""" Check authorization to use user's Photos Library
Returns:
True if user has authorized access to the Photos library, otherwise False
"""
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
return auth_status == Photos.PHAuthorizationStatusAuthorized
def request_photokit_authorization():
""" Request authorization to user's Photos Library
Returns:
authorization status
Note: In actual practice, the terminal process running the python code
will do the actual request.
"""
(_, major, _) = _get_os_version()
def handler(status):
pass
auth_status = 0
if int(major) < 16:
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(handler)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
else:
# requestAuthorization deprecated in 10.16/11.0
# but requestAuthorizationForAccessLevel not yet implemented in pyobjc (will be in ver 7.0)
# https://developer.apple.com/documentation/photokit/phphotolibrary/3616053-requestauthorizationforaccesslev?language=objc
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(handler)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
return auth_status
### exceptions
class PhotoKitError(Exception):
"""Base class for exceptions in this module. """
@@ -1051,12 +1103,6 @@ class PhotoLibrary:
# get image manager and request options
self._phimagemanager = Photos.PHCachingImageManager.defaultManager()
def _auth_status(self, status):
""" Handler for requestAuthorization_ """
# This doesn't actually get called but requestAuthorization needs a callable handler
# The Terminal will handle the actual authorization when called
pass
def request_authorization(self):
""" Request authorization to user's Photos Library
@@ -1064,33 +1110,8 @@ class PhotoLibrary:
authorization status
"""
(_, major, _) = _get_os_version()
auth_status = 0
if int(major) < 16:
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
else:
# requestAuthorization deprecated in 10.16/11.0
# but requestAuthorizationForAccessLevel not yet implemented in pyobjc (will be in ver 7.0)
# https://developer.apple.com/documentation/photokit/phphotolibrary/3616053-requestauthorizationforaccesslev?language=objc
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
self.auth_status = auth_status
return auth_status
self.auth_status = request_photokit_authorization()
return self.auth_status
def fetch_uuid_list(self, uuid_list):
""" fetch PHAssets with uuids in uuid_list

View File

@@ -18,6 +18,7 @@ from functools import partial
from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifTool
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
# ensure locale set to user's locale
@@ -126,6 +127,10 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)",
"{exiftool:GROUP:TAGNAME}": "Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. "
"E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. "
"See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) "
"as used in `exiftool -G`. exiftool must be installed in the path to use this template.",
}
# Just the multi-valued substitution names without the braces
@@ -150,6 +155,62 @@ class PhotoTemplate:
# gets initialized in get_template_value
self.today = None
def make_subst_function(
self, none_str, filename, dirname, replacement, get_func=None
):
""" returns: substitution function for use in re.sub
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 """
if get_func is None:
# used by make_subst_function to get the value for a template substitution
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups != 5:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
return subst
def render(
self,
template,
@@ -208,60 +269,7 @@ class PhotoTemplate:
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
# used by make_subst_function to get the value for a template substitution
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
def make_subst_function(self, none_str, get_func=get_func):
""" returns: substitution function for use in re.sub
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, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 5:
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(self, none_str)
subst_func = self.make_subst_function(none_str, filename, dirname, replacement)
# do the replacements
rendered = re.sub(regex, subst_func, template)
@@ -289,88 +297,28 @@ class PhotoTemplate:
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = [rendered]
for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"("
+ field # group 2: field name
+ r")"
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
rendered_strings = self._render_multi_valued_templates(
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
)
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1] if matches.group(1) is not None else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = delim.join(sorted(values)) if values and values[0] else None
def lookup_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
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = {new_string}
else:
# create a new template string for each value
for val in values:
def lookup_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
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = list(new_strings.keys())
# process exiftool: templates
rendered_strings = self._render_exiftool_template(
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
)
# find any {fields} that weren't replaced
unmatched = []
@@ -396,6 +344,244 @@ class PhotoTemplate:
return rendered_strings, unmatched
def _render_multi_valued_templates(
self,
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
):
rendered_strings = [rendered]
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"("
+ field # group 2: field name
+ r")"
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
)
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values))
if values and values[0]
else None
)
def lookup_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
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
def lookup_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
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def _render_exiftool_template(
self,
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
):
# TODO: lots of code commonality with render_multi_valued_templates -- combine or pull out
# TODO: put these in globals
if path_sep is None:
path_sep = os.path.sep
if inplace_sep is None:
inplace_sep = ","
# Build a regex that matches only the field being processed
# todo: pull out regexes into globals?
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"(exiftool:[^\\,}+\?]+)" # group 3 field name
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
# allmatches = regex_multi.finditer(str_template)
# for matches in allmatches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
field = matches.group(2)
subfield = field[9:]
if not self.photo.path:
values = []
else:
exif = ExifTool(self.photo.path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]
values = (
[values] if not isinstance(values, list) else values
)
else:
values = [None]
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values)) if values and values[0] else None
)
def lookup_template_value_exif(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
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
def lookup_template_value_exif(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
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def get_template_value(
self,
field,
@@ -681,6 +867,7 @@ class PhotoTemplate:
"""
""" return list of values for a multi-valued template field """
values = []
if field == "album":
values = self.photo.albums
elif field == "keyword":
@@ -724,7 +911,7 @@ class PhotoTemplate:
values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments
]
else:
elif not field.startswith("exiftool:"):
raise ValueError(f"Unhandled template value: {field}")
# sanitize directory names if needed, folder_album handled differently above

View File

@@ -12,7 +12,7 @@
% if desc is None:
<dc:description></dc:description>
% else:
<dc:description>${desc}</dc:description>
<dc:description>${desc | x}</dc:description>
% endif
</%def>
@@ -20,7 +20,7 @@
% if title is None:
<dc:title></dc:title>
% else:
<dc:title>${title}</dc:title>
<dc:title>${title | x}</dc:title>
% endif
</%def>
@@ -30,7 +30,7 @@
<dc:subject>
<rdf:Seq>
% for subj in subject:
<rdf:li>${subj}</rdf:li>
<rdf:li>${subj | x}</rdf:li>
% endfor
</rdf:Seq>
</dc:subject>
@@ -48,7 +48,7 @@
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
% for person in persons:
<rdf:li>${person}</rdf:li>
<rdf:li>${person | x}</rdf:li>
% endfor
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
@@ -60,7 +60,7 @@
<digiKam:TagsList>
<rdf:Seq>
% for keyword in keywords:
<rdf:li>${keyword}</rdf:li>
<rdf:li>${keyword | x}</rdf:li>
% endfor
</rdf:Seq>
</digiKam:TagsList>
@@ -81,10 +81,8 @@
<%def name="gps_info(latitude, longitude)">
% if latitude is not None and longitude is not None:
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef>
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude>
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
<exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
<exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
% endif
</%def>

View File

@@ -177,6 +177,12 @@ RAW_DICT = {
),
}
ORIGINAL_FILENAME_DICT = {
"uuid": "D79B8D77-BFFC-460B-9312-034F2877D35B",
"filename": "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg",
"original_filename": "Pumkins2.jpg",
}
@pytest.fixture(scope="module")
def photosdb():
@@ -864,6 +870,27 @@ def test_export_14(photosdb, caplog):
assert "Invalid destination suffix" not in caplog.text
def test_export_no_original_filename(photosdb):
# test export OK if original filename is null
# issue #267
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
# monkey patch original_filename for testing
original_filename = photos[0]._info["originalFilename"]
photos[0]._info["originalFilename"] = None
filename = f"{photos[0].uuid}.jpeg"
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)[0]
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
photos[0]._info["originalFilename"] = original_filename
def test_eq():
""" Test equality of two PhotoInfo objects """
@@ -1070,3 +1097,18 @@ def test_verbose(capsys):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB, verbose=print)
captured = capsys.readouterr()
assert "Processing database" in captured.out
def test_original_filename(photosdb):
""" test original filename """
uuid = ORIGINAL_FILENAME_DICT["uuid"]
photo = photosdb.get_photo(uuid)
assert photo.original_filename == ORIGINAL_FILENAME_DICT["original_filename"]
assert photo.filename == ORIGINAL_FILENAME_DICT["filename"]
# monkey patch
original_filename = photo._info["originalFilename"]
photo._info["originalFilename"] = None
assert photo.original_filename == ORIGINAL_FILENAME_DICT["filename"]
photo._info["originalFilename"] = original_filename

View File

@@ -17,6 +17,7 @@ PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary"
PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary"
PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary"
PHOTOS_DB_15_6 = "tests/Test-10.15.6.photoslibrary"
PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary"
PHOTOS_DB_TOUCH = PHOTOS_DB_15_6
PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary"
@@ -65,6 +66,7 @@ CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"Pumkins1.jpg",
@@ -77,6 +79,16 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"wedding_bearbeiten.jpeg",
]
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
"Pumkins1_original.jpg",
"Pumkins2_original.jpg",
"Pumpkins3_original.jpg",
"St James Park_original.jpg",
"St James Park_edited.jpeg",
"Tulips_original.jpg",
"wedding_original.jpg",
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_CURRENT = [
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
@@ -976,7 +988,9 @@ def test_export_exiftool_ignore_date_modified():
)
assert result.exit_code == 0
exif = ExifTool(CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]).asdict()
exif = ExifTool(
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]
).asdict()
for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]:
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
@@ -1008,6 +1022,33 @@ def test_export_edited_suffix():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX)
def test_export_original_suffix():
""" test export with --original-suffix """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-suffix",
CLI_EXPORT_ORIGINAL_SUFFIX,
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX)
@pytest.mark.skipif(
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
reason="Skip if running in Github actions, no GPU.",
@@ -1734,6 +1775,142 @@ def test_export_sidecar_templates():
)
def test_export_sidecar_update():
""" test sidecar don't update if not changed and do update if changed """
import datetime
import glob
import os
import os.path
import osxphotos
from osxphotos.fileutil import FileUtil
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",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
],
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
# delete a sidecar file and run update
fileutil = FileUtil()
fileutil.unlink(CLI_EXPORT_SIDECAR_FILENAMES[1])
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
# run update again, no sidecar files should update
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON sidecar" in result.output
# touch a file and export again
ts = datetime.datetime.now().timestamp() + 1000
fileutil.utime(CLI_EXPORT_SIDECAR_FILENAMES[2], (ts, ts))
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON sidecar" in result.output
# run update again, no sidecar files should update
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON sidecar" in result.output
# run update again with updated metadata, forcing update
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
"--keyword-template",
"foo",
],
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
def test_export_live():
import glob
import os
@@ -3587,3 +3764,116 @@ def test_persons():
json_got = json.loads(result.output)
assert json_got == PERSONS_JSON
def test_export_report():
""" test export with --report option """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "report.csv"],
)
assert result.exit_code == 0
assert "Writing export report" in result.output
assert os.path.exists("report.csv")
def test_export_report_not_a_file():
""" test export with --report option and bad report value """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."]
)
assert result.exit_code != 0
assert "Aborted!" in result.output
def test_export_as_hardlink_download_missing():
""" test export with incompatible export options """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--download-missing",
"--export-as-hardlink",
".",
],
)
assert result.exit_code != 0
assert "Aborted!" in result.output
def test_export_missing():
""" test export with --missing """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--missing",
"--download-missing",
".",
],
)
assert result.exit_code == 0
assert "Exporting 2 photos" in result.output
def test_export_missing_not_download_missing():
""" test export with incompatible export options """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--missing", "."]
)
assert result.exit_code != 0
assert "Aborted!" in result.output

View File

@@ -1029,7 +1029,7 @@ def test_xmp_sidecar_gps():
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description></dc:description>
<dc:title>St. James's Park</dc:title>
<dc:title>St. James&#39;s Park</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
@@ -1038,7 +1038,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li>
<rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated>
@@ -1055,7 +1055,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li>
<rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
@@ -1066,10 +1066,8 @@ def test_xmp_sidecar_gps():
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
<exif:GPSLongitudeRef>W</exif:GPSLongitudeRef>
<exif:GPSLongitude>0.1318055</exif:GPSLongitude>
<exif:GPSLatitude>51.50357167</exif:GPSLatitude>
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
<exif:GPSLongitude>0,7.908329999999999W</exif:GPSLongitude>
<exif:GPSLatitude>51,30.21430019999997N</exif:GPSLatitude>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""

View File

@@ -4,6 +4,8 @@ import pytest
EXIF_DATA = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "EXIF:ImageDescription": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Title": "Elder Park", "EXIF:GPSLatitude": "34 deg 55' 8.01\" S", "EXIF:GPSLongitude": "138 deg 35' 48.70\" E", "Composite:GPSPosition": "34 deg 55' 8.01\" S, 138 deg 35' 48.70\" E", "EXIF:GPSLatitudeRef": "South", "EXIF:GPSLongitudeRef": "East", "EXIF:DateTimeOriginal": "2017:06:20 17:18:56", "EXIF:OffsetTimeOriginal": "+09:30", "EXIF:ModifyDate": "2020:05:18 14:42:04"}]"""
INFO_DATA = """{"uuid": "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "filename": "3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "original_filename": "IMG_4547.jpg", "date": "2017-06-20T17:18:56.518000+09:30", "description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "title": "Elder Park", "keywords": [], "labels": ["Statue", "Art"], "albums": ["AlbumInFolder"], "folders": {"AlbumInFolder": ["Folder1", "SubFolder2"]}, "persons": [], "path": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/originals/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "ismissing": false, "hasadjustments": true, "external_edit": false, "favorite": false, "hidden": false, "latitude": -34.91889167000001, "longitude": 138.59686167, "path_edited": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/resources/renders/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907_1_201_a.jpeg", "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": false, "incloud": null, "date_modified": "2020-05-18T14:42:04.608664+09:30", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Elder Park, Adelaide, South Australia, Australia, River Torrens", "names": {"field0": [], "country": ["Australia"], "state_province": ["South Australia"], "sub_administrative_area": ["Adelaide"], "city": ["Adelaide", "Adelaide"], "field5": [], "additional_city_info": ["Adelaide CBD", "Tarndanya"], "ocean": [], "area_of_interest": ["Elder Park", ""], "inland_water": ["River Torrens", "River Torrens"], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": ["River Torrens", "River Torrens"]}, "country_code": "AU", "ishome": false, "address_str": "River Torrens, Adelaide SA, Australia", "address": {"street": null, "sub_locality": "Tarndanya", "city": "Adelaide", "sub_administrative_area": "Adelaide", "state_province": "SA", "postal_code": null, "country": "Australia", "iso_country_code": "AU"}}, "exif": {"flash_fired": false, "iso": 320, "metering_mode": 3, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.058823529411764705, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
SIDECAR_DATA = """FOO_BAR"""
EXIF_DATA2 = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "XMP:Title": "St. James's Park", "XMP:TagsList": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "IPTC:Keywords": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "XMP:Subject": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "EXIF:GPSLatitude": "51 deg 30' 12.86\" N", "EXIF:GPSLongitude": "0 deg 7' 54.50\" W", "Composite:GPSPosition": "51 deg 30' 12.86\" N, 0 deg 7' 54.50\" W", "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", "EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:08 14:06:44"}]"""
INFO_DATA2 = """{"uuid": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529", "filename": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "original_filename": "IMG_8440.JPG", "date": "2019-06-11T11:42:06.711805-07:00", "description": null, "title": null, "keywords": [], "labels": ["Sky", "Cloudy", "Fence", "Land", "Outdoor", "Park", "Amusement Park", "Roller Coaster"], "albums": [], "folders": {}, "persons": [], "path": "/Volumes/MacBook Catalina - Data/Users/rhet/Pictures/Photos Library.photoslibrary/originals/F/F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "ismissing": false, "hasadjustments": false, "external_edit": false, "favorite": false, "hidden": false, "latitude": 33.81558666666667, "longitude": -117.99298, "path_edited": null, "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": true, "incloud": true, "date_modified": "2019-10-14T00:51:47.141950-07:00", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Adventure City, Stanton, California, United States", "names": {"field0": [], "country": ["United States"], "state_province": ["California"], "sub_administrative_area": ["Orange"], "city": ["Stanton", "Anaheim", "Anaheim"], "field5": [], "additional_city_info": ["West Anaheim"], "ocean": [], "area_of_interest": ["Adventure City", "Adventure City"], "inland_water": [], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": []}, "country_code": "US", "ishome": false, "address_str": "Adventure City, 1240 S Beach Blvd, Anaheim, CA 92804, United States", "address": {"street": "1240 S Beach Blvd", "sub_locality": "West Anaheim", "city": "Stanton", "sub_administrative_area": "Orange", "state_province": "CA", "postal_code": "92804", "country": "United States", "iso_country_code": "US"}}, "exif": {"flash_fired": false, "iso": 25, "metering_mode": 5, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.0004940711462450593, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
DATABASE_VERSION1 = "tests/export_db_version1.db"
@@ -41,6 +43,8 @@ def test_export_db():
assert db.get_stat_edited_for_file(filepath) == (10, 11, 12)
db.set_stat_converted_for_file(filepath, (7, 8, 9))
assert db.get_stat_converted_for_file(filepath) == (7, 8, 9)
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
# test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -109,6 +113,8 @@ def test_export_db_no_op():
assert db.get_stat_converted_for_file(filepath) is None
db.set_stat_edited_for_file(filepath, (10, 11, 12))
assert db.get_stat_edited_for_file(filepath) is None
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_sidecar_for_file(filepath) == (None, (None, None, None))
# test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -160,6 +166,7 @@ def test_export_db_in_memory():
db.set_stat_exif_for_file(filepath, (4, 5, 6))
db.set_stat_converted_for_file(filepath, (7, 8, 9))
db.set_stat_edited_for_file(filepath, (10, 11, 12))
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
db.close()
@@ -176,6 +183,7 @@ def test_export_db_in_memory():
assert dbram.get_stat_exif_for_file(filepath) == (4, 5, 6)
assert dbram.get_stat_converted_for_file(filepath) == (7, 8, 9)
assert dbram.get_stat_edited_for_file(filepath) == (10, 11, 12)
assert dbram.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
# change a value
dbram.set_uuid_for_file(filepath, "FUBAR")
@@ -185,6 +193,7 @@ def test_export_db_in_memory():
dbram.set_stat_exif_for_file(filepath, (10, 11, 12))
dbram.set_stat_converted_for_file(filepath, (1, 2, 3))
dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -193,6 +202,7 @@ def test_export_db_in_memory():
assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12)
assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3)
assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6)
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
dbram.close()
@@ -205,6 +215,7 @@ def test_export_db_in_memory():
assert db.get_stat_exif_for_file(filepath) == (4, 5, 6)
assert db.get_stat_converted_for_file(filepath) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath) == (10, 11, 12)
assert db.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
assert db.get_info_for_uuid("FUBAR") is None
@@ -232,6 +243,7 @@ def test_export_db_in_memory_nofile():
dbram.set_stat_exif_for_file(filepath, (10, 11, 12))
dbram.set_stat_converted_for_file(filepath, (1, 2, 3))
dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -240,5 +252,6 @@ def test_export_db_in_memory_nofile():
assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12)
assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3)
assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6)
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
dbram.close()

View File

@@ -1,6 +1,13 @@
""" Test template.py """
import pytest
from osxphotos.exiftool import get_exiftool_path
try:
exiftool = get_exiftool_path()
except:
exiftool = None
PHOTOS_DB_PLACES = (
"./tests/Test-Places-Catalina-10_15_7.photoslibrary/database/photos.db"
)
@@ -57,6 +64,29 @@ UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"}
# Boolean type values that render to False
UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"}
# for exiftool template
UUID_EXIFTOOL = {
"A92D9C26-3A50-4197-9388-CB5F7DB9FA91": {
"{exiftool:EXIF:Make}": ["Canon"],
"{exiftool:EXIF:Model}": ["Canon PowerShot G10"],
"{exiftool:EXIF:Make}/{exiftool:EXIF:Model}": ["Canon/Canon PowerShot G10"],
"{exiftool:IPTC:Keywords,foo}": ["foo"],
},
"DC99FBDD-7A52-4100-A5BB-344131646C30": {
"{exiftool:IPTC:Keywords}": [
"England",
"London",
"London 2018",
"St. James's Park",
"UK",
"United Kingdom",
],
"{,+exiftool:IPTC:Keywords}": [
"England,London,London 2018,St. James's Park,UK,United Kingdom"
],
},
}
TEMPLATE_VALUES = {
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
"{original_name}": "IMG_1064",
@@ -737,3 +767,15 @@ def test_expand_in_place_with_delim_single_value():
for template in TEMPLATE_VALUES_TITLE:
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(TEMPLATE_VALUES_TITLE[template])
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_exiftool_template():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
for uuid in UUID_EXIFTOOL:
photo = photosdb.get_photo(uuid)
for template in UUID_EXIFTOOL[uuid]:
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(UUID_EXIFTOOL[uuid][template])