Feature custom sidecar 1123 (#1129)
* Working on custom sidecar, #1123 * Working on custom sidecar, #1123 * Added custom sidecar example * Added WRITE_SKIPPED argument for --sidecar-template * Updated report writer for user sidecars * Added noqa to long lines in report writer * Initial tests for --sidecar-template * Initial tests for --sidecar-template * Initial tests for --sidecar-template * Completed tests for --sidecar-template * Added json example for --sidecar-template * Added json example for --sidecar-template
This commit is contained in:
parent
3bae875a63
commit
02b6698c80
@ -1,223 +1,22 @@
|
||||
<%doc>
|
||||
This is an example Mako template for use with --sidecar-template which produces an XMP sidecar file
|
||||
This is an example Mako template for use with --sidecar-template
|
||||
For more information on Mako templates, see https://docs.makotemplates.org/en/latest/
|
||||
|
||||
The template will be passed three variables for rendering:
|
||||
photo: a PhotoInfo object for the photo being exported
|
||||
photo_path: a pathlib.Path object for the photo file being exported
|
||||
sidecar_path: a pathlib.Path object for the sidecar file being written
|
||||
photo: a PhotoInfo object for the photo being exported
|
||||
photo_path: a pathlib.Path object for the photo file being exported
|
||||
sidecar_path: a pathlib.Path object for the sidecar file being written
|
||||
</%doc>
|
||||
|
||||
<%def name="photoshop_sidecar_for_extension(extension)">
|
||||
% if extension is None:
|
||||
<photoshop:SidecarForExtension></photoshop:SidecarForExtension>
|
||||
<%def name="rating(photo)" filter="trim">\
|
||||
% if photo.favorite:
|
||||
★★★★★
|
||||
% else:
|
||||
<photoshop:SidecarForExtension>${extension}</photoshop:SidecarForExtension>
|
||||
★☆☆☆☆
|
||||
% endif
|
||||
</%def>
|
||||
</%def>\
|
||||
|
||||
<%def name="dc_description(desc)">
|
||||
% if desc is None:
|
||||
<dc:description>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'/>
|
||||
</rdf:Alt>
|
||||
</dc:description>
|
||||
% else:
|
||||
<dc:description>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'>${desc | x}</rdf:li>
|
||||
</rdf:Alt>
|
||||
</dc:description>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_title(title)">
|
||||
% if title is None:
|
||||
<dc:title>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'/>
|
||||
</rdf:Alt>
|
||||
</dc:title>
|
||||
% else:
|
||||
<dc:title>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'>${title | x}</rdf:li>
|
||||
</rdf:Alt>
|
||||
</dc:title>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_subject(subject)">
|
||||
% if subject:
|
||||
<dc:subject>
|
||||
<rdf:Bag>
|
||||
% for subj in subject:
|
||||
<rdf:li>${subj | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</dc:subject>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_datecreated(date)">
|
||||
% if date is not None:
|
||||
<photoshop:DateCreated>${date.isoformat()}</photoshop:DateCreated>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="iptc_personinimage(persons)">
|
||||
% if persons:
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
% for person in persons:
|
||||
<rdf:li>${person | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dk_tagslist(keywords)">
|
||||
% if keywords:
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
% for keyword in keywords:
|
||||
<rdf:li>${keyword | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="adobe_createdate(date)">
|
||||
% if date is not None:
|
||||
<xmp:CreateDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:CreateDate>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="adobe_modifydate(date)">
|
||||
% if date is not None:
|
||||
<xmp:ModifyDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:ModifyDate>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="xmp_rating(rating)">
|
||||
% if rating is not None:
|
||||
<xmp:Rating>${rating}</xmp:Rating>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="gps_info(latitude, longitude)">
|
||||
% if latitude is not None and longitude is not None:
|
||||
<exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
|
||||
<exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="mwg_face_regions(photo)">
|
||||
% if photo.face_info:
|
||||
<mwg-rs:Regions rdf:parseType="Resource">
|
||||
<mwg-rs:AppliedToDimensions rdf:parseType="Resource">
|
||||
<stDim:h>${photo.width if photo.orientation in [5, 6, 7, 8] else photo.height}</stDim:h>
|
||||
<stDim:w>${photo.height if photo.orientation in [5, 6, 7, 8] else photo.width}</stDim:w>
|
||||
<stDim:unit>pixel</stDim:unit>
|
||||
</mwg-rs:AppliedToDimensions>
|
||||
<mwg-rs:RegionList>
|
||||
<rdf:Bag>
|
||||
% for face in photo.face_info:
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<mwg-rs:Area rdf:parseType="Resource">
|
||||
<stArea:h>${'{0:.6f}'.format(face.mwg_rs_area.h)}</stArea:h>
|
||||
<stArea:w>${'{0:.6f}'.format(face.mwg_rs_area.w)}</stArea:w>
|
||||
<stArea:x>${'{0:.6f}'.format(face.mwg_rs_area.x)}</stArea:x>
|
||||
<stArea:y>${'{0:.6f}'.format(face.mwg_rs_area.y)}</stArea:y>
|
||||
<stArea:unit>normalized</stArea:unit>
|
||||
</mwg-rs:Area>
|
||||
<mwg-rs:Name>${face.name}</mwg-rs:Name>
|
||||
<mwg-rs:Rotation>${face.roll}</mwg-rs:Rotation>
|
||||
<mwg-rs:Type>Face</mwg-rs:Type>
|
||||
</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</mwg-rs:RegionList>
|
||||
</mwg-rs:Regions>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="mpri_face_regions(photo)">
|
||||
% if photo.face_info:
|
||||
<MP:RegionInfo rdf:parseType="Resource">
|
||||
<MPRI:Regions>
|
||||
<rdf:Bag>
|
||||
% for face in photo.face_info:
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<MPReg:PersonDisplayName>${face.name}</MPReg:PersonDisplayName>
|
||||
<MPReg:Rectangle>${'{0:.6f}'.format(face.mpri_reg_rect.x)}, ${'{0:.6f}'.format(face.mpri_reg_rect.y)}, ${'{0:.6f}'.format(face.mpri_reg_rect.h)}, ${'{0:.6f}'.format(face.mpri_reg_rect.w)}</MPReg:Rectangle>
|
||||
</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</MPRI:Regions>
|
||||
</MP:RegionInfo>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
|
||||
<?xpacket begin="${"\uFEFF"}" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="osxphotos">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||
<%
|
||||
extension = photo_path.suffix[1:].lower() if photo_path.suffix else ""
|
||||
%>
|
||||
${photoshop_sidecar_for_extension(extension)}
|
||||
${dc_description(photo.description)}
|
||||
${dc_title(photo.title)}
|
||||
<%
|
||||
subjects = photo.keywords + photo.persons
|
||||
%>
|
||||
${dc_subject(subjects)}
|
||||
${dc_datecreated(photo.date)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
${iptc_personinimage(photo.persons)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
${dk_tagslist(photo.keywords)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
${adobe_createdate(photo.date)}
|
||||
${adobe_modifydate(photo.date)}
|
||||
${xmp_rating("5" if photo.favorite else None)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
${gps_info(*photo.location)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
|
||||
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
|
||||
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
|
||||
${mwg_face_regions(photo)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:MP="http://ns.microsoft.com/photo/1.2/"
|
||||
xmlns:MPRI="http://ns.microsoft.com/photo/1.2/t/RegionInfo#"
|
||||
xmlns:MPReg="http://ns.microsoft.com/photo/1.2/t/Region#">
|
||||
${mpri_face_regions(photo)}
|
||||
</rdf:Description>
|
||||
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>
|
||||
Photo: ${photo_path.name}
|
||||
UUID: ${photo.uuid}
|
||||
Sidecar: ${sidecar_path.name}
|
||||
Rating: ${rating(photo)}
|
||||
|
||||
6
examples/custom_sidecar_json.mako
Normal file
6
examples/custom_sidecar_json.mako
Normal file
@ -0,0 +1,6 @@
|
||||
<%doc>
|
||||
Mako template to dump a full json representation of the photo object
|
||||
Can be run from the command line with:
|
||||
osxphotos export /path/to/export --sidecar-template custom_sidecar_json.mako "{filepath}.json" yes no yes
|
||||
</%doc>
|
||||
${photo.json(shallow=False, indent=4)}
|
||||
223
examples/custom_sidecar_xmp.mako
Normal file
223
examples/custom_sidecar_xmp.mako
Normal file
@ -0,0 +1,223 @@
|
||||
<%doc>
|
||||
This is an example Mako template for use with --sidecar-template which produces an XMP sidecar file
|
||||
For more information on Mako templates, see https://docs.makotemplates.org/en/latest/
|
||||
|
||||
The template will be passed three variables for rendering:
|
||||
photo: a PhotoInfo object for the photo being exported
|
||||
photo_path: a pathlib.Path object for the photo file being exported
|
||||
sidecar_path: a pathlib.Path object for the sidecar file being written
|
||||
</%doc>
|
||||
|
||||
<%def name="photoshop_sidecar_for_extension(extension)">
|
||||
% if extension is None:
|
||||
<photoshop:SidecarForExtension></photoshop:SidecarForExtension>
|
||||
% else:
|
||||
<photoshop:SidecarForExtension>${extension}</photoshop:SidecarForExtension>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_description(desc)">
|
||||
% if desc is None:
|
||||
<dc:description>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'/>
|
||||
</rdf:Alt>
|
||||
</dc:description>
|
||||
% else:
|
||||
<dc:description>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'>${desc | x}</rdf:li>
|
||||
</rdf:Alt>
|
||||
</dc:description>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_title(title)">
|
||||
% if title is None:
|
||||
<dc:title>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'/>
|
||||
</rdf:Alt>
|
||||
</dc:title>
|
||||
% else:
|
||||
<dc:title>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang='x-default'>${title | x}</rdf:li>
|
||||
</rdf:Alt>
|
||||
</dc:title>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_subject(subject)">
|
||||
% if subject:
|
||||
<dc:subject>
|
||||
<rdf:Bag>
|
||||
% for subj in subject:
|
||||
<rdf:li>${subj | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</dc:subject>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_datecreated(date)">
|
||||
% if date is not None:
|
||||
<photoshop:DateCreated>${date.isoformat()}</photoshop:DateCreated>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="iptc_personinimage(persons)">
|
||||
% if persons:
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
% for person in persons:
|
||||
<rdf:li>${person | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dk_tagslist(keywords)">
|
||||
% if keywords:
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
% for keyword in keywords:
|
||||
<rdf:li>${keyword | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="adobe_createdate(date)">
|
||||
% if date is not None:
|
||||
<xmp:CreateDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:CreateDate>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="adobe_modifydate(date)">
|
||||
% if date is not None:
|
||||
<xmp:ModifyDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:ModifyDate>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="xmp_rating(rating)">
|
||||
% if rating is not None:
|
||||
<xmp:Rating>${rating}</xmp:Rating>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="gps_info(latitude, longitude)">
|
||||
% if latitude is not None and longitude is not None:
|
||||
<exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
|
||||
<exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="mwg_face_regions(photo)">
|
||||
% if photo.face_info:
|
||||
<mwg-rs:Regions rdf:parseType="Resource">
|
||||
<mwg-rs:AppliedToDimensions rdf:parseType="Resource">
|
||||
<stDim:h>${photo.width if photo.orientation in [5, 6, 7, 8] else photo.height}</stDim:h>
|
||||
<stDim:w>${photo.height if photo.orientation in [5, 6, 7, 8] else photo.width}</stDim:w>
|
||||
<stDim:unit>pixel</stDim:unit>
|
||||
</mwg-rs:AppliedToDimensions>
|
||||
<mwg-rs:RegionList>
|
||||
<rdf:Bag>
|
||||
% for face in photo.face_info:
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<mwg-rs:Area rdf:parseType="Resource">
|
||||
<stArea:h>${'{0:.6f}'.format(face.mwg_rs_area.h)}</stArea:h>
|
||||
<stArea:w>${'{0:.6f}'.format(face.mwg_rs_area.w)}</stArea:w>
|
||||
<stArea:x>${'{0:.6f}'.format(face.mwg_rs_area.x)}</stArea:x>
|
||||
<stArea:y>${'{0:.6f}'.format(face.mwg_rs_area.y)}</stArea:y>
|
||||
<stArea:unit>normalized</stArea:unit>
|
||||
</mwg-rs:Area>
|
||||
<mwg-rs:Name>${face.name}</mwg-rs:Name>
|
||||
<mwg-rs:Rotation>${face.roll}</mwg-rs:Rotation>
|
||||
<mwg-rs:Type>Face</mwg-rs:Type>
|
||||
</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</mwg-rs:RegionList>
|
||||
</mwg-rs:Regions>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="mpri_face_regions(photo)">
|
||||
% if photo.face_info:
|
||||
<MP:RegionInfo rdf:parseType="Resource">
|
||||
<MPRI:Regions>
|
||||
<rdf:Bag>
|
||||
% for face in photo.face_info:
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<MPReg:PersonDisplayName>${face.name}</MPReg:PersonDisplayName>
|
||||
<MPReg:Rectangle>${'{0:.6f}'.format(face.mpri_reg_rect.x)}, ${'{0:.6f}'.format(face.mpri_reg_rect.y)}, ${'{0:.6f}'.format(face.mpri_reg_rect.h)}, ${'{0:.6f}'.format(face.mpri_reg_rect.w)}</MPReg:Rectangle>
|
||||
</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</MPRI:Regions>
|
||||
</MP:RegionInfo>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
|
||||
<?xpacket begin="${"\uFEFF"}" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="osxphotos">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||
<%
|
||||
extension = photo_path.suffix[1:].lower() if photo_path.suffix else ""
|
||||
%>
|
||||
${photoshop_sidecar_for_extension(extension)}
|
||||
${dc_description(photo.description)}
|
||||
${dc_title(photo.title)}
|
||||
<%
|
||||
subjects = photo.keywords + photo.persons
|
||||
%>
|
||||
${dc_subject(subjects)}
|
||||
${dc_datecreated(photo.date)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
${iptc_personinimage(photo.persons)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
${dk_tagslist(photo.keywords)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
${adobe_createdate(photo.date)}
|
||||
${adobe_modifydate(photo.date)}
|
||||
${xmp_rating("5" if photo.favorite else None)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
${gps_info(*photo.location)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
|
||||
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
|
||||
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
|
||||
${mwg_face_regions(photo)}
|
||||
</rdf:Description>
|
||||
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:MP="http://ns.microsoft.com/photo/1.2/"
|
||||
xmlns:MPRI="http://ns.microsoft.com/photo/1.2/t/RegionInfo#"
|
||||
xmlns:MPReg="http://ns.microsoft.com/photo/1.2/t/Region#">
|
||||
${mpri_face_regions(photo)}
|
||||
</rdf:Description>
|
||||
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>
|
||||
@ -88,9 +88,10 @@ from .common import (
|
||||
)
|
||||
from .help import ExportCommand, get_help_msg
|
||||
from .list import _list_libraries
|
||||
from .param_types import ExportDBType, FunctionCall, TemplateString
|
||||
from .param_types import BooleanString, ExportDBType, FunctionCall, TemplateString
|
||||
from .report_writer import ReportWriterNoOp, export_report_writer_factory
|
||||
from .rich_progress import rich_progress
|
||||
from .sidecar import generate_user_sidecar
|
||||
from .verbose import get_verbose_console, verbose_print
|
||||
|
||||
|
||||
@ -304,7 +305,8 @@ from .verbose import get_verbose_console, verbose_print
|
||||
"The resulting file is named photoname.AAE. "
|
||||
"Note that to import these files back to Photos succesfully, you also need to "
|
||||
"export the edited photo and match the filename format Photos.app expects: "
|
||||
"--filename 'IMG_{edited_version?E,}{id:04d}' --edited-suffix ''")
|
||||
"--filename 'IMG_{edited_version?E,}{id:04d}' --edited-suffix ''",
|
||||
)
|
||||
@click.option(
|
||||
"--sidecar",
|
||||
default=None,
|
||||
@ -338,6 +340,41 @@ from .verbose import get_verbose_console, verbose_print
|
||||
"Warning: this may result in sidecar filename collisions if there are files of different "
|
||||
"types but the same name in the output directory, e.g. 'IMG_1234.JPG' and 'IMG_1234.MOV'.",
|
||||
)
|
||||
@click.option(
|
||||
"--sidecar-template",
|
||||
metavar="MAKO_TEMPLATE_FILE SIDECAR_FILENAME_TEMPLATE WRITE_SKIPPED STRIP_WHITESPACE STRIP_LINES",
|
||||
multiple=True,
|
||||
type=click.Tuple(
|
||||
[
|
||||
click.Path(dir_okay=False, file_okay=True, exists=True),
|
||||
TemplateString(),
|
||||
BooleanString(),
|
||||
BooleanString(),
|
||||
BooleanString(),
|
||||
]
|
||||
),
|
||||
help="Create a custom sidecar file for each photo exported with user provided Mako template (MAKO_TEMPLATE_FILE). "
|
||||
"MAKO_TEMPLATE_FILE must be a valid Mako template (see https://www.makotemplates.org/). "
|
||||
"The template will passed the following variables: photo (PhotoInfo object for the photo being exported), "
|
||||
"sidecar_path (pathlib.Path object for the path to the sidecar being written), and "
|
||||
"photo_path (pathlib.Path object for the path to the exported photo. "
|
||||
"SIDECAR_TEMPLATE_FILENAME must be a valid template string (see Templating System in help) "
|
||||
"which will be rendered to generate the filename of the sidecar file. "
|
||||
"The `{filepath}` template variable may be used in the SIDECAR_TEMPLATE_FILENAME to refer to the filename of the "
|
||||
"photo being exported. "
|
||||
"WRITE_SKIPPED is a boolean value (true/false, yes/no, 1/0 are all valid values) and indicates whether or not "
|
||||
"write the sidecar file even if the photo is skipped during export. "
|
||||
"If WRITE_SKIPPED is false, the sidecar file will not be written if the photo is skipped during export. "
|
||||
"If WRITE_SKIPPED is true, the sidecar file will be written even if the photo is skipped during export. "
|
||||
"STRIP_WHITESPACE and STRIP_LINES are boolean values (true/false, yes/no, 1/0 are all valid values) "
|
||||
"and indicate whether or not to strip whitespace and blank lines from the resulting sidecar file. "
|
||||
"For example, to create a sidecar file with extension .xmp using a template file named 'sidecar.mako' "
|
||||
"and write a sidecar for skipped photos and strip blank lines but not whitespace: "
|
||||
"`--sidecar-template sidecar.mako '{filepath}.xmp' yes no yes`. "
|
||||
"To do the same but to drop the photo extension from the sidecar filename: "
|
||||
"`--sidecar-template sidecar.mako '{filepath.parent}/{filepath.stem}.xmp' yes no yes --sidecar-drop-ext`. "
|
||||
"For an example Mako file see https://raw.githubusercontent.com/RhetTbull/osxphotos/main/examples/custom_sidecar.mako",
|
||||
)
|
||||
@click.option(
|
||||
"--exiftool",
|
||||
is_flag=True,
|
||||
@ -863,6 +900,7 @@ def export(
|
||||
export_aae,
|
||||
sidecar,
|
||||
sidecar_drop_ext,
|
||||
sidecar_template,
|
||||
skip_bursts,
|
||||
skip_edited,
|
||||
skip_live,
|
||||
@ -1090,6 +1128,7 @@ def export(
|
||||
export_aae = cfg.export_aae
|
||||
sidecar = cfg.sidecar
|
||||
sidecar_drop_ext = cfg.sidecar_drop_ext
|
||||
sidecar_template = cfg.sidecar_template
|
||||
skip_bursts = cfg.skip_bursts
|
||||
skip_edited = cfg.skip_edited
|
||||
skip_live = cfg.skip_live
|
||||
@ -1473,6 +1512,20 @@ def export(
|
||||
kwargs["photo"] = p
|
||||
kwargs["photo_num"] = photo_num
|
||||
export_results = export_photo(**kwargs)
|
||||
|
||||
# generate custom sidecars if needed
|
||||
if sidecar_template:
|
||||
export_results += generate_user_sidecar(
|
||||
photo=p,
|
||||
export_results=export_results,
|
||||
sidecar_template=sidecar_template,
|
||||
exiftool_path=exiftool_path,
|
||||
export_dir=dest,
|
||||
dry_run=dry_run,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
# run post functions
|
||||
if post_function:
|
||||
for function in post_function:
|
||||
# post function is tuple of (function, filename.py::function_name)
|
||||
@ -1485,6 +1538,7 @@ def export(
|
||||
f"[error]Error running post-function [italic]{function[1]}[/italic]: {e}"
|
||||
)
|
||||
|
||||
# run post command
|
||||
run_post_command(
|
||||
photo=p,
|
||||
post_command=post_command,
|
||||
@ -1667,6 +1721,8 @@ def export(
|
||||
+ results.sidecar_exiftool_skipped
|
||||
+ results.sidecar_xmp_written
|
||||
+ results.sidecar_xmp_skipped
|
||||
+ results.sidecar_user_written
|
||||
+ results.sidecar_user_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)
|
||||
@ -1766,7 +1822,7 @@ def export_photo(
|
||||
num_photos=1,
|
||||
tmpdir=None,
|
||||
update_errors=False,
|
||||
):
|
||||
) -> ExportResults:
|
||||
"""Helper function for export that does the actual export
|
||||
|
||||
Args:
|
||||
@ -1817,6 +1873,7 @@ def export_photo(
|
||||
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
|
||||
verbose: callable for verbose output
|
||||
tmpdir: optional str; temporary directory to use for export
|
||||
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
|
||||
@ -2189,7 +2246,7 @@ def export_photo_to_directory(
|
||||
use_photokit,
|
||||
verbose,
|
||||
tmpdir,
|
||||
):
|
||||
) -> ExportResults:
|
||||
"""Export photo to directory dest_path"""
|
||||
|
||||
results = ExportResults()
|
||||
@ -2739,9 +2796,6 @@ def run_post_command(
|
||||
verbose,
|
||||
):
|
||||
# todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?)
|
||||
# todo: need a shell_quote template type:
|
||||
# {shell_quote,{filepath}/foo/bar}
|
||||
# that quotes everything in the default value
|
||||
for category, command_template in post_command:
|
||||
files = getattr(export_results, category)
|
||||
for f in files:
|
||||
|
||||
@ -18,6 +18,7 @@ from osxphotos.utils import expand_and_validate_filepath, load_function
|
||||
|
||||
__all__ = [
|
||||
"BitMathSize",
|
||||
"BooleanString",
|
||||
"DateOffset",
|
||||
"DateTimeISO8601",
|
||||
"DeprecatedPath",
|
||||
@ -302,3 +303,19 @@ class Longitude(click.ParamType):
|
||||
self.fail(
|
||||
f"Invalid longitude {value}. Must be a floating point number between -180 and 180."
|
||||
)
|
||||
|
||||
|
||||
class BooleanString(click.ParamType):
|
||||
"""A boolean string in the format True/False, Yes/No, T/F, Y/N, 1/0 (case insensitive)"""
|
||||
|
||||
name = "BooleanString"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
if value.lower() in ["true", "yes", "t", "y", "1"]:
|
||||
return True
|
||||
elif value.lower() in ["false", "no", "f", "n", "0"]:
|
||||
return False
|
||||
else:
|
||||
self.fail(
|
||||
f"Invalid boolean string {value}. Must be one of True/False, Yes/No, T/F, Y/N, 1/0 (case insensitive)."
|
||||
)
|
||||
|
||||
@ -98,6 +98,7 @@ class ExportReportWriterCSV(ReportWriterABC):
|
||||
"cleanup_deleted_file",
|
||||
"cleanup_deleted_directory",
|
||||
"exported_album",
|
||||
"sidecar_user",
|
||||
]
|
||||
|
||||
mode = "a" if append else "w"
|
||||
@ -197,9 +198,9 @@ class ExportReportWriterSQLite(ReportWriterABC):
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO report "
|
||||
"(datetime, filename, exported, new, updated, skipped, exif_updated, touched, converted_to_jpeg, sidecar_xmp, sidecar_json, sidecar_exiftool, missing, error, exiftool_warning, exiftool_error, extended_attributes_written, extended_attributes_skipped, cleanup_deleted_file, cleanup_deleted_directory, exported_album, report_id) "
|
||||
"(datetime, filename, exported, new, updated, skipped, exif_updated, touched, converted_to_jpeg, sidecar_xmp, sidecar_json, sidecar_exiftool, missing, error, exiftool_warning, exiftool_error, extended_attributes_written, extended_attributes_skipped, cleanup_deleted_file, cleanup_deleted_directory, exported_album, report_id, sidecar_user) " # noqa
|
||||
"VALUES "
|
||||
"(:datetime, :filename, :exported, :new, :updated, :skipped, :exif_updated, :touched, :converted_to_jpeg, :sidecar_xmp, :sidecar_json, :sidecar_exiftool, :missing, :error, :exiftool_warning, :exiftool_error, :extended_attributes_written, :extended_attributes_skipped, :cleanup_deleted_file, :cleanup_deleted_directory, :exported_album, :report_id);",
|
||||
"(:datetime, :filename, :exported, :new, :updated, :skipped, :exif_updated, :touched, :converted_to_jpeg, :sidecar_xmp, :sidecar_json, :sidecar_exiftool, :missing, :error, :exiftool_warning, :exiftool_error, :extended_attributes_written, :extended_attributes_skipped, :cleanup_deleted_file, :cleanup_deleted_directory, :exported_album, :report_id, :sidecar_user);", # noqa
|
||||
data,
|
||||
)
|
||||
self._conn.commit()
|
||||
@ -262,6 +263,13 @@ class ExportReportWriterSQLite(ReportWriterABC):
|
||||
self._conn.cursor().execute("ALTER TABLE report ADD COLUMN report_id TEXT;")
|
||||
self._conn.commit()
|
||||
|
||||
# migrate report table and add sidecar_user column if needed (#1123)
|
||||
if "sidecar_user" not in sqlite_columns(self._conn, "report"):
|
||||
self._conn.cursor().execute(
|
||||
"ALTER TABLE report ADD COLUMN sidecar_user INTEGER;"
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# create report_summary view
|
||||
c.execute(
|
||||
"""
|
||||
@ -347,6 +355,7 @@ def prepare_export_results_for_writing(
|
||||
"cleanup_deleted_file": false,
|
||||
"cleanup_deleted_directory": false,
|
||||
"exported_album": "",
|
||||
"sidecar_user": false,
|
||||
}
|
||||
|
||||
for result in export_results.exported:
|
||||
@ -421,6 +430,14 @@ def prepare_export_results_for_writing(
|
||||
for result, album in export_results.exported_album:
|
||||
all_results[str(result)]["exported_album"] = album
|
||||
|
||||
for result in export_results.sidecar_user_written:
|
||||
all_results[str(result)]["sidecar_user"] = true
|
||||
all_results[str(result)]["exported"] = true
|
||||
|
||||
for result in export_results.sidecar_user_skipped:
|
||||
all_results[str(result)]["sidecar_user"] = true
|
||||
all_results[str(result)]["skipped"] = true
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
|
||||
144
osxphotos/cli/sidecar.py
Normal file
144
osxphotos/cli/sidecar.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""Generate custom sidecar files for use wit `osxphotos export` command and --sidecar-template option"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
from typing import Callable
|
||||
|
||||
import click
|
||||
from mako.template import Template
|
||||
|
||||
from osxphotos.photoexporter import ExportResults
|
||||
from osxphotos.photoinfo import PhotoInfo
|
||||
from osxphotos.phototemplate import PhotoTemplate, RenderOptions
|
||||
|
||||
|
||||
def generate_user_sidecar(
|
||||
photo: PhotoInfo,
|
||||
export_results: ExportResults,
|
||||
sidecar_template: tuple[tuple[str, str, bool, bool]],
|
||||
exiftool_path: str,
|
||||
export_dir: str,
|
||||
dry_run: bool,
|
||||
verbose: Callable[..., None],
|
||||
) -> ExportResults:
|
||||
"""Generate custom sidecar files for use with `osxphotos export` command and --sidecar-template option
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo object for photo
|
||||
export_results: ExportResults object
|
||||
sidecar_template: tuple of (template_file, filename_template) for sidecar template
|
||||
strip_sidecar: bool, strip whitespace and blank lines from sidecar
|
||||
exiftool_path: str, path to exiftool
|
||||
export_dir: str, path to export directory
|
||||
dry_run: bool, if True, do not actually write sidecar files
|
||||
verbose: Callable[..., None], verbose logging function
|
||||
|
||||
Returns:
|
||||
ExportResults object with sidecar_user_written and sidecar_user_skipped set
|
||||
"""
|
||||
sidecar_results = ExportResults()
|
||||
for (
|
||||
template_file,
|
||||
filename_template,
|
||||
write_skipped,
|
||||
strip_whitespace,
|
||||
strip_lines,
|
||||
) in sidecar_template:
|
||||
if not write_skipped:
|
||||
# skip writing sidecar if photo not exported
|
||||
# but if run with --update and --cleanup, a sidecar file may have been written
|
||||
# in the past, so check if it exists and if so keep it
|
||||
for filepath in export_results.skipped:
|
||||
template_filename = _render_sidecar_filename(
|
||||
photo=photo,
|
||||
filepath=filepath,
|
||||
filename_template=filename_template,
|
||||
export_dir=export_dir,
|
||||
exiftool_path=exiftool_path,
|
||||
)
|
||||
if template_filename and pathlib.Path(template_filename).exists():
|
||||
verbose(
|
||||
f"Skipping existing sidecar file [filepath]{template_filename}[/]"
|
||||
)
|
||||
sidecar_results.sidecar_user_skipped.append(template_filename)
|
||||
|
||||
# write sidecar files for exported and missing files (and skipped if write_skipped)
|
||||
files_to_process = export_results.exported + export_results.missing
|
||||
if write_skipped:
|
||||
files_to_process += export_results.skipped
|
||||
for filepath in files_to_process:
|
||||
template_filename = _render_sidecar_filename(
|
||||
photo=photo,
|
||||
filepath=filepath,
|
||||
filename_template=filename_template,
|
||||
export_dir=export_dir,
|
||||
exiftool_path=exiftool_path,
|
||||
)
|
||||
if not template_filename:
|
||||
raise click.BadOptionUsage(
|
||||
f"Invalid SIDECAR_FILENAME_TEMPLATE for --sidecar-template '{filename_template}'"
|
||||
)
|
||||
|
||||
verbose(f"Writing sidecar file [filepath]{template_filename}[/]")
|
||||
_render_sidecar_and_write_data(
|
||||
template_file=template_file,
|
||||
photo=photo,
|
||||
template_filename=template_filename,
|
||||
filepath=filepath,
|
||||
strip_whitespace=strip_whitespace,
|
||||
strip_lines=strip_lines,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
sidecar_results.sidecar_user_written.append(template_filename)
|
||||
|
||||
return sidecar_results
|
||||
|
||||
|
||||
def _render_sidecar_filename(
|
||||
photo: PhotoInfo,
|
||||
filepath: str,
|
||||
filename_template: str,
|
||||
export_dir: str,
|
||||
exiftool_path: str,
|
||||
):
|
||||
"""Render sidecar filename template"""
|
||||
render_options = RenderOptions(export_dir=export_dir, filepath=filepath)
|
||||
photo_template = PhotoTemplate(photo, exiftool_path=exiftool_path)
|
||||
template_filename, _ = photo_template.render(
|
||||
filename_template, options=render_options
|
||||
)
|
||||
template_filename = template_filename[0] if template_filename else None
|
||||
|
||||
return template_filename
|
||||
|
||||
|
||||
def _render_sidecar_and_write_data(
|
||||
template_file: str,
|
||||
photo: PhotoInfo,
|
||||
template_filename: str,
|
||||
filepath: str,
|
||||
strip_whitespace: bool,
|
||||
strip_lines: bool,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""Render sidecar template and write data to file"""
|
||||
sidecar = Template(filename=template_file)
|
||||
sidecar_data = sidecar.render(
|
||||
photo=photo,
|
||||
sidecar_path=pathlib.Path(template_filename),
|
||||
photo_path=pathlib.Path(filepath),
|
||||
)
|
||||
|
||||
if strip_whitespace:
|
||||
# strip whitespace
|
||||
sidecar_data = "\n".join(line.strip() for line in sidecar_data.split("\n"))
|
||||
if strip_lines:
|
||||
# strip blank lines
|
||||
sidecar_data = "\n".join(
|
||||
line for line in sidecar_data.split("\n") if line.strip()
|
||||
)
|
||||
if not dry_run:
|
||||
# write sidecar file
|
||||
with open(template_filename, "w") as f:
|
||||
f.write(sidecar_data)
|
||||
@ -290,6 +290,8 @@ class ExportResults:
|
||||
"sidecar_json_written",
|
||||
"sidecar_xmp_skipped",
|
||||
"sidecar_xmp_written",
|
||||
"sidecar_user_written",
|
||||
"sidecar_user_skipped",
|
||||
"skipped",
|
||||
"skipped_album",
|
||||
"to_touch",
|
||||
@ -321,6 +323,8 @@ class ExportResults:
|
||||
sidecar_json_written=None,
|
||||
sidecar_xmp_skipped=None,
|
||||
sidecar_xmp_written=None,
|
||||
sidecar_user_written=None,
|
||||
sidecar_user_skipped=None,
|
||||
skipped=None,
|
||||
skipped_album=None,
|
||||
to_touch=None,
|
||||
@ -361,6 +365,8 @@ class ExportResults:
|
||||
+ self.sidecar_exiftool_skipped
|
||||
+ self.sidecar_xmp_written
|
||||
+ self.sidecar_xmp_skipped
|
||||
+ self.sidecar_user_written
|
||||
+ self.sidecar_user_skipped
|
||||
+ self.missing
|
||||
)
|
||||
files += [x[0] for x in self.exiftool_warning]
|
||||
|
||||
22
tests/custom_sidecar.mako
Normal file
22
tests/custom_sidecar.mako
Normal file
@ -0,0 +1,22 @@
|
||||
<%doc>
|
||||
This is an example Mako template for use with --sidecar-template
|
||||
For more information on Mako templates, see https://docs.makotemplates.org/en/latest/
|
||||
|
||||
The template will be passed three variables for rendering:
|
||||
photo: a PhotoInfo object for the photo being exported
|
||||
photo_path: a pathlib.Path object for the photo file being exported
|
||||
sidecar_path: a pathlib.Path object for the sidecar file being written
|
||||
</%doc>
|
||||
|
||||
<%def name="rating(photo)" filter="trim">\
|
||||
% if photo.favorite:
|
||||
★★★★★
|
||||
% else:
|
||||
★☆☆☆☆
|
||||
% endif
|
||||
</%def>\
|
||||
|
||||
Sidecar: ${sidecar_path.name}
|
||||
Photo: ${photo_path.name}
|
||||
UUID: ${photo.uuid}
|
||||
Rating: ${rating(photo)}
|
||||
403
tests/test_cli_export_sidecar_template.py
Normal file
403
tests/test_cli_export_sidecar_template.py
Normal file
@ -0,0 +1,403 @@
|
||||
"""Test export with --sidecar-template (#1123)"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from osxphotos.cli import export
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.15.7.photoslibrary"
|
||||
|
||||
PHOTO_UUID = "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" # wedding.jpg
|
||||
SIDECAR_FILENAME = "wedding.jpg.txt"
|
||||
SIDECAR_DATA = """
|
||||
|
||||
|
||||
Sidecar: wedding.jpg.txt
|
||||
Photo: wedding.jpg
|
||||
UUID: E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51
|
||||
Rating: ★★★★★
|
||||
"""
|
||||
|
||||
|
||||
def test_export_sidecar_template_1():
|
||||
"""test basic export with --sidecar-template"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"no",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
sidecar_file = pathlib.Path(SIDECAR_FILENAME)
|
||||
assert sidecar_file.exists()
|
||||
sidecar_data = sidecar_file.read_text()
|
||||
assert sidecar_data == SIDECAR_DATA
|
||||
|
||||
|
||||
def test_export_sidecar_template_strip_whitespace():
|
||||
"""test basic export with --sidecar-template and STRIP_WHITESPACE = True"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"yes",
|
||||
"no",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
sidecar_file = pathlib.Path(SIDECAR_FILENAME)
|
||||
assert sidecar_file.exists()
|
||||
sidecar_data = sidecar_file.read_text()
|
||||
sidecar_expected = (
|
||||
"\n".join(line.strip() for line in SIDECAR_DATA.splitlines()) + "\n"
|
||||
)
|
||||
assert sidecar_data == sidecar_expected
|
||||
|
||||
|
||||
def test_export_sidecar_template_strip_lines():
|
||||
"""test basic export with --sidecar-template and STRIP_LINES = True"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"yes",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
sidecar_file = pathlib.Path(SIDECAR_FILENAME)
|
||||
assert sidecar_file.exists()
|
||||
sidecar_data = sidecar_file.read_text()
|
||||
sidecar_expected = "\n".join(
|
||||
line for line in SIDECAR_DATA.splitlines() if line.strip()
|
||||
)
|
||||
assert sidecar_data == sidecar_expected
|
||||
|
||||
|
||||
def test_export_sidecar_template_strip_lines_strip_whitespace():
|
||||
"""test basic export with --sidecar-template and STRIP_LINES = True and STRIP_WHITESPACE = True"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"yes",
|
||||
"yes",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
sidecar_file = pathlib.Path(SIDECAR_FILENAME)
|
||||
assert sidecar_file.exists()
|
||||
sidecar_data = sidecar_file.read_text()
|
||||
sidecar_expected = "\n".join(
|
||||
line.strip() for line in SIDECAR_DATA.splitlines() if line.strip()
|
||||
)
|
||||
assert sidecar_data == sidecar_expected
|
||||
|
||||
|
||||
def test_export_sidecar_template_update_no():
|
||||
"""test basic export with --sidecar-template and WRITE_SKIPPED = False, also test --cleanup"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"no",
|
||||
],
|
||||
)
|
||||
|
||||
# run export again, should not update sidecar
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"no",
|
||||
"--update",
|
||||
"--cleanup",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
sidecar_file = pathlib.Path(SIDECAR_FILENAME)
|
||||
assert sidecar_file.exists()
|
||||
sidecar_data = sidecar_file.read_text()
|
||||
sidecar_expected = "\n".join(
|
||||
line.strip() for line in SIDECAR_DATA.splitlines() if line.strip()
|
||||
)
|
||||
assert sidecar_data == SIDECAR_DATA
|
||||
assert "Skipping existing sidecar file" in result.output
|
||||
assert "Deleted: 0 files, 0 directories" in result.output
|
||||
|
||||
|
||||
def test_export_sidecar_template_update_ues():
|
||||
"""test basic export with --sidecar-template and WRITE_SKIPPED = True, also test --cleanup"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"no",
|
||||
],
|
||||
)
|
||||
|
||||
# run export again, should not update sidecar
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"yes",
|
||||
"no",
|
||||
"no",
|
||||
"--update",
|
||||
"--cleanup",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
sidecar_file = pathlib.Path(SIDECAR_FILENAME)
|
||||
assert sidecar_file.exists()
|
||||
sidecar_data = sidecar_file.read_text()
|
||||
sidecar_expected = "\n".join(
|
||||
line.strip() for line in SIDECAR_DATA.splitlines() if line.strip()
|
||||
)
|
||||
assert sidecar_data == SIDECAR_DATA
|
||||
assert "Skipping existing sidecar file" not in result.output
|
||||
assert "Writing sidecar file" in result.output
|
||||
assert "Deleted: 0 files, 0 directories" in result.output
|
||||
|
||||
|
||||
def test_export_sidecar_template_report_csv():
|
||||
"""test basic export with --sidecar-template --report to csv"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"no",
|
||||
"--report",
|
||||
"report.csv",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# verify report output
|
||||
report_file = pathlib.Path("report.csv")
|
||||
assert report_file.exists()
|
||||
csvreader = csv.DictReader(report_file.open())
|
||||
assert "sidecar_user" in csvreader.fieldnames
|
||||
|
||||
found_sidecar = 0
|
||||
for row in csvreader: # sourcery skip: no-loop-in-tests
|
||||
# sidecar ends with .txt so verify report has sidecar_user = 1
|
||||
if row["filename"].endswith(
|
||||
".txt"
|
||||
): # sourcery skip: no-conditionals-in-tests
|
||||
assert str(row["sidecar_user"]) == "1"
|
||||
found_sidecar += 1
|
||||
else:
|
||||
assert str(row["sidecar_user"]) == "0"
|
||||
assert found_sidecar
|
||||
|
||||
|
||||
def test_export_sidecar_template_report_json():
|
||||
"""test basic export with --sidecar-template --report to json"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"no",
|
||||
"--report",
|
||||
"report.json",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# read the json report output and verify it is correct
|
||||
report_file = pathlib.Path("report.json")
|
||||
assert report_file.exists()
|
||||
report_data = json.loads(report_file.read_text())
|
||||
assert "sidecar_user" in report_data[0]
|
||||
found_sidecar = 0
|
||||
for row in report_data: # sourcery skip: no-loop-in-tests
|
||||
# sidecar ends with .txt so verify report has sidecar_user = 1
|
||||
if row["filename"].endswith(
|
||||
".txt"
|
||||
): # sourcery skip: no-conditionals-in-tests
|
||||
assert row["sidecar_user"]
|
||||
found_sidecar += 1
|
||||
else:
|
||||
assert not row["sidecar_user"]
|
||||
assert found_sidecar
|
||||
|
||||
|
||||
def test_export_sidecar_template_report_db():
|
||||
"""test basic export with --sidecar-template --report to sqlite db"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
"--library",
|
||||
os.path.join(cwd, PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
PHOTO_UUID,
|
||||
"--sidecar-template",
|
||||
os.path.join(cwd, "tests", "custom_sidecar.mako"),
|
||||
"{filepath}.txt",
|
||||
"no",
|
||||
"no",
|
||||
"no",
|
||||
"--report",
|
||||
"report.db",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# read the report sqlite db and verify it is correct
|
||||
report_file = pathlib.Path("report.db")
|
||||
assert report_file.exists()
|
||||
conn = sqlite3.connect(report_file)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT filename, sidecar_user FROM report")
|
||||
rows = c.fetchall()
|
||||
found_sidecar = 0
|
||||
for row in rows: # sourcery skip: no-loop-in-tests
|
||||
# sidecar ends with .txt so verify report has sidecar_user = 1
|
||||
if row[0].endswith(".txt"): # sourcery skip: no-conditionals-in-tests
|
||||
assert row[1] == 1
|
||||
found_sidecar += 1
|
||||
else:
|
||||
assert row[1] == 0
|
||||
assert found_sidecar
|
||||
Loading…
x
Reference in New Issue
Block a user