Skip to content

Commit

Permalink
Initial implementation of #1526
Browse files Browse the repository at this point in the history
  • Loading branch information
RhetTbull committed Apr 20, 2024
1 parent d6199a8 commit 0666a91
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 31 deletions.
4 changes: 4 additions & 0 deletions osxphotos/exportoptions.py
Expand Up @@ -150,6 +150,7 @@ class ExportResults:
missing_album: list of tuples of (file, album) for any files that were missing from an album
new: list of files that were new
aae_written: list of files where .AAE file was written
aae_skipped: list of files where .AAE file was written
sidecar_exiftool_skipped: list of files where exiftool sidecar was skipped
sidecar_exiftool_written: list of files where exiftool sidecar was written
sidecar_json_skipped: list of files where json sidecar was skipped
Expand Down Expand Up @@ -195,6 +196,7 @@ class ExportResults:
"missing_album",
"new",
"aae_written",
"aae_skipped",
"sidecar_exiftool_skipped",
"sidecar_exiftool_written",
"sidecar_json_skipped",
Expand Down Expand Up @@ -232,6 +234,7 @@ def __init__(
missing_album: list[tuple[str, str]] | None = None,
new: list[str] | None = None,
aae_written: list[str] | None = None,
aae_skipped: list[str] | None = None,
sidecar_exiftool_skipped: list[str] | None = None,
sidecar_exiftool_written: list[str] | None = None,
sidecar_json_skipped: list[str] | None = None,
Expand Down Expand Up @@ -282,6 +285,7 @@ def all_files(self) -> list[str]:
+ self.touched
+ self.converted_to_jpeg
+ self.aae_written
+ self.aae_skipped
+ self.sidecar_json_written
+ self.sidecar_json_skipped
+ self.sidecar_exiftool_written
Expand Down
119 changes: 89 additions & 30 deletions osxphotos/photoexporter.py
Expand Up @@ -92,6 +92,7 @@ def __init__(
edited_live: t.Optional[str] = None,
preview: t.Optional[str] = None,
raw: t.Optional[str] = None,
aae: t.Optional[str] = None,
error: t.Optional[t.List[str]] = None,
):
self.original = original
Expand All @@ -100,6 +101,7 @@ def __init__(
self.edited_live = edited_live
self.preview = preview
self.raw = raw
self.aae = aae
self.error = error or []

# TODO: bursts?
Expand Down Expand Up @@ -354,8 +356,22 @@ def export(
f"Skipping missing preview photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})"
)

if options.export_aae:
all_results += self._write_aae_file(dest=dest, options=options)
if export_original and options.export_aae:
# export associated AAE adjustments file if requested but only for original images
# AAE applies changes to the original so is not meaningful for the edited image
if staged_files.aae:
aae_path = pathlib.Path(staged_files.aae)
aae_name = normalize_fs_path(dest.with_suffix(".AAE"))
all_results += self._export_aae(
aae_path,
aae_name,
options=options,
)
else:
verbose(
f"Skipping adjustments for {self._filename(self.photo.original_filename)}: no AAE adjustments file"
)

sidecar_writer = SidecarWriter(self.photo)
all_results += sidecar_writer.write_sidecar_files(dest=dest, options=options)

Expand Down Expand Up @@ -590,6 +606,8 @@ def _stage_photos_for_export(self, options: ExportOptions) -> StagedFiles:
staged.original = self.photo.path
if options.live_photo and self.photo.live_photo:
staged.original_live = self.photo.path_live_photo
if options.export_aae:
staged.aae = self.photo.adjustments_path

if options.edited:
# edited file
Expand Down Expand Up @@ -1181,12 +1199,13 @@ def _export_photo_uuid_applescript(
exported_paths.append(str(dest_new))
return exported_paths

def _write_aae_file(
def _export_aae(
self,
src: pathlib.Path,
dest: pathlib.Path,
options: ExportOptions,
) -> ExportResults:
"""Write AAE file for the photo."""
"""Export AAE file for the photo."""

# AAE files describe adjustments to originals, so they don't make sense
# for edited files
Expand All @@ -1195,42 +1214,82 @@ def _write_aae_file(

verbose = options.verbose or self._verbose

aae_src = self.photo.adjustments_path
if aae_src is None:
verbose(
f"Skipping adjustments for {self._filename(self.photo.original_filename)}: no AAE adjustments file"
)
return ExportResults()
aae_dest = normalize_fs_path(dest.with_suffix(".AAE"))
action = None

if options.update or options.force_update: # updating
if dest.exists():
if update_reason := self._should_update_photo(src, dest, options):
print(f"{update_reason=} {src=} {dest=}")
action = "update: " + update_reason.name
else:
# update_skipped_files.append(dest_str)
action = "skip"
else:
action = "new"
else:
action = "export"

if action == "skip":
if dest.exists():
options.export_db.set_history(
filename=str(dest), uuid=self.photo.uuid, action=action, diff=None
)
verbose(f"Skipping up to date AAE file {dest}")
return ExportResults(aae_skipped=[])
else:
action = "export"

print(f"action={action}")

errors = []
if dest.exists() and any(
[options.overwrite, options.update, options.force_update]
):
try:
options.fileutil.unlink(dest)
except Exception as e:
errors.append(f"Error removing file {dest}: {e} (({lineno(__file__)})")

if options.export_as_hardlink:
try:
if aae_dest.exists() and any(
[options.overwrite, options.update, options.force_update]
):
try:
options.fileutil.unlink(aae_dest)
except Exception as e:
raise ExportError(
f"Error removing file {aae_dest}: {e} (({lineno(__file__)})"
) from e
options.fileutil.hardlink(aae_src, aae_dest)
options.fileutil.hardlink(src, dest)
except Exception as e:
raise ExportError(
f"Error hardlinking {aae_src} to {aae_dest}: {e} ({lineno(__file__)})"
) from e
errors.append(
f"Error hardlinking {src} to {dest}: {e} ({lineno(__file__)})"
)
else:
try:
options.fileutil.copy(aae_src, aae_dest)
options.fileutil.copy(src, dest)
except Exception as e:
raise ExportError(
f"Error copying file {aae_src} to {aae_dest}: {e} ({lineno(__file__)})"
) from e
errors.append(
f"Error copying file {src} to {dest}: {e} ({lineno(__file__)})"
)

# set data in the database
fileutil = options.fileutil
with options.export_db.create_or_get_file_record(
str(dest), self.photo.uuid
) as rec:
# don't set src_sig as that is set above before any modifications by convert_to_jpeg or exiftool
rec.src_sig = fileutil.file_sig(src)
if not options.ignore_signature:
rec.dest_sig = fileutil.file_sig(dest)
rec.export_options = options.bit_flags
if errors:
rec.error = {
"error": errors,
"exiftool_error": None,
"exiftool_warning": None,
}

options.export_db.set_history(
filename=str(dest), uuid=self.photo.uuid, action=action, diff=None
)

verbose(
f"Exported adjustments of {self._filename(self.photo.original_filename)} to {self._filepath(aae_dest)}"
f"Exported adjustments of {self._filename(self.photo.original_filename)} to {self._filepath(dest)}"
)
return ExportResults(aae_written=[aae_dest])
return ExportResults(aae_written=[dest], error=errors)

def write_exiftool_metadata_to_file(
self,
Expand Down
43 changes: 42 additions & 1 deletion tests/test_cli.py
Expand Up @@ -3853,6 +3853,47 @@ def test_export_aae_as_hardlink():
files = glob.glob("*.*")
assert sorted(files) == sorted(CLI_EXPORT_AAE_FILENAMES)

def test_export_aae_update():
"""Test export with --export-aae --update"""

runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli_main,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--export-aae",
f"--uuid={CLI_EXPORT_AAE_UUID}",
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*.*")
assert sorted(files) == sorted(CLI_EXPORT_AAE_FILENAMES)

# now update
result = runner.invoke(
cli_main,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--export-aae",
f"--uuid={CLI_EXPORT_AAE_UUID}",
"--update",
"-V",
],
)
assert result.exit_code == 0
assert "Error" not in result.output
assert re.match(r"Skipped up to date file.*\.AAE", result.output)


def test_export_sidecar():
"""test --sidecar"""
Expand Down Expand Up @@ -6496,7 +6537,7 @@ def test_export_ignore_signature():
def test_export_ignore_signature_sidecar():
"""test export with --ignore-signature and --sidecar"""
"""
Test the following use cases:
Test the following use cases:
If the metadata (in Photos) that went into the sidecar did not change, the sidecar will not be updated
If the metadata (in Photos) that went into the sidecar did change, a new sidecar is written but a new image file is not
If a sidecar does not exist for the photo, a sidecar will be written whether or not the photo file was written
Expand Down

0 comments on commit 0666a91

Please sign in to comment.