diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 73714807..fc141185 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1623,7 +1623,7 @@ def export( # print summary results if update: photo_str_new = "photos" if len(results_new) != 1 else "photo" - photo_str_updated = "photos" if len(results_new) != 1 else "photo" + photo_str_updated = "photos" if len(results_updated) != 1 else "photo" photo_str_skipped = "photos" if len(results_skipped) != 1 else "photo" photo_str_touched = "photos" if len(results_touched) != 1 else "photo" photo_str_exif_updated = ( @@ -2217,6 +2217,8 @@ def export_photo( 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 diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index 33bd3a47..49d1837e 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -850,7 +850,10 @@ def _export_photo( logging.debug(f"Exporting file with {op_desc} {src} {dest}") exported_files.append(dest_str) if touch_file: - touched_files.append(dest_str) + sig = fileutil.file_sig(src) + sig = (sig[0], sig[1], self.date.timestamp()) + if not fileutil.cmp_file_sig(src, sig): + touched_files.append(dest_str) else: # updating if not dest_exists: # update, destination doesn't exist (new file) @@ -860,10 +863,9 @@ def _export_photo( touched_files.append(dest_str) else: # update, destination exists, but we might not need to replace it... - if touch_file: - sig_cmp = fileutil.cmp(src, dest, mtime1=self.date.timestamp()) - else: - sig_cmp = fileutil.cmp(src, dest) + cmp_orig = fileutil.cmp(src, dest) + cmp_touch = fileutil.cmp(src, dest, mtime1=self.date.timestamp()) + sig_cmp = cmp_touch if touch_file else cmp_orig if (export_as_hardlink and dest.samefile(src)) or ( not export_as_hardlink and not dest.samefile(src) @@ -877,18 +879,19 @@ def _export_photo( or (not exiftool and sig_cmp) ) ): - # destination exists but its signature is "identical" - # call set_stat because code can reach this spot if no export DB but exporting a RAW or live photo - # potentially re-writes the data in the database but ensures database is complete - export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str)) update_skipped_files.append(dest_str) else: # destination exists but signature is different - if touch_file and fileutil.cmp(src, dest) and not sig_cmp: + if touch_file and cmp_orig and not cmp_touch: # destination exists, signature matches original but does not match expected touch time # skip exporting but update touch time update_skipped_files.append(dest_str) touched_files.append(dest_str) + elif not touch_file and cmp_touch and not cmp_orig: + # destination exists, signature matches expected touch but not original + # user likely exported with touch_file and is now exporting without touch_file + # don't update the file because it's same but leave touch time + update_skipped_files.append(dest_str) else: # destination exists but is different update_updated_files.append(dest_str) diff --git a/tests/test_cli.py b/tests/test_cli.py index 83ddffba..8143f36c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,7 @@ PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-10_15_1.photoslibrary" 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.5.photoslibrary" PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary" UUID_FILE = "tests/uuid_from_file.txt" @@ -182,6 +183,7 @@ CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B" CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg" +CLI_EXPORT_BY_DATE_TOUCH_TIMES = [1538165373, 1538163349] CLI_EXPORT_BY_DATE = ["2018/09/28/Pumpkins3.jpg", "2018/09/28/Pumkins1.jpg"] CLI_EXPORT_SIDECAR_FILENAMES = ["Pumkins2.jpg", "Pumkins2.json", "Pumkins2.xmp"] @@ -706,7 +708,7 @@ def test_query_date_1(): import time from osxphotos.__main__ import query - os.environ['TZ'] = "US/Pacific" + os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() @@ -726,6 +728,7 @@ def test_query_date_1(): json_got = json.loads(result.output) assert len(json_got) == 4 + def test_query_date_2(): """ Test --from-date and --to-date """ import json @@ -735,7 +738,7 @@ def test_query_date_2(): import time from osxphotos.__main__ import query - os.environ['TZ'] = "Asia/Jerusalem" + os.environ["TZ"] = "Asia/Jerusalem" time.tzset() runner = CliRunner() @@ -755,6 +758,7 @@ def test_query_date_2(): json_got = json.loads(result.output) assert len(json_got) == 2 + def test_query_date_timezone(): """ Test --from-date, --to-date with ISO 8601 timezone """ import json @@ -764,7 +768,7 @@ def test_query_date_timezone(): import time from osxphotos.__main__ import query - os.environ['TZ'] = "US/Pacific" + os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() @@ -2587,6 +2591,162 @@ def test_export_directory_template_1_dry_run(): assert not os.path.isfile(os.path.join(workdir, filepath)) +def test_export_touch_files(): + """ test export with --touch-files """ + import os + + 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_6), + ".", + "-V", + "--touch-file", + "--export-by-date", + ], + ) + assert result.exit_code == 0 + + assert "Exported: 16 photos, touched date: 14 photos" in result.output + + for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): + st = os.stat(fname) + assert int(st.st_mtime) == mtime + + +def test_export_touch_files_update(): + """ test complex export scenario with --update and --touch-files """ + import os + import pathlib + import time + + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + # basic export with dry-run + result = runner.invoke( + export, + [os.path.join(cwd, PHOTOS_DB_15_6), ".", "--export-by-date", "--dry-run"], + ) + assert result.exit_code == 0 + + assert "Exported: 16 photos" in result.output + + assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() + + # without dry-run + result = runner.invoke( + export, [os.path.join(cwd, PHOTOS_DB_15_6), ".", "--export-by-date"] + ) + assert result.exit_code == 0 + + assert "Exported: 16 photos" in result.output + + assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() + + # --update + result = runner.invoke( + export, + [os.path.join(cwd, PHOTOS_DB_15_6), ".", "--export-by-date", "--update"], + ) + assert result.exit_code == 0 + + assert ( + "Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos" + in result.output + ) + + # --update --touch-file --dry-run + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--touch-file", + "--dry-run", + ], + ) + assert result.exit_code == 0 + assert ( + "Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos, touched date: 14 photos" + in result.output + ) + + for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): + st = os.stat(fname) + assert int(st.st_mtime) != mtime + + # --update --touch-file + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--touch-file", + ], + ) + assert result.exit_code == 0 + assert ( + "Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos, touched date: 14 photos" + in result.output + ) + + for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): + st = os.stat(fname) + assert int(st.st_mtime) == mtime + + # touch one file and run update again + ts = time.time() + os.utime(CLI_EXPORT_BY_DATE[0], (ts, ts)) + + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--touch-file", + ], + ) + assert result.exit_code == 0 + assert ( + "Exported: 0 photos, updated: 1 photo, skipped: 15 photos, updated EXIF data: 0 photos, touched date: 1 photo" + in result.output + ) + + for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): + st = os.stat(fname) + assert int(st.st_mtime) == mtime + + # run update without --touch-file + result = runner.invoke( + export, + [os.path.join(cwd, PHOTOS_DB_15_6), ".", "--export-by-date", "--update"], + ) + assert result.exit_code == 0 + + assert ( + "Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos" + in result.output + ) + + def test_labels(): """Test osxphotos labels """ import json