Finished --touch-file, closes #206
This commit is contained in:
18
README.md
18
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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.33.1"
|
||||
__version__ = "0.33.2"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user