diff --git a/README.md b/README.md index bd87608e..c74c84ef 100644 --- a/README.md +++ b/README.md @@ -203,14 +203,14 @@ Options: both images and movies). --only-photos Search only for photos/images (default searches both images and movies). - --from-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S] - Search by start item date, e.g. - 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 - w/o TZ). - --to-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S] - Search by end item date, e.g. - 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 - w/o TZ). + --from-date DATETIME Search by start item date, e.g. + 2000-01-12T12:00:00, + 2001-01-12T12:00:00-07:00, or 2000-12-31 + (ISO 8601). + --to-date DATETIME Search by end item date, e.g. + 2000-01-12T12:00:00, + 2001-01-12T12:00:00-07:00, or 2000-12-31 + (ISO 8601). --deleted Include photos from the 'Recently Deleted' folder. --deleted-only Include only photos from the 'Recently @@ -222,6 +222,8 @@ Options: --export-as-hardlink Hardlink files instead of copying them. Cannot be used with --exiftool which creates copies of the files with embedded EXIF data. + --touch-file Sets the file's modification time to match + photo date. --overwrite Overwrite existing files. Default behavior is to add (1), (2), etc to filename if file already exists. Use this with caution as it diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index fc141185..8e4749f4 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1124,7 +1124,7 @@ def query( @click.option( "--touch-file", is_flag=True, - help="Sets the file's modification time upon photo date.", + help="Sets the file's modification time to match photo date.", ) @click.option( "--overwrite", @@ -1269,6 +1269,13 @@ def query( "to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get " "an error while exporting.", ) +@click.option( + "--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).", +) @DB_ARGUMENT @click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.pass_obj @@ -1350,6 +1357,7 @@ def export( label, deleted, deleted_only, + use_photos_export, ): """ Export photos from the Photos database. Export path DEST is required. @@ -1520,7 +1528,6 @@ def export( deleted_only=deleted_only, ) - results_exported = [] if photos: if export_bursts: # add the burst_photos to the export set @@ -1539,6 +1546,7 @@ def export( # because the original code used --original-name as an option original_name = not current_name + results_exported = [] results_new = [] results_updated = [] results_skipped = [] @@ -1573,6 +1581,7 @@ def export( dry_run=dry_run, touch_file=touch_file, edited_suffix=edited_suffix, + use_photos_export=use_photos_export, ) results_exported.extend(results.exported) results_new.extend(results.new) @@ -1612,6 +1621,7 @@ def export( dry_run=dry_run, touch_file=touch_file, edited_suffix=edited_suffix, + use_photos_export=use_photos_export, ) results_exported.extend(results.exported) results_new.extend(results.new) @@ -1629,10 +1639,12 @@ def export( photo_str_exif_updated = ( "photos" if len(results_exif_updated) != 1 else "photo" ) - summary = f"Exported: {len(results_new)} {photo_str_new}, " \ - f"updated: {len(results_updated)} {photo_str_updated}, " \ - f"skipped: {len(results_skipped)} {photo_str_skipped}, " \ - f"updated EXIF data: {len(results_exif_updated)} {photo_str_exif_updated}" + summary = ( + f"Exported: {len(results_new)} {photo_str_new}, " + f"updated: {len(results_updated)} {photo_str_updated}, " + f"skipped: {len(results_skipped)} {photo_str_skipped}, " + f"updated EXIF data: {len(results_exif_updated)} {photo_str_exif_updated}" + ) if touch_file: summary += f", touched date: {len(results_touched)} {photo_str_touched}" click.echo(summary) @@ -2092,6 +2104,7 @@ def export_photo( dry_run=None, touch_file=None, edited_suffix="_edited", + use_photos_export=False, ): """ Helper function for export that does the actual export @@ -2119,7 +2132,8 @@ def export_photo( export_db: export database instance compatible with ExportDB_ABC fileutil: file util class compatible with FileUtilABC dry_run: boolean; if True, doesn't actually export or update any files - touch_file: boolean; sets file's modification time upon photo date + touch_file: boolean; sets file's modification time to match photo date + use_photos_export: boolean; if True forces the use of AppleScript to export even if photo not missing Returns: list of path(s) of exported photo or None if photo was missing @@ -2172,8 +2186,10 @@ def export_photo( # if download_missing and the photo is missing or path doesn't exist, # try to download with Photos - use_photos_export = download_missing and ( - photo.ismissing or not os.path.exists(photo.path) + use_photos_export = ( + download_missing and (photo.ismissing or not os.path.exists(photo.path)) + if not use_photos_export + else True ) # export the photo to each path in dest_paths @@ -2225,7 +2241,6 @@ def export_photo( if export_edited and photo.hasadjustments: # if download_missing and the photo is missing or path doesn't exist, # try to download with Photos - use_photos_export = download_missing and photo.path_edited is None if not download_missing and photo.path_edited is None: verbose(f"Skipping missing edited photo for {filename}") else: @@ -2266,7 +2281,7 @@ def export_photo( 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.touched) + results_touched.extend(export_results_edited.touched) if verbose_: for exported in export_results_edited.exported: @@ -2277,6 +2292,8 @@ def export_photo( 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}") return ExportResults( results_exported, @@ -2284,7 +2301,7 @@ def export_photo( results_updated, results_skipped, results_exif_updated, - results_touched + results_touched, ) diff --git a/osxphotos/_export_db.py b/osxphotos/_export_db.py index 1eaf944d..23726218 100644 --- a/osxphotos/_export_db.py +++ b/osxphotos/_export_db.py @@ -189,7 +189,12 @@ class ExportDB(ExportDB_ABC): (filename,), ) results = c.fetchone() - stats = results[0:3] if results else None + if results: + stats = results[0:3] + mtime = int(stats[2]) if stats[2] is not None else None + stats = (stats[0], stats[1], mtime) + else: + stats = (None, None, None) except Error as e: logging.warning(e) stats = (None, None, None) @@ -232,7 +237,12 @@ class ExportDB(ExportDB_ABC): (filename,), ) results = c.fetchone() - stats = results[0:3] if results else None + if results: + stats = results[0:3] + mtime = int(stats[2]) if stats[2] is not None else None + stats = (stats[0], stats[1], mtime) + else: + stats = (None, None, None) except Error as e: logging.warning(e) stats = (None, None, None) diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 19d85187..9f9a3090 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.33.1" +__version__ = "0.33.2" diff --git a/osxphotos/fileutil.py b/osxphotos/fileutil.py index 9b6487d3..f5609e09 100644 --- a/osxphotos/fileutil.py +++ b/osxphotos/fileutil.py @@ -34,7 +34,7 @@ class FileUtilABC(ABC): @classmethod @abstractmethod - def cmp(cls, file1, file2, mtime1 = None): + def cmp(cls, file1, file2, mtime1=None): pass @classmethod @@ -119,7 +119,7 @@ class FileUtilMacOS(FileUtilABC): os.utime(path, times) @classmethod - def cmp(cls, f1, f2, mtime1 = None): + def cmp(cls, f1, f2, mtime1=None): """Does shallow compare (file signatures) of f1 to file f2. Arguments: f1 -- File name diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index 49d1837e..cf52dfcb 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -629,7 +629,7 @@ def export2( logging.debug(f"Skipping missing RAW photo for {filename}") else: # use_photo_export - exported = None + exported = [] # export live_photo .mov file? live_photo = True if live_photo and self.live_photo else False if edited: @@ -639,7 +639,7 @@ def export2( filestem = dest.stem else: # didn't get passed a filename, add _edited - filestem = f"{dest.stem}_edited" + filestem = f"{dest.stem}{edited_identifier}" dest = dest.parent / f"{filestem}.jpeg" exported = _export_photo_uuid_applescript( @@ -668,9 +668,16 @@ def export2( dry_run=dry_run, ) - if exported is not None: - # TODO: add touch_file code + if exported: + if touch_file: + for exported_file in exported: + touched_files.append(exported_file) + ts = int(self.date.timestamp()) + fileutil.utime(exported_file, (ts, ts)) exported_files.extend(exported) + if update: + update_new_files.extend(exported) + else: logging.warning( f"Error exporting photo {self.uuid} to {dest} with use_photos_export" @@ -712,7 +719,6 @@ def export2( raise e # if exiftool, write the metadata - # TODO: add touch_file code to update touch time if update: exif_files = update_new_files + update_updated_files + update_skipped_files else: @@ -773,6 +779,7 @@ def export2( keyword_template=keyword_template, description_template=description_template, ) + export_db.set_exifdata_for_file( exported_file, self._exiftool_json_sidecar( @@ -787,7 +794,15 @@ def export2( ) exif_files_updated.append(exported_file) - return ExportResults( + if touch_file: + for exif_file in exif_files_updated: + touched_files.append(exported_file) + ts = int(self.date.timestamp()) + fileutil.utime(exported_file, (ts, ts)) + + touched_files = list(set(touched_files)) + + results = ExportResults( exported_files, update_new_files, update_updated_files, @@ -795,6 +810,7 @@ def export2( exif_files_updated, touched_files, ) + return results def _export_photo( @@ -863,22 +879,21 @@ def _export_photo( touched_files.append(dest_str) else: # update, destination exists, but we might not need to replace it... - cmp_orig = fileutil.cmp(src, dest) - cmp_touch = fileutil.cmp(src, dest, mtime1=self.date.timestamp()) + if exiftool: + sig_exif = export_db.get_stat_exif_for_file(dest_str) + cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif) + sig_exif = (sig_exif[0], sig_exif[1], int(self.date.timestamp())) + cmp_touch = fileutil.cmp_file_sig(dest_str, sig_exif) + else: + 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) - and ( - ( - exiftool - and fileutil.cmp_file_sig( - dest_str, export_db.get_stat_exif_for_file(dest_str) - ) - ) - or (not exiftool and sig_cmp) - ) + not export_as_hardlink and not dest.samefile(src) and sig_cmp ): + # destination exists and signatures match, skip it update_skipped_files.append(dest_str) else: # destination exists but signature is different @@ -910,18 +925,18 @@ def _export_photo( else: fileutil.copy(src, dest_str, norsrc=no_xattr) - if touched_files: - ts = self.date.timestamp() - fileutil.utime(dest, (ts, ts)) + export_db.set_data( + dest_str, + self.uuid, + fileutil.file_sig(dest_str), + (None, None, None), + self.json(), + None, + ) - export_db.set_data( - dest_str, - self.uuid, - fileutil.file_sig(dest_str), - (None, None, None), - self.json(), - None, - ) + if touched_files: + ts = int(self.date.timestamp()) + fileutil.utime(dest, (ts, ts)) return ExportResults( exported_files + update_new_files + update_updated_files, diff --git a/tests/test_cli.py b/tests/test_cli.py index 8143f36c..d745efd3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2304,6 +2304,16 @@ def test_export_update_exiftool(): in result.output ) + # update with exiftool again, should be no changes + result = runner.invoke( + export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update", "--exiftool"] + ) + assert result.exit_code == 0 + assert ( + "Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos" + in result.output + ) + def test_export_update_hardlink(): """ test export with hardlink then update """ @@ -2747,6 +2757,197 @@ def test_export_touch_files_update(): ) +@pytest.mark.skipif(exiftool is None, reason="exiftool not installed") +def test_export_touch_files_exiftool_update(): + """ test complex export scenario with --update, --exiftol, 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 --exiftool --dry-run + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--exiftool", + "--dry-run", + ], + ) + assert result.exit_code == 0 + + assert ( + "Exported: 0 photos, updated: 16 photos, skipped: 0 photos, updated EXIF data: 16 photos" + in result.output + ) + + # --update --exiftool + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--exiftool", + ], + ) + assert result.exit_code == 0 + + assert ( + "Exported: 0 photos, updated: 16 photos, skipped: 0 photos, updated EXIF data: 16 photos" + in result.output + ) + + # --update --touch-file --exiftool --dry-run + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--exiftool", + "--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: 16 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 --exiftool + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--exiftool", + "--touch-file", + ], + ) + assert result.exit_code == 0 + assert ( + "Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos, touched date: 16 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", + "--exiftool", + "--touch-file", + ], + ) + assert result.exit_code == 0 + assert ( + "Exported: 0 photos, updated: 1 photo, skipped: 15 photos, updated EXIF data: 1 photo, 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 --exiftool --touch-file again + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--update", + "--exiftool", + "--touch-file", + ], + ) + assert result.exit_code == 0 + assert ( + "Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos, touched date: 0 photos" + in result.output + ) + + # run update without --touch-file + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "--export-by-date", + "--exiftool", + "--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