Added --cleanup, issue #262
This commit is contained in:
131
README.md
131
README.md
@@ -119,12 +119,12 @@ Example: `osxphotos help export`
|
|||||||
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
||||||
|
|
||||||
Export photos from the Photos database. Export path DEST is required.
|
Export photos from the Photos database. Export path DEST is required.
|
||||||
Optionally, query the Photos database using 1 or more search options; if
|
Optionally, query the Photos database using 1 or more search options; if
|
||||||
more than one option is provided, they are treated as "AND" (e.g. search
|
more than one option is provided, they are treated as "AND" (e.g. search
|
||||||
for photos matching all options). If no query options are provided, all
|
for photos matching all options). If no query options are provided, all
|
||||||
photos will be exported. By default, all versions of all photos will be
|
photos will be exported. By default, all versions of all photos will be
|
||||||
exported including edited versions, live photo movies, burst photos, and
|
exported including edited versions, live photo movies, burst photos, and
|
||||||
associated raw images. See --skip-edited, --skip-live, --skip-bursts, and
|
associated raw images. See --skip-edited, --skip-live, --skip-bursts, and
|
||||||
--skip-raw options to modify this behavior.
|
--skip-raw options to modify this behavior.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -265,6 +265,79 @@ Options:
|
|||||||
photos if the raw photo does not have an
|
photos if the raw photo does not have an
|
||||||
associated jpeg image (e.g. the raw file was
|
associated jpeg image (e.g. the raw file was
|
||||||
imported to Photos without a jpeg preview).
|
imported to Photos without a jpeg preview).
|
||||||
|
--current-name Use photo's current filename instead of
|
||||||
|
original filename for export. Note:
|
||||||
|
Starting with Photos 5, all photos are
|
||||||
|
renamed upon import. By default, photos are
|
||||||
|
exported with the the original name they had
|
||||||
|
before import.
|
||||||
|
--convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC,
|
||||||
|
PNG, etc) to JPEG upon export. Only works
|
||||||
|
if your Mac has a GPU.
|
||||||
|
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
|
||||||
|
--convert-to-jpeg. A value of 1.0 specifies
|
||||||
|
best quality, a value of 0.0 specifies
|
||||||
|
maximum compression. Defaults to 1.0.
|
||||||
|
--download-missing Attempt to download missing photos from
|
||||||
|
iCloud. The current implementation uses
|
||||||
|
Applescript to interact with Photos to
|
||||||
|
export the photo which will force Photos to
|
||||||
|
download from iCloud if the photo does not
|
||||||
|
exist on disk. This will be slow and will
|
||||||
|
require internet connection. This obviously
|
||||||
|
only works if the Photos library is synched
|
||||||
|
to iCloud. Note: --download-missing does
|
||||||
|
not currently export all burst images; only
|
||||||
|
the primary photo will be exported--
|
||||||
|
associated burst images will be skipped.
|
||||||
|
--sidecar FORMAT Create sidecar for each photo exported;
|
||||||
|
valid FORMAT values: xmp, json; --sidecar
|
||||||
|
json: create JSON sidecar useable by
|
||||||
|
exiftool (https://exiftool.org/) The sidecar
|
||||||
|
file can be used to apply metadata to the
|
||||||
|
file with exiftool, for example: "exiftool
|
||||||
|
-j=photoname.jpg.json photoname.jpg" The
|
||||||
|
sidecar file is named in format
|
||||||
|
photoname.ext.json --sidecar xmp: create
|
||||||
|
XMP sidecar used by Adobe Lightroom, etc.The
|
||||||
|
sidecar file is named in format
|
||||||
|
photoname.ext.xmpThe XMP sidecar exports the
|
||||||
|
following tags: Description, Title,
|
||||||
|
Keywords/Tags, Subject (set to Keywords +
|
||||||
|
PersonInImage), PersonInImage, CreateDate,
|
||||||
|
ModifyDate, GPSLongitude. For a list of tags
|
||||||
|
exported in the JSON sidecar, see
|
||||||
|
--exiftool.
|
||||||
|
--exiftool Use exiftool to write metadata directly to
|
||||||
|
exported photos. To use this option,
|
||||||
|
exiftool must be installed and in the path.
|
||||||
|
exiftool may be installed from
|
||||||
|
https://exiftool.org/. Cannot be used with
|
||||||
|
--export-as-hardlink. Writes the following
|
||||||
|
metadata: EXIF:ImageDescription,
|
||||||
|
XMP:Description (see also --description-
|
||||||
|
template); XMP:Title; XMP:TagsList,
|
||||||
|
IPTC:Keywords (see also --keyword-template,
|
||||||
|
--person-keyword, --album-keyword);
|
||||||
|
XMP:Subject (set to keywords + person in
|
||||||
|
image to mirror Photos' behavior);
|
||||||
|
XMP:PersonInImage; EXIF:GPSLatitudeRef;
|
||||||
|
EXIF:GPSLongitudeRef; EXIF:GPSLatitude;
|
||||||
|
EXIF:GPSLongitude; EXIF:GPSPosition;
|
||||||
|
EXIF:DateTimeOriginal;
|
||||||
|
EXIF:OffsetTimeOriginal; EXIF:ModifyDate
|
||||||
|
(see --ignore-date-modified);
|
||||||
|
IPTC:DateCreated; IPTC:TimeCreated; (video
|
||||||
|
files only): QuickTime:CreationDate (UTC);
|
||||||
|
QuickTime:ModifyDate (UTC) (see also
|
||||||
|
--ignore-date-modified);
|
||||||
|
QuickTime:GPSCoordinates;
|
||||||
|
UserData:GPSCoordinates.
|
||||||
|
--ignore-date-modified If used with --exiftool or --sidecar, will
|
||||||
|
ignore the photo modification date and set
|
||||||
|
EXIF:ModifyDate to EXIF:DateTimeOriginal;
|
||||||
|
this is consistent with how Photos handles
|
||||||
|
the EXIF:ModifyDate tag.
|
||||||
--person-keyword Use person in image as keyword/tag when
|
--person-keyword Use person in image as keyword/tag when
|
||||||
exporting metadata.
|
exporting metadata.
|
||||||
--album-keyword Use album name as keyword/tag when exporting
|
--album-keyword Use album name as keyword/tag when exporting
|
||||||
@@ -291,53 +364,6 @@ Options:
|
|||||||
could specify --description-template
|
could specify --description-template
|
||||||
"{descr} exported with osxphotos on
|
"{descr} exported with osxphotos on
|
||||||
{today.date}" See Templating System below.
|
{today.date}" See Templating System below.
|
||||||
--current-name Use photo's current filename instead of
|
|
||||||
original filename for export. Note:
|
|
||||||
Starting with Photos 5, all photos are
|
|
||||||
renamed upon import. By default, photos are
|
|
||||||
exported with the the original name they had
|
|
||||||
before import.
|
|
||||||
--convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC,
|
|
||||||
PNG, etc) to JPEG upon export. Only works
|
|
||||||
if your Mac has a GPU.
|
|
||||||
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
|
|
||||||
--convert-to-jpeg. A value of 1.0 specifies
|
|
||||||
best quality, a value of 0.0 specifies
|
|
||||||
maximum compression. Defaults to 1.0.
|
|
||||||
--sidecar FORMAT Create sidecar for each photo exported;
|
|
||||||
valid FORMAT values: xmp, json; --sidecar
|
|
||||||
json: create JSON sidecar useable by
|
|
||||||
exiftool (https://exiftool.org/) The sidecar
|
|
||||||
file can be used to apply metadata to the
|
|
||||||
file with exiftool, for example: "exiftool
|
|
||||||
-j=photoname.json photoname.jpg" The sidecar
|
|
||||||
file is named in format photoname.json
|
|
||||||
--sidecar xmp: create XMP sidecar used by
|
|
||||||
Adobe Lightroom, etc.The sidecar file is
|
|
||||||
named in format photoname.xmp
|
|
||||||
--download-missing Attempt to download missing photos from
|
|
||||||
iCloud. The current implementation uses
|
|
||||||
Applescript to interact with Photos to
|
|
||||||
export the photo which will force Photos to
|
|
||||||
download from iCloud if the photo does not
|
|
||||||
exist on disk. This will be slow and will
|
|
||||||
require internet connection. This obviously
|
|
||||||
only works if the Photos library is synched
|
|
||||||
to iCloud. Note: --download-missing does
|
|
||||||
not currently export all burst images; only
|
|
||||||
the primary photo will be exported--
|
|
||||||
associated burst images will be skipped.
|
|
||||||
--exiftool Use exiftool to write metadata directly to
|
|
||||||
exported photos. To use this option,
|
|
||||||
exiftool must be installed and in the path.
|
|
||||||
exiftool may be installed from
|
|
||||||
https://exiftool.org/. Cannot be used with
|
|
||||||
--export-as-hardlink.
|
|
||||||
--ignore-date-modified If used with --exiftool or --sidecar, will
|
|
||||||
ignore the photo modification date and set
|
|
||||||
EXIF:ModifyDate to EXIF:DateTimeOriginal;
|
|
||||||
this is consistent with how Photos handles
|
|
||||||
the EXIF:ModifyDate tag.
|
|
||||||
--directory DIRECTORY Optional template for specifying name of
|
--directory DIRECTORY Optional template for specifying name of
|
||||||
output directory in the form
|
output directory in the form
|
||||||
'{name,DEFAULT}'. See below for additional
|
'{name,DEFAULT}'. See below for additional
|
||||||
@@ -379,6 +405,11 @@ Options:
|
|||||||
default AppleScript interface.
|
default AppleScript interface.
|
||||||
--report REPORTNAME.CSV Write a CSV formatted report of all files
|
--report REPORTNAME.CSV Write a CSV formatted report of all files
|
||||||
that were exported.
|
that were exported.
|
||||||
|
--cleanup Cleanup export directory by deleting any
|
||||||
|
files which were not included in this export
|
||||||
|
set. For example, photos which had
|
||||||
|
previously been exported and were
|
||||||
|
subsequently deleted in Photos.
|
||||||
-h, --help Show this message and exit.
|
-h, --help Show this message and exit.
|
||||||
|
|
||||||
** Export **
|
** Export **
|
||||||
|
|||||||
@@ -1436,6 +1436,12 @@ def query(
|
|||||||
help="Write a CSV formatted report of all files that were exported.",
|
help="Write a CSV formatted report of all files that were exported.",
|
||||||
type=click.Path(),
|
type=click.Path(),
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--cleanup",
|
||||||
|
is_flag=True,
|
||||||
|
help="Cleanup export directory by deleting any files which were not included in this export set. "
|
||||||
|
"For example, photos which had previously been exported and were subsequently deleted in Photos.",
|
||||||
|
)
|
||||||
@DB_ARGUMENT
|
@DB_ARGUMENT
|
||||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
@@ -1530,6 +1536,7 @@ def export(
|
|||||||
use_photos_export,
|
use_photos_export,
|
||||||
use_photokit,
|
use_photokit,
|
||||||
report,
|
report,
|
||||||
|
cleanup,
|
||||||
):
|
):
|
||||||
"""Export photos from the Photos database.
|
"""Export photos from the Photos database.
|
||||||
Export path DEST is required.
|
Export path DEST is required.
|
||||||
@@ -1550,6 +1557,8 @@ def export(
|
|||||||
click.echo(f"DEST {dest} must be valid path", err=True)
|
click.echo(f"DEST {dest} must be valid path", err=True)
|
||||||
raise click.Abort()
|
raise click.Abort()
|
||||||
|
|
||||||
|
dest = str(pathlib.Path(dest).resolve())
|
||||||
|
|
||||||
if report and os.path.isdir(report):
|
if report and os.path.isdir(report):
|
||||||
click.echo(f"report is a directory, must be file name", err=True)
|
click.echo(f"report is a directory, must be file name", err=True)
|
||||||
raise click.Abort()
|
raise click.Abort()
|
||||||
@@ -1579,6 +1588,7 @@ def export(
|
|||||||
(shared, not_shared),
|
(shared, not_shared),
|
||||||
(has_comment, no_comment),
|
(has_comment, no_comment),
|
||||||
(has_likes, no_likes),
|
(has_likes, no_likes),
|
||||||
|
(export_as_hardlink, cleanup),
|
||||||
]
|
]
|
||||||
if any(all(bb) for bb in exclusive):
|
if any(all(bb) for bb in exclusive):
|
||||||
click.echo("Incompatible export options", err=True)
|
click.echo("Incompatible export options", err=True)
|
||||||
@@ -1890,7 +1900,6 @@ def export(
|
|||||||
results_missing.extend(results.missing)
|
results_missing.extend(results.missing)
|
||||||
results_error.extend(results.error)
|
results_error.extend(results.error)
|
||||||
|
|
||||||
stop_time = time.perf_counter()
|
|
||||||
# print summary results
|
# print summary results
|
||||||
# print(f"results_exported: {results_exported}")
|
# print(f"results_exported: {results_exported}")
|
||||||
# print(f"results_new: {results_new}")
|
# print(f"results_new: {results_new}")
|
||||||
@@ -1899,11 +1908,36 @@ def export(
|
|||||||
# print(f"results_exif_updated: {results_exif_updated}")
|
# print(f"results_exif_updated: {results_exif_updated}")
|
||||||
# print(f"results_touched: {results_touched}")
|
# print(f"results_touched: {results_touched}")
|
||||||
# print(f"results_converted: {results_converted}")
|
# print(f"results_converted: {results_converted}")
|
||||||
# print(f"results_sidecar_json: {results_sidecar_json}")
|
# print(f"results_sidecar_json_written: {results_sidecar_json_written}")
|
||||||
# print(f"results_sidecar_xmp: {results_sidecar_xmp}")
|
# print(f"results_sidecar_json_skipped: {results_sidecar_json_skipped}")
|
||||||
|
# print(f"results_sidecar_xmp_written: {results_sidecar_xmp_written}")
|
||||||
|
# print(f"results_sidecar_xmp_skipped: {results_sidecar_xmp_skipped}")
|
||||||
# print(f"results_missing: {results_missing}")
|
# print(f"results_missing: {results_missing}")
|
||||||
# print(f"results_error: {results_error}")
|
# print(f"results_error: {results_error}")
|
||||||
|
|
||||||
|
if cleanup:
|
||||||
|
all_files = (
|
||||||
|
results_exported
|
||||||
|
+ results_skipped
|
||||||
|
+ results_exif_updated
|
||||||
|
+ results_touched
|
||||||
|
+ results_converted
|
||||||
|
+ results_sidecar_json_written
|
||||||
|
+ results_sidecar_json_skipped
|
||||||
|
+ results_sidecar_xmp_written
|
||||||
|
+ results_sidecar_xmp_skipped
|
||||||
|
# include missing so a file that was already in export directory
|
||||||
|
# but was missing on --update doesn't get deleted
|
||||||
|
# (better to have old version than none)
|
||||||
|
+ results_missing
|
||||||
|
+ [str(pathlib.Path(export_db_path).resolve())]
|
||||||
|
)
|
||||||
|
click.echo(f"Cleaning up {dest}")
|
||||||
|
(cleaned_files, cleaned_dirs) = cleanup_files(dest, all_files, fileutil)
|
||||||
|
file_str = "files" if cleaned_files != 1 else "file"
|
||||||
|
dir_str = "directories" if cleaned_dirs != 1 else "directory"
|
||||||
|
click.echo(f"Deleted: {cleaned_files} {file_str}, {cleaned_dirs} {dir_str}")
|
||||||
|
|
||||||
if report:
|
if report:
|
||||||
verbose(f"Writing export report to {report}")
|
verbose(f"Writing export report to {report}")
|
||||||
write_export_report(
|
write_export_report(
|
||||||
@@ -1942,6 +1976,7 @@ def export(
|
|||||||
if touch_file:
|
if touch_file:
|
||||||
summary += f", touched date: {len(results_touched)}"
|
summary += f", touched date: {len(results_touched)}"
|
||||||
click.echo(summary)
|
click.echo(summary)
|
||||||
|
stop_time = time.perf_counter()
|
||||||
click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds")
|
click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds")
|
||||||
else:
|
else:
|
||||||
click.echo("Did not find any photos to export")
|
click.echo("Did not find any photos to export")
|
||||||
@@ -2465,68 +2500,6 @@ def export_photo(
|
|||||||
global VERBOSE
|
global VERBOSE
|
||||||
VERBOSE = bool(verbose_)
|
VERBOSE = bool(verbose_)
|
||||||
|
|
||||||
# TODO: if --skip-original-if-edited, it's possible edited version is on disk but
|
|
||||||
# original is missing, in which case we should download the edited version
|
|
||||||
if not download_missing:
|
|
||||||
if photo.ismissing:
|
|
||||||
space = " " if not verbose_ else ""
|
|
||||||
verbose(f"{space}Skipping missing photo {photo.original_filename}")
|
|
||||||
return ExportResults(
|
|
||||||
exported=[],
|
|
||||||
new=[],
|
|
||||||
updated=[],
|
|
||||||
skipped=[],
|
|
||||||
exif_updated=[],
|
|
||||||
touched=[],
|
|
||||||
converted_to_jpeg=[],
|
|
||||||
sidecar_json_written=[],
|
|
||||||
sidecar_json_skipped=[],
|
|
||||||
sidecar_xmp_written=[],
|
|
||||||
sidecar_xmp_skipped=[],
|
|
||||||
missing=[f"{photo.original_filename} ({photo.uuid})"],
|
|
||||||
error=[],
|
|
||||||
)
|
|
||||||
elif photo.path is None:
|
|
||||||
space = " " if not verbose_ else ""
|
|
||||||
verbose(
|
|
||||||
f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, "
|
|
||||||
f"skipping {photo.original_filename}"
|
|
||||||
)
|
|
||||||
return ExportResults(
|
|
||||||
exported=[],
|
|
||||||
new=[],
|
|
||||||
updated=[],
|
|
||||||
skipped=[],
|
|
||||||
exif_updated=[],
|
|
||||||
touched=[],
|
|
||||||
converted_to_jpeg=[],
|
|
||||||
sidecar_json_written=[],
|
|
||||||
sidecar_json_skipped=[],
|
|
||||||
sidecar_xmp_written=[],
|
|
||||||
sidecar_xmp_skipped=[],
|
|
||||||
missing=[f"{photo.original_filename} ({photo.uuid})"],
|
|
||||||
error=[],
|
|
||||||
)
|
|
||||||
elif photo.ismissing and not photo.iscloudasset and not photo.incloud:
|
|
||||||
verbose(
|
|
||||||
f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud"
|
|
||||||
)
|
|
||||||
return ExportResults(
|
|
||||||
exported=[],
|
|
||||||
new=[],
|
|
||||||
updated=[],
|
|
||||||
skipped=[],
|
|
||||||
exif_updated=[],
|
|
||||||
touched=[],
|
|
||||||
converted_to_jpeg=[],
|
|
||||||
sidecar_json_written=[],
|
|
||||||
sidecar_json_skipped=[],
|
|
||||||
sidecar_xmp_written=[],
|
|
||||||
sidecar_xmp_skipped=[],
|
|
||||||
missing=[f"{photo.original_filename} ({photo.uuid})"],
|
|
||||||
error=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
results_exported = []
|
results_exported = []
|
||||||
results_new = []
|
results_new = []
|
||||||
results_updated = []
|
results_updated = []
|
||||||
@@ -2539,6 +2512,7 @@ def export_photo(
|
|||||||
results_sidecar_xmp_written = []
|
results_sidecar_xmp_written = []
|
||||||
results_sidecar_xmp_skipped = []
|
results_sidecar_xmp_skipped = []
|
||||||
results_error = []
|
results_error = []
|
||||||
|
results_missing = []
|
||||||
|
|
||||||
export_original = not (skip_original_if_edited and photo.hasadjustments)
|
export_original = not (skip_original_if_edited and photo.hasadjustments)
|
||||||
|
|
||||||
@@ -2558,6 +2532,29 @@ def export_photo(
|
|||||||
f"Edited file for {photo.original_filename} is missing, exporting original"
|
f"Edited file for {photo.original_filename} is missing, exporting original"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# check for missing photos before downloading
|
||||||
|
missing_original = False
|
||||||
|
missing_edited = False
|
||||||
|
if download_missing:
|
||||||
|
if (
|
||||||
|
(photo.ismissing or photo.path is None)
|
||||||
|
and not photo.iscloudasset
|
||||||
|
and not photo.incloud
|
||||||
|
):
|
||||||
|
missing_original = True
|
||||||
|
if (
|
||||||
|
photo.hasadjustments
|
||||||
|
and photo.path_edited is None
|
||||||
|
and not photo.iscloudasset
|
||||||
|
and not photo.incloud
|
||||||
|
):
|
||||||
|
missing_edited = True
|
||||||
|
else:
|
||||||
|
if photo.ismissing or photo.path is None:
|
||||||
|
missing_original = True
|
||||||
|
if photo.hasadjustments and photo.path_edited is None:
|
||||||
|
missing_edited = True
|
||||||
|
|
||||||
filenames = get_filenames_from_template(photo, filename_template, original_name)
|
filenames = get_filenames_from_template(photo, filename_template, original_name)
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
if original_suffix:
|
if original_suffix:
|
||||||
@@ -2598,73 +2595,73 @@ def export_photo(
|
|||||||
|
|
||||||
# export the photo to each path in dest_paths
|
# export the photo to each path in dest_paths
|
||||||
for dest_path in dest_paths:
|
for dest_path in dest_paths:
|
||||||
|
# TODO: if --skip-original-if-edited, it's possible edited version is on disk but
|
||||||
|
# original is missing, in which case we should download the edited version
|
||||||
if export_original:
|
if export_original:
|
||||||
try:
|
if missing_original:
|
||||||
export_results = photo.export2(
|
space = " " if not verbose_ else ""
|
||||||
dest_path,
|
verbose(f"{space}Skipping missing photo {photo.original_filename}")
|
||||||
original_filename,
|
results_missing.append(
|
||||||
sidecar_json=sidecar_json,
|
str(pathlib.Path(dest_path) / original_filename)
|
||||||
sidecar_xmp=sidecar_xmp,
|
|
||||||
live_photo=export_live,
|
|
||||||
raw_photo=export_raw,
|
|
||||||
export_as_hardlink=export_as_hardlink,
|
|
||||||
overwrite=overwrite,
|
|
||||||
use_photos_export=use_photos_export,
|
|
||||||
exiftool=exiftool,
|
|
||||||
no_xattr=no_extended_attributes,
|
|
||||||
use_albums_as_keywords=album_keyword,
|
|
||||||
use_persons_as_keywords=person_keyword,
|
|
||||||
keyword_template=keyword_template,
|
|
||||||
description_template=description_template,
|
|
||||||
update=update,
|
|
||||||
export_db=export_db,
|
|
||||||
fileutil=fileutil,
|
|
||||||
dry_run=dry_run,
|
|
||||||
touch_file=touch_file,
|
|
||||||
convert_to_jpeg=convert_to_jpeg,
|
|
||||||
jpeg_quality=jpeg_quality,
|
|
||||||
ignore_date_modified=ignore_date_modified,
|
|
||||||
use_photokit=use_photokit,
|
|
||||||
verbose=verbose,
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
export_results = photo.export2(
|
||||||
|
dest_path,
|
||||||
|
original_filename,
|
||||||
|
sidecar_json=sidecar_json,
|
||||||
|
sidecar_xmp=sidecar_xmp,
|
||||||
|
live_photo=export_live,
|
||||||
|
raw_photo=export_raw,
|
||||||
|
export_as_hardlink=export_as_hardlink,
|
||||||
|
overwrite=overwrite,
|
||||||
|
use_photos_export=use_photos_export,
|
||||||
|
exiftool=exiftool,
|
||||||
|
no_xattr=no_extended_attributes,
|
||||||
|
use_albums_as_keywords=album_keyword,
|
||||||
|
use_persons_as_keywords=person_keyword,
|
||||||
|
keyword_template=keyword_template,
|
||||||
|
description_template=description_template,
|
||||||
|
update=update,
|
||||||
|
export_db=export_db,
|
||||||
|
fileutil=fileutil,
|
||||||
|
dry_run=dry_run,
|
||||||
|
touch_file=touch_file,
|
||||||
|
convert_to_jpeg=convert_to_jpeg,
|
||||||
|
jpeg_quality=jpeg_quality,
|
||||||
|
ignore_date_modified=ignore_date_modified,
|
||||||
|
use_photokit=use_photokit,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
|
||||||
results_exported.extend(export_results.exported)
|
results_exported.extend(export_results.exported)
|
||||||
results_new.extend(export_results.new)
|
results_new.extend(export_results.new)
|
||||||
results_updated.extend(export_results.updated)
|
results_updated.extend(export_results.updated)
|
||||||
results_skipped.extend(export_results.skipped)
|
results_skipped.extend(export_results.skipped)
|
||||||
results_exif_updated.extend(export_results.exif_updated)
|
results_exif_updated.extend(export_results.exif_updated)
|
||||||
results_touched.extend(export_results.touched)
|
results_touched.extend(export_results.touched)
|
||||||
results_converted.extend(export_results.converted_to_jpeg)
|
results_converted.extend(export_results.converted_to_jpeg)
|
||||||
results_sidecar_json_written.extend(
|
results_sidecar_json_written.extend(
|
||||||
export_results.sidecar_json_written
|
export_results.sidecar_json_written
|
||||||
)
|
)
|
||||||
results_sidecar_json_skipped.extend(
|
results_sidecar_json_skipped.extend(
|
||||||
export_results.sidecar_json_skipped
|
export_results.sidecar_json_skipped
|
||||||
)
|
)
|
||||||
results_sidecar_xmp_written.extend(
|
results_sidecar_xmp_written.extend(
|
||||||
export_results.sidecar_xmp_written
|
export_results.sidecar_xmp_written
|
||||||
)
|
)
|
||||||
results_sidecar_xmp_skipped.extend(
|
results_sidecar_xmp_skipped.extend(
|
||||||
export_results.sidecar_xmp_skipped
|
export_results.sidecar_xmp_skipped
|
||||||
)
|
)
|
||||||
|
|
||||||
if verbose_:
|
except Exception:
|
||||||
for exported in export_results.exported:
|
click.echo(
|
||||||
verbose(f"Exported {exported}")
|
f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}",
|
||||||
for new in export_results.new:
|
err=True,
|
||||||
verbose(f"Exported new file {new}")
|
)
|
||||||
for updated in export_results.updated:
|
results_error.extend(
|
||||||
verbose(f"Exported updated file {updated}")
|
str(pathlib.Path(dest) / original_filename)
|
||||||
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}")
|
|
||||||
except Exception:
|
|
||||||
click.echo(
|
|
||||||
f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
results_error.extend(dest)
|
|
||||||
else:
|
else:
|
||||||
verbose(f"Skipping original version of {photo.original_filename}")
|
verbose(f"Skipping original version of {photo.original_filename}")
|
||||||
|
|
||||||
@@ -2673,23 +2670,26 @@ def export_photo(
|
|||||||
if export_edited and photo.hasadjustments:
|
if export_edited and photo.hasadjustments:
|
||||||
# if download_missing and the photo is missing or path doesn't exist,
|
# if download_missing and the photo is missing or path doesn't exist,
|
||||||
# try to download with Photos
|
# try to download with Photos
|
||||||
if not download_missing and photo.path_edited is None:
|
|
||||||
verbose(f"Skipping missing edited photo for {filename}")
|
edited_filename = pathlib.Path(filename)
|
||||||
|
# check for correct edited suffix
|
||||||
|
if photo.path_edited is not None:
|
||||||
|
edited_ext = pathlib.Path(photo.path_edited).suffix
|
||||||
else:
|
else:
|
||||||
edited_filename = pathlib.Path(filename)
|
# use filename suffix which might be wrong,
|
||||||
# check for correct edited suffix
|
# will be corrected by use_photos_export
|
||||||
if photo.path_edited is not None:
|
edited_ext = pathlib.Path(photo.filename).suffix
|
||||||
edited_ext = pathlib.Path(photo.path_edited).suffix
|
edited_filename = f"{edited_filename.stem}{edited_suffix}{edited_ext}"
|
||||||
else:
|
verbose(
|
||||||
# use filename suffix which might be wrong,
|
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
||||||
# will be corrected by use_photos_export
|
)
|
||||||
edited_ext = pathlib.Path(photo.filename).suffix
|
if missing_edited:
|
||||||
edited_filename = (
|
space = " " if not verbose_ else ""
|
||||||
f"{edited_filename.stem}{edited_suffix}{edited_ext}"
|
verbose(f"{space}Skipping missing edited photo for {filename}")
|
||||||
)
|
results_missing.append(
|
||||||
verbose(
|
str(pathlib.Path(dest_path) / edited_filename)
|
||||||
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
export_results_edited = photo.export2(
|
export_results_edited = photo.export2(
|
||||||
dest_path,
|
dest_path,
|
||||||
@@ -2740,23 +2740,26 @@ def export_photo(
|
|||||||
export_results_edited.sidecar_xmp_skipped
|
export_results_edited.sidecar_xmp_skipped
|
||||||
)
|
)
|
||||||
|
|
||||||
if verbose_:
|
|
||||||
for exported in export_results_edited.exported:
|
|
||||||
verbose(f"Exported {exported}")
|
|
||||||
for new in export_results_edited.new:
|
|
||||||
verbose(f"Exported new file {new}")
|
|
||||||
for updated in export_results_edited.updated:
|
|
||||||
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}")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
click.echo(
|
click.echo(
|
||||||
f"Error exporting photo {filename} as {edited_filename}",
|
f"Error exporting photo {filename} as {edited_filename}",
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
results_error.extend(dest)
|
results_error.extend(str(pathlib.Path(dest) / edited_filename))
|
||||||
|
|
||||||
|
if verbose_:
|
||||||
|
if update:
|
||||||
|
for new in results_new:
|
||||||
|
verbose(f"Exported new file {new}")
|
||||||
|
for updated in results_updated:
|
||||||
|
verbose(f"Exported updated file {updated}")
|
||||||
|
for skipped in results_skipped:
|
||||||
|
verbose(f"Skipped up to date file {skipped}")
|
||||||
|
else:
|
||||||
|
for exported in results_exported:
|
||||||
|
verbose(f"Exported {exported}")
|
||||||
|
for touched in results_touched:
|
||||||
|
verbose(f"Touched date on file {touched}")
|
||||||
|
|
||||||
return ExportResults(
|
return ExportResults(
|
||||||
exported=results_exported,
|
exported=results_exported,
|
||||||
@@ -2770,7 +2773,7 @@ def export_photo(
|
|||||||
sidecar_json_skipped=results_sidecar_json_skipped,
|
sidecar_json_skipped=results_sidecar_json_skipped,
|
||||||
sidecar_xmp_written=results_sidecar_xmp_written,
|
sidecar_xmp_written=results_sidecar_xmp_written,
|
||||||
sidecar_xmp_skipped=results_sidecar_xmp_skipped,
|
sidecar_xmp_skipped=results_sidecar_xmp_skipped,
|
||||||
missing=[],
|
missing=results_missing,
|
||||||
error=results_error,
|
error=results_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3043,5 +3046,40 @@ def write_export_report(
|
|||||||
raise click.Abort()
|
raise click.Abort()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_files(dest_path, files_to_keep, fileutil):
|
||||||
|
""" cleanup dest_path by deleting and files and empty directories
|
||||||
|
not in files_to_keep
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dest_path: path to directory to clean
|
||||||
|
files_to_keep: list of full file paths to keep (not delete)
|
||||||
|
fileutile: FileUtil object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple of (number of files deleted, number of directories deleted)
|
||||||
|
"""
|
||||||
|
keepers = {filename.lower(): 1 for filename in files_to_keep}
|
||||||
|
|
||||||
|
deleted_files = 0
|
||||||
|
for p in pathlib.Path(dest_path).rglob("*"):
|
||||||
|
path = str(p).lower()
|
||||||
|
if p.is_file() and path not in keepers:
|
||||||
|
verbose(f"Deleting {p}")
|
||||||
|
fileutil.unlink(p)
|
||||||
|
deleted_files += 1
|
||||||
|
|
||||||
|
# delete empty directories
|
||||||
|
deleted_dirs = 0
|
||||||
|
for p in pathlib.Path(dest_path).rglob("*"):
|
||||||
|
path = str(p).lower()
|
||||||
|
# if directory and directory is empty
|
||||||
|
if p.is_dir() and not next(p.iterdir(), False):
|
||||||
|
verbose(f"Deleting empty directory {p}")
|
||||||
|
fileutil.rmdir(p)
|
||||||
|
deleted_dirs += 1
|
||||||
|
|
||||||
|
return (deleted_files, deleted_dirs)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli() # pylint: disable=no-value-for-parameter
|
cli() # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.37.5"
|
__version__ = "0.37.6"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from abc import ABC, abstractmethod
|
|||||||
|
|
||||||
from .imageconverter import ImageConverter
|
from .imageconverter import ImageConverter
|
||||||
|
|
||||||
|
|
||||||
class FileUtilABC(ABC):
|
class FileUtilABC(ABC):
|
||||||
""" Abstract base class for FileUtil """
|
""" Abstract base class for FileUtil """
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ class FileUtilABC(ABC):
|
|||||||
def unlink(cls, dest):
|
def unlink(cls, dest):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def rmdir(cls, dest):
|
||||||
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def utime(cls, path, times):
|
def utime(cls, path, times):
|
||||||
@@ -114,6 +120,14 @@ class FileUtilMacOS(FileUtilABC):
|
|||||||
else:
|
else:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rmdir(cls, dirpath):
|
||||||
|
""" remove directory filepath; dirpath must be empty """
|
||||||
|
if isinstance(dirpath, pathlib.Path):
|
||||||
|
dirpath.rmdir()
|
||||||
|
else:
|
||||||
|
os.rmdir(dirpath)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def utime(cls, path, times):
|
def utime(cls, path, times):
|
||||||
""" Set the access and modified time of path. """
|
""" Set the access and modified time of path. """
|
||||||
@@ -164,7 +178,7 @@ class FileUtilMacOS(FileUtilABC):
|
|||||||
def file_sig(cls, f1):
|
def file_sig(cls, f1):
|
||||||
""" return os.stat signature for file f1 """
|
""" return os.stat signature for file f1 """
|
||||||
return cls._sig(os.stat(f1))
|
return cls._sig(os.stat(f1))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||||
""" converts image file src_file to jpeg format as dest_file
|
""" converts image file src_file to jpeg format as dest_file
|
||||||
@@ -178,7 +192,9 @@ class FileUtilMacOS(FileUtilABC):
|
|||||||
True if success, otherwise False
|
True if success, otherwise False
|
||||||
"""
|
"""
|
||||||
converter = ImageConverter()
|
converter = ImageConverter()
|
||||||
return converter.write_jpeg(src_file, dest_file, compression_quality=compression_quality)
|
return converter.write_jpeg(
|
||||||
|
src_file, dest_file, compression_quality=compression_quality
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sig(st):
|
def _sig(st):
|
||||||
@@ -189,6 +205,7 @@ class FileUtilMacOS(FileUtilABC):
|
|||||||
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
|
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
|
||||||
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
||||||
|
|
||||||
|
|
||||||
class FileUtil(FileUtilMacOS):
|
class FileUtil(FileUtilMacOS):
|
||||||
""" Various file utilities """
|
""" Various file utilities """
|
||||||
|
|
||||||
@@ -228,6 +245,10 @@ class FileUtilNoOp(FileUtil):
|
|||||||
def unlink(cls, dest):
|
def unlink(cls, dest):
|
||||||
cls.verbose(f"unlink: {dest}")
|
cls.verbose(f"unlink: {dest}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rmdir(cls, dest):
|
||||||
|
cls.verbose(f"rmdir: {dest}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def utime(cls, path, times):
|
def utime(cls, path, times):
|
||||||
cls.verbose(f"utime: {path}, {times}")
|
cls.verbose(f"utime: {path}, {times}")
|
||||||
|
|||||||
@@ -3272,9 +3272,9 @@ def test_export_then_hardlink():
|
|||||||
|
|
||||||
def test_export_dry_run():
|
def test_export_dry_run():
|
||||||
""" test export with dry-run flag """
|
""" test export with dry-run flag """
|
||||||
import glob
|
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
|
import re
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.__main__ import export
|
from osxphotos.__main__ import export
|
||||||
|
|
||||||
@@ -3288,7 +3288,7 @@ def test_export_dry_run():
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output
|
assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output
|
||||||
for filepath in CLI_EXPORT_FILENAMES:
|
for filepath in CLI_EXPORT_FILENAMES:
|
||||||
assert f"Exported {filepath}" in result.output
|
assert re.search(r"Exported.*" + f"{filepath}", result.output)
|
||||||
assert not os.path.isfile(filepath)
|
assert not os.path.isfile(filepath)
|
||||||
|
|
||||||
|
|
||||||
@@ -3340,10 +3340,10 @@ def test_export_update_edits_dry_run():
|
|||||||
|
|
||||||
def test_export_directory_template_1_dry_run():
|
def test_export_directory_template_1_dry_run():
|
||||||
""" test export using directory template with dry-run flag """
|
""" test export using directory template with dry-run flag """
|
||||||
import glob
|
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
|
import re
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos.__main__ import export
|
from osxphotos.__main__ import export
|
||||||
|
|
||||||
@@ -3368,7 +3368,7 @@ def test_export_directory_template_1_dry_run():
|
|||||||
assert "exported: 8" in result.output
|
assert "exported: 8" in result.output
|
||||||
workdir = os.getcwd()
|
workdir = os.getcwd()
|
||||||
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
|
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
|
||||||
assert f"Exported {filepath}" in result.output
|
assert re.search(r"Exported.*" + f"{filepath}", result.output)
|
||||||
assert not os.path.isfile(os.path.join(workdir, filepath))
|
assert not os.path.isfile(os.path.join(workdir, filepath))
|
||||||
|
|
||||||
|
|
||||||
@@ -3914,3 +3914,73 @@ def test_export_missing_not_download_missing():
|
|||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Aborted!" in result.output
|
assert "Aborted!" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_cleanup():
|
||||||
|
""" test export with --cleanup flag """
|
||||||
|
import pathlib
|
||||||
|
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, CLI_PHOTOS_DB), ".", "-V"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
# create 2 files and a directory
|
||||||
|
with open("delete_me.txt", "w") as fd:
|
||||||
|
fd.write("delete me!")
|
||||||
|
os.mkdir("./foo")
|
||||||
|
with open("foo/delete_me_too.txt", "w") as fd:
|
||||||
|
fd.write("delete me too!")
|
||||||
|
|
||||||
|
assert pathlib.Path("./delete_me.txt").is_file()
|
||||||
|
# run cleanup with dry-run
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[
|
||||||
|
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--update",
|
||||||
|
"--cleanup",
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "Deleted: 2 files, 0 directories" in result.output
|
||||||
|
assert pathlib.Path("./delete_me.txt").is_file()
|
||||||
|
assert pathlib.Path("./foo/delete_me_too.txt").is_file()
|
||||||
|
|
||||||
|
# run cleanup without dry-run
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup"],
|
||||||
|
)
|
||||||
|
assert "Deleted: 2 files, 1 directory" in result.output
|
||||||
|
assert not pathlib.Path("./delete_me.txt").is_file()
|
||||||
|
assert not pathlib.Path("./foo/delete_me_too.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_cleanup_export_as_hardling():
|
||||||
|
""" test export with incompatible option """
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
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, CLI_PHOTOS_DB),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--export-as-hardlink",
|
||||||
|
"--cleanup",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "Incompatible export options" in result.output
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ def test_unlink_file():
|
|||||||
assert not os.path.isfile(dest)
|
assert not os.path.isfile(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rmdir():
|
||||||
|
import os.path
|
||||||
|
import tempfile
|
||||||
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
|
dir_name = temp_dir.name
|
||||||
|
assert os.path.isdir(dir_name)
|
||||||
|
FileUtil.rmdir(dir_name)
|
||||||
|
assert not os.path.isdir(dir_name)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
||||||
reason="Skip if running in Github actions, no GPU.",
|
reason="Skip if running in Github actions, no GPU.",
|
||||||
@@ -90,6 +102,7 @@ def test_convert_to_jpeg():
|
|||||||
assert FileUtil.convert_to_jpeg(imgfile, outfile)
|
assert FileUtil.convert_to_jpeg(imgfile, outfile)
|
||||||
assert outfile.is_file()
|
assert outfile.is_file()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
||||||
reason="Skip if running in Github actions, no GPU.",
|
reason="Skip if running in Github actions, no GPU.",
|
||||||
|
|||||||
Reference in New Issue
Block a user