diff --git a/README.md b/README.md index aa3a00e9..85943fdb 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,8 @@ Options: 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 ** diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 2b6d4ef2..e7339d37 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1413,6 +1413,12 @@ def query( "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)) @click.pass_obj @@ -1505,6 +1511,7 @@ def export( deleted_only, use_photos_export, use_photokit, + report, ): """ Export photos from the Photos database. Export path DEST is required. @@ -1522,7 +1529,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 = [ @@ -1729,8 +1741,11 @@ def export( results_skipped = [] results_exif_updated = [] results_touched = [] - results_sidecar_json = [] - results_sidecar_xmp = [] + 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( @@ -1774,8 +1789,11 @@ def export( results_skipped.extend(results.skipped) results_exif_updated.extend(results.exif_updated) results_touched.extend(results.touched) - results_sidecar_json.extend(results.sidecar_json) - results_sidecar_xmp.extend(results.sidecar_xmp) + 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( @@ -1828,8 +1846,11 @@ def export( results_skipped.extend(results.skipped) results_exif_updated.extend(results.exif_updated) results_touched.extend(results.touched) - results_sidecar_json.extend(results.sidecar_json) - results_sidecar_xmp.extend(results.sidecar_xmp) + 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 @@ -1839,9 +1860,27 @@ def export( # 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" @@ -2391,19 +2430,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 = [] @@ -2411,8 +2450,11 @@ def export_photo( results_skipped = [] results_exif_updated = [] results_touched = [] - results_sidecar_json = [] - results_sidecar_xmp = [] + 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) @@ -2507,8 +2549,11 @@ def export_photo( results_skipped.extend(export_results.skipped) results_exif_updated.extend(export_results.exif_updated) results_touched.extend(export_results.touched) - results_sidecar_json.extend(export_results.sidecar_json) - results_sidecar_xmp.extend(export_results.sidecar_xmp) + 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.exported: @@ -2580,8 +2625,19 @@ def export_photo( results_skipped.extend(export_results_edited.skipped) results_exif_updated.extend(export_results_edited.exif_updated) results_touched.extend(export_results_edited.touched) - results_sidecar_json.extend(export_results_edited.sidecar_json) - results_sidecar_xmp.extend(export_results_edited.sidecar_xmp) + 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: @@ -2602,8 +2658,11 @@ def export_photo( results_skipped, results_exif_updated, results_touched, - results_sidecar_json, - results_sidecar_xmp, + results_converted, + results_sidecar_json_written, + results_sidecar_json_skipped, + results_sidecar_xmp_written, + results_sidecar_xmp_skipped, ) @@ -2755,5 +2814,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 diff --git a/osxphotos/_version.py b/osxphotos/_version.py index ada88bec..ed191aac 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,4 +1,4 @@ """ version info """ -__version__ = "0.37.0" +__version__ = "0.37.1" diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index 8ad9f4ac..d1aa359f 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -53,8 +53,11 @@ ExportResults = namedtuple( "skipped", "exif_updated", "touched", - "sidecar_json", - "sidecar_xmp", + "converted_to_jpeg", + "sidecar_json_written", + "sidecar_json_skipped", + "sidecar_xmp_written", + "sidecar_xmp_skipped", ], ) @@ -425,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: @@ -597,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: @@ -622,6 +629,7 @@ def export2( update_updated_files.extend(results.updated) update_skipped_files.extend(results.skipped) touched_files.extend(results.touched) + converted_to_jpeg_files.extend(results.converted_to_jpeg) # copy associated RAW image if requested if raw_photo and self.has_raw: @@ -648,6 +656,7 @@ def export2( update_updated_files.extend(results.updated) update_skipped_files.extend(results.skipped) touched_files.extend(results.touched) + converted_to_jpeg_files.extend(results.converted_to_jpeg) else: # use_photo_export exported = [] @@ -748,10 +757,10 @@ def export2( ) # export metadata - sidecar_json_files = [] + sidecar_json_files_skipped = [] + sidecar_json_files_written = [] if sidecar_json: sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json") - sidecar_json_files.append(str(sidecar_filename)) sidecar_str = self._exiftool_json_sidecar( use_albums_as_keywords=use_albums_as_keywords, use_persons_as_keywords=use_persons_as_keywords, @@ -774,6 +783,7 @@ def export2( ) 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) export_db.set_sidecar_for_file( @@ -783,11 +793,12 @@ def export2( ) else: verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}") + sidecar_json_files_skipped.append(str(sidecar_filename)) - sidecar_xmp_files = [] + sidecar_xmp_files_skipped = [] + sidecar_xmp_files_written = [] if sidecar_xmp: sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp") - sidecar_xmp_files.append(str(sidecar_filename)) sidecar_str = self._xmp_sidecar( use_albums_as_keywords=use_albums_as_keywords, use_persons_as_keywords=use_persons_as_keywords, @@ -810,6 +821,7 @@ def export2( ) 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) export_db.set_sidecar_for_file( @@ -819,6 +831,7 @@ def export2( ) 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: @@ -918,8 +931,11 @@ def export2( update_skipped_files, exif_files_updated, touched_files, - sidecar_json_files, - sidecar_xmp_files, + converted_to_jpeg_files, + sidecar_json_files_written, + sidecar_json_files_skipped, + sidecar_xmp_files_written, + sidecar_xmp_files_skipped, ) return results @@ -976,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() @@ -1065,6 +1082,7 @@ 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) @@ -1090,6 +1108,9 @@ def _export_photo( update_skipped_files, [], touched_files, + converted_to_jpeg_files, + [], + [], [], [], ) diff --git a/tests/test_cli.py b/tests/test_cli.py index d5ed28ec..4f4f3c81 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3763,3 +3763,44 @@ 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 +