Skip to content

Commit

Permalink
osxphotos sync: add support for location (#1642)
Browse files Browse the repository at this point in the history
* Initial support for sync --set / --merge location

* typo sync.py

* refactor sync.py for location

* Update sync_results.py to cater for location

* Update conftest.py to run --tests-sync on commpatibility mode OS_VER: 10.16

* Update test_cli_sync.py: added location test
  • Loading branch information
oPromessa authored Aug 13, 2024
1 parent 9a07c29 commit c74e863
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 2 deletions.
87 changes: 86 additions & 1 deletion osxphotos/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"title",
"description",
"favorite",
"location",
]
SYNC_IMPORT_TYPES_ALL = ["all"] + SYNC_IMPORT_TYPES

Expand Down Expand Up @@ -374,11 +375,87 @@ def import_metadata_for_photo(
# add photo to any new albums but do not remove from existing albums
results += _update_albums_for_photo(photo, metadata, dry_run, verbose)

if "location" in set_:
# If --set use origin location in the destination photo
results += _set_location_for_photo(photo, metadata, dry_run, verbose)
elif "location" in merge:
# if --merge
# and property is set in the destination then no action is taken;
# if property is not set in the destination but is set in the source,
# then the value is copied to destination.
results += _merge_location_for_photo(photo, metadata, dry_run, verbose)

results += _set_metadata_for_photo(photo, metadata, set_, dry_run, verbose)
results += _merge_metadata_for_photo(photo, metadata, merge, dry_run, verbose)

return results

def _process_location_for_photo(
photo: PhotoInfo,
metadata: dict[str, Any],
dry_run: bool,
verbose: Callable[..., None],
merge: bool = False,
) -> SyncResults:
"""Set or merge location metadata for a photo."""
field = "location"
updated = False
results = SyncResults()
photo_ = photoscript.Photo(photo.uuid)

value = tuple(metadata.get(field, (None, None)))
before = getattr(photo, field, (None, None))

if merge:
if all(coord is None for coord in before):
if value != before:
updated = True
verbose(f"\tMerging {field} to {value} from {before}")
if not dry_run:
set_photo_property(photo_, field, value)
else:
verbose(f"\tNothing to do for {field}", level=2)
else:
verbose(f"\tLocation already set. Nothing done for {field}", level=2)
else:
if value != before:
updated = True
verbose(f"\tSetting {field} to {value} from {before}")
if not dry_run:
set_photo_property(photo_, field, value)
else:
verbose(f"\tNothing to do for {field}", level=2)

results.add_result(
photo.uuid,
photo.original_filename,
photo.fingerprint,
field,
updated,
before,
value)
return results


def _set_location_for_photo(
photo: PhotoInfo,
metadata: dict[str, Any],
dry_run: bool,
verbose: Callable[..., None],
) -> SyncResults:
"""Set location metadata 9even if (None, None) for a photo, if different."""
return _process_location_for_photo(photo, metadata, dry_run, verbose, merge=False)


def _merge_location_for_photo(
photo: PhotoInfo,
metadata: dict[str, Any],
dry_run: bool,
verbose: Callable[..., None],
) -> SyncResults:
"""Merge location metadata for a photo if not already set."""
return _process_location_for_photo(photo, metadata, dry_run, verbose, merge=True)


def _update_albums_for_photo(
photo: PhotoInfo,
Expand Down Expand Up @@ -438,9 +515,12 @@ def _set_metadata_for_photo(
if field == "albums":
continue

if field == "location":
continue

value = metadata[field]
before = getattr(photo, field)

if isinstance(value, list):
value = sorted(value)
if isinstance(before, list):
Expand Down Expand Up @@ -480,6 +560,9 @@ def _merge_metadata_for_photo(
if field == "albums":
continue

if field == "location":
continue

value = metadata[field]
before = getattr(photo, field)

Expand Down Expand Up @@ -547,6 +630,8 @@ def set_photo_property(photo: photoscript.Photo, property: str, value: Any):
raise ValueError(f"{property} must be a str, not {type(value)}")
elif property == "favorite":
value = bool(value)
elif property == "location":
value = (value[0], value[1])
elif property not in {"title", "description", "favorite", "keywords"}:
raise ValueError(f"Unknown property: {property}")
setattr(photo, property, value)
Expand Down
1 change: 1 addition & 0 deletions osxphotos/cli/sync_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"favorite",
"keywords",
"title",
"location",
]


Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def get_os_version():


OS_VER = get_os_version() if is_macos else [None, None]
if OS_VER[0] == "10" and OS_VER[1] == "15":
if OS_VER[0] == "10" and OS_VER[1] in ("15", "16"):
# Catalina
TEST_LIBRARY = "tests/Test-10.15.7.photoslibrary"
TEST_LIBRARY_IMPORT = TEST_LIBRARY
Expand Down
89 changes: 89 additions & 0 deletions tests/test_cli_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@

TEST_ALBUM_NAME = "SyncTestAlbum"

UUID_TEST_PHOTO_3 = "D1D4040D-D141-44E8-93EA-E403D9F63E07" # Frítest.jpg, No Location
UUID_TEST_PHOTO_4 = "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3" # Jellyfish1.mp4, Location
UUID_TEST_PHOTO_5 = "7783E8E6-9CAC-40F3-BE22-81FB7051C266" # IMG_3092.heic, Location

TEST_ALBUM_NAME_LOCATION = "SyncTestAlbumLocation"


@pytest.mark.test_sync
def test_sync_export():
Expand Down Expand Up @@ -114,3 +120,86 @@ def test_sync_export_import():
assert report_data[uuid]["updated"]
assert report_data[uuid]["albums"]["updated"]
assert not report_data[uuid]["error"]

@pytest.mark.test_sync
def test_sync_export_import_location():
"""Test --export and --import location"""

photoslib = photoscript.PhotosLibrary()

# create a new album and initialize metadata
test_album = photoslib.create_album(TEST_ALBUM_NAME_LOCATION)
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
photo = photoscript.Photo(uuid)
photo.favorite = True
test_album.add([photo])

# export data
with CliRunner().isolated_filesystem():
result = CliRunner().invoke(
sync,
[
"--export",
"test_location.db",
],
)
assert result.exit_code == 0

# preserve metadata for comparison and clear metadata
metadata_before = {}
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
photo = photoscript.Photo(uuid)
metadata_before[uuid] = {
"title": photo.title,
"description": photo.description,
"keywords": photo.keywords,
"favorites": photo.favorite,
"location": photo.location,
}
photo.title = ""
photo.description = ""
photo.keywords = ["NewKeyword"]
photo.favorite = False
photo.location = (24.681666439037876, 32.88630618597232)

# delete the test album
photoslib.delete_album(test_album)

# import metadata
result = CliRunner().invoke(
sync,
[
"--import",
"test_location.db",
"--set",
"title,description,favorite,albums,location",
"--merge",
"keywords",
"--report",
"test_report_location.json",
],
)
assert result.exit_code == 0
assert os.path.exists("test_report_location.json")

# check metadata
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
photo = photoscript.Photo(uuid)
assert photo.title == metadata_before[uuid]["title"]
assert photo.description == metadata_before[uuid]["description"]
assert sorted(photo.keywords) == sorted(
["NewKeyword", *metadata_before[uuid]["keywords"]]
)
assert photo.favorite == metadata_before[uuid]["favorites"]
assert photo.location == metadata_before[uuid]["location"]
assert TEST_ALBUM_NAME_LOCATION in [album.title for album in photo.albums]

# check report
with open("test_report_location.json", "r") as f:
report = json.load(f)
report_data = {record["uuid"]: record for record in report}
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
assert report_data[uuid]["updated"]
assert report_data[uuid]["albums"]["updated"]
assert report_data[uuid]["location"]["updated"]
assert not report_data[uuid]["error"]

0 comments on commit c74e863

Please sign in to comment.