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
|
||||
|
||||
Export photos from the Photos database. Export path DEST is required.
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
Options:
|
||||
@@ -265,6 +265,79 @@ Options:
|
||||
photos if the raw photo does not have an
|
||||
associated jpeg image (e.g. the raw file was
|
||||
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
|
||||
exporting metadata.
|
||||
--album-keyword Use album name as keyword/tag when exporting
|
||||
@@ -291,53 +364,6 @@ Options:
|
||||
could specify --description-template
|
||||
"{descr} exported with osxphotos on
|
||||
{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
|
||||
output directory in the form
|
||||
'{name,DEFAULT}'. See below for additional
|
||||
@@ -379,6 +405,11 @@ Options:
|
||||
default AppleScript interface.
|
||||
--report REPORTNAME.CSV Write a CSV formatted report of all files
|
||||
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.
|
||||
|
||||
** Export **
|
||||
|
||||
@@ -1436,6 +1436,12 @@ def query(
|
||||
help="Write a CSV formatted report of all files that were exported.",
|
||||
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
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -1530,6 +1536,7 @@ def export(
|
||||
use_photos_export,
|
||||
use_photokit,
|
||||
report,
|
||||
cleanup,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1550,6 +1557,8 @@ def export(
|
||||
click.echo(f"DEST {dest} must be valid path", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
dest = str(pathlib.Path(dest).resolve())
|
||||
|
||||
if report and os.path.isdir(report):
|
||||
click.echo(f"report is a directory, must be file name", err=True)
|
||||
raise click.Abort()
|
||||
@@ -1579,6 +1588,7 @@ def export(
|
||||
(shared, not_shared),
|
||||
(has_comment, no_comment),
|
||||
(has_likes, no_likes),
|
||||
(export_as_hardlink, cleanup),
|
||||
]
|
||||
if any(all(bb) for bb in exclusive):
|
||||
click.echo("Incompatible export options", err=True)
|
||||
@@ -1890,7 +1900,6 @@ def export(
|
||||
results_missing.extend(results.missing)
|
||||
results_error.extend(results.error)
|
||||
|
||||
stop_time = time.perf_counter()
|
||||
# print summary results
|
||||
# print(f"results_exported: {results_exported}")
|
||||
# print(f"results_new: {results_new}")
|
||||
@@ -1899,11 +1908,36 @@ def export(
|
||||
# print(f"results_exif_updated: {results_exif_updated}")
|
||||
# print(f"results_touched: {results_touched}")
|
||||
# print(f"results_converted: {results_converted}")
|
||||
# print(f"results_sidecar_json: {results_sidecar_json}")
|
||||
# print(f"results_sidecar_xmp: {results_sidecar_xmp}")
|
||||
# print(f"results_sidecar_json_written: {results_sidecar_json_written}")
|
||||
# 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_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:
|
||||
verbose(f"Writing export report to {report}")
|
||||
write_export_report(
|
||||
@@ -1942,6 +1976,7 @@ def export(
|
||||
if touch_file:
|
||||
summary += f", touched date: {len(results_touched)}"
|
||||
click.echo(summary)
|
||||
stop_time = time.perf_counter()
|
||||
click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds")
|
||||
else:
|
||||
click.echo("Did not find any photos to export")
|
||||
@@ -2465,68 +2500,6 @@ def export_photo(
|
||||
global 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_new = []
|
||||
results_updated = []
|
||||
@@ -2539,6 +2512,7 @@ def export_photo(
|
||||
results_sidecar_xmp_written = []
|
||||
results_sidecar_xmp_skipped = []
|
||||
results_error = []
|
||||
results_missing = []
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
# 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)
|
||||
for filename in filenames:
|
||||
if original_suffix:
|
||||
@@ -2598,73 +2595,73 @@ def export_photo(
|
||||
|
||||
# export the photo to each 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:
|
||||
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,
|
||||
if missing_original:
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(f"{space}Skipping missing photo {photo.original_filename}")
|
||||
results_missing.append(
|
||||
str(pathlib.Path(dest_path) / original_filename)
|
||||
)
|
||||
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_new.extend(export_results.new)
|
||||
results_updated.extend(export_results.updated)
|
||||
results_skipped.extend(export_results.skipped)
|
||||
results_exif_updated.extend(export_results.exif_updated)
|
||||
results_touched.extend(export_results.touched)
|
||||
results_converted.extend(export_results.converted_to_jpeg)
|
||||
results_sidecar_json_written.extend(
|
||||
export_results.sidecar_json_written
|
||||
)
|
||||
results_sidecar_json_skipped.extend(
|
||||
export_results.sidecar_json_skipped
|
||||
)
|
||||
results_sidecar_xmp_written.extend(
|
||||
export_results.sidecar_xmp_written
|
||||
)
|
||||
results_sidecar_xmp_skipped.extend(
|
||||
export_results.sidecar_xmp_skipped
|
||||
)
|
||||
results_exported.extend(export_results.exported)
|
||||
results_new.extend(export_results.new)
|
||||
results_updated.extend(export_results.updated)
|
||||
results_skipped.extend(export_results.skipped)
|
||||
results_exif_updated.extend(export_results.exif_updated)
|
||||
results_touched.extend(export_results.touched)
|
||||
results_converted.extend(export_results.converted_to_jpeg)
|
||||
results_sidecar_json_written.extend(
|
||||
export_results.sidecar_json_written
|
||||
)
|
||||
results_sidecar_json_skipped.extend(
|
||||
export_results.sidecar_json_skipped
|
||||
)
|
||||
results_sidecar_xmp_written.extend(
|
||||
export_results.sidecar_xmp_written
|
||||
)
|
||||
results_sidecar_xmp_skipped.extend(
|
||||
export_results.sidecar_xmp_skipped
|
||||
)
|
||||
|
||||
if verbose_:
|
||||
for exported in export_results.exported:
|
||||
verbose(f"Exported {exported}")
|
||||
for new in export_results.new:
|
||||
verbose(f"Exported new file {new}")
|
||||
for updated in export_results.updated:
|
||||
verbose(f"Exported updated file {updated}")
|
||||
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)
|
||||
except Exception:
|
||||
click.echo(
|
||||
f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}",
|
||||
err=True,
|
||||
)
|
||||
results_error.extend(
|
||||
str(pathlib.Path(dest) / original_filename)
|
||||
)
|
||||
else:
|
||||
verbose(f"Skipping original version of {photo.original_filename}")
|
||||
|
||||
@@ -2673,23 +2670,26 @@ 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
|
||||
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:
|
||||
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:
|
||||
# use filename suffix which might be wrong,
|
||||
# will be corrected by use_photos_export
|
||||
edited_ext = pathlib.Path(photo.filename).suffix
|
||||
edited_filename = (
|
||||
f"{edited_filename.stem}{edited_suffix}{edited_ext}"
|
||||
)
|
||||
verbose(
|
||||
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
||||
# use filename suffix which might be wrong,
|
||||
# will be corrected by use_photos_export
|
||||
edited_ext = pathlib.Path(photo.filename).suffix
|
||||
edited_filename = f"{edited_filename.stem}{edited_suffix}{edited_ext}"
|
||||
verbose(
|
||||
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
||||
)
|
||||
if missing_edited:
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(f"{space}Skipping missing edited photo for {filename}")
|
||||
results_missing.append(
|
||||
str(pathlib.Path(dest_path) / edited_filename)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
export_results_edited = photo.export2(
|
||||
dest_path,
|
||||
@@ -2740,23 +2740,26 @@ def export_photo(
|
||||
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:
|
||||
click.echo(
|
||||
f"Error exporting photo {filename} as {edited_filename}",
|
||||
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(
|
||||
exported=results_exported,
|
||||
@@ -2770,7 +2773,7 @@ def export_photo(
|
||||
sidecar_json_skipped=results_sidecar_json_skipped,
|
||||
sidecar_xmp_written=results_sidecar_xmp_written,
|
||||
sidecar_xmp_skipped=results_sidecar_xmp_skipped,
|
||||
missing=[],
|
||||
missing=results_missing,
|
||||
error=results_error,
|
||||
)
|
||||
|
||||
@@ -3043,5 +3046,40 @@ def write_export_report(
|
||||
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__":
|
||||
cli() # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.37.5"
|
||||
__version__ = "0.37.6"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from .imageconverter import ImageConverter
|
||||
|
||||
|
||||
class FileUtilABC(ABC):
|
||||
""" Abstract base class for FileUtil """
|
||||
|
||||
@@ -27,6 +28,11 @@ class FileUtilABC(ABC):
|
||||
def unlink(cls, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def rmdir(cls, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def utime(cls, path, times):
|
||||
@@ -114,6 +120,14 @@ class FileUtilMacOS(FileUtilABC):
|
||||
else:
|
||||
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
|
||||
def utime(cls, path, times):
|
||||
""" Set the access and modified time of path. """
|
||||
@@ -164,7 +178,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
def file_sig(cls, f1):
|
||||
""" return os.stat signature for file f1 """
|
||||
return cls._sig(os.stat(f1))
|
||||
|
||||
|
||||
@classmethod
|
||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||
""" converts image file src_file to jpeg format as dest_file
|
||||
@@ -178,7 +192,9 @@ class FileUtilMacOS(FileUtilABC):
|
||||
True if success, otherwise False
|
||||
"""
|
||||
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
|
||||
def _sig(st):
|
||||
@@ -189,6 +205,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
# 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))
|
||||
|
||||
|
||||
class FileUtil(FileUtilMacOS):
|
||||
""" Various file utilities """
|
||||
|
||||
@@ -228,6 +245,10 @@ class FileUtilNoOp(FileUtil):
|
||||
def unlink(cls, dest):
|
||||
cls.verbose(f"unlink: {dest}")
|
||||
|
||||
@classmethod
|
||||
def rmdir(cls, dest):
|
||||
cls.verbose(f"rmdir: {dest}")
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
cls.verbose(f"utime: {path}, {times}")
|
||||
|
||||
@@ -3272,9 +3272,9 @@ def test_export_then_hardlink():
|
||||
|
||||
def test_export_dry_run():
|
||||
""" test export with dry-run flag """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
@@ -3288,7 +3288,7 @@ def test_export_dry_run():
|
||||
assert result.exit_code == 0
|
||||
assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output
|
||||
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)
|
||||
|
||||
|
||||
@@ -3340,10 +3340,10 @@ def test_export_update_edits_dry_run():
|
||||
|
||||
def test_export_directory_template_1_dry_run():
|
||||
""" test export using directory template with dry-run flag """
|
||||
import glob
|
||||
import locale
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
@@ -3368,7 +3368,7 @@ def test_export_directory_template_1_dry_run():
|
||||
assert "exported: 8" in result.output
|
||||
workdir = os.getcwd()
|
||||
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))
|
||||
|
||||
|
||||
@@ -3914,3 +3914,73 @@ def test_export_missing_not_download_missing():
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
||||
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 outfile.is_file()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
||||
reason="Skip if running in Github actions, no GPU.",
|
||||
|
||||
Reference in New Issue
Block a user