Initial version of templating system for CLI
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
@@ -8,13 +9,20 @@ import sys
|
||||
|
||||
import click
|
||||
import yaml
|
||||
from pathvalidate import (
|
||||
is_valid_filename,
|
||||
is_valid_filepath,
|
||||
sanitize_filepath,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
import osxphotos
|
||||
|
||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION
|
||||
from ._version import __version__
|
||||
from .utils import create_path_by_date, _copy_file
|
||||
from .exiftool import get_exiftool_path
|
||||
from .template import render_filename_template, TEMPLATE_SUBSTITUTIONS
|
||||
from .utils import _copy_file, create_path_by_date
|
||||
|
||||
|
||||
def get_photos_db(*db_options):
|
||||
@@ -59,6 +67,65 @@ class CLI_Obj:
|
||||
self.json = json
|
||||
|
||||
|
||||
class ExportCommand(click.Command):
|
||||
""" Custom click.Command that overrides get_help() to show additional help info for export """
|
||||
|
||||
def get_help(self, ctx):
|
||||
help_text = super().get_help(ctx)
|
||||
formatter = click.HelpFormatter()
|
||||
|
||||
formatter.write("\n\n")
|
||||
# passed to click.HelpFormatter.write_dl for formatting
|
||||
formatter.write_text("**Templating System**")
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"With the --directory option, you may specify a template for the "
|
||||
+ "export directory. This directory will be appended to the export path specified "
|
||||
+ " in the export DEST argument to export. For example, if template is "
|
||||
+ "'{created.year}/{created.month}', and export desitnation DEST is "
|
||||
+ "'/Users/maria/Pictures/export', "
|
||||
+ " the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' "
|
||||
+ " if the photo was created in March 2020. "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"In the template, valid template substitutions will be replaced by "
|
||||
+ "the corresponding value from the table below. Invalid substitutions will result in a "
|
||||
+ "warning but will be left unchanged. e.g. if you put '{foo}' in your template, "
|
||||
+ "e.g. '{created.year}/{foo}', the resulting output directory would look like "
|
||||
+ "'/Users/maria/Pictures/export/2020/{foo}' "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"If you want the actual text of the template substition to appear "
|
||||
+ "in the rendered name, escape the curly braces with \\, for example, "
|
||||
+ "using '{created.year}/\\{name\\}' for --directory "
|
||||
+ "would result in output of 2020/{name}/photoname.jpg"
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"In the current implementation, substitutions which have no value "
|
||||
+ "will be replaced by '_', "
|
||||
+ "for example, your template looked like '{created.year}/{place.address}' "
|
||||
+ "but there was no address associated with the photo, the resulting output would be: "
|
||||
+ "'2020/_/photoname.jpg' "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"I plan to add the option to specify the value to be used for missing "
|
||||
+ "subsitutions in a future version. I also plan to extend the templating system "
|
||||
+ "to the exported filename so you can specify the filename using a template."
|
||||
)
|
||||
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
help_text += formatter.getvalue()
|
||||
return help_text
|
||||
|
||||
|
||||
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
DB_OPTION = click.option(
|
||||
"--db",
|
||||
@@ -674,7 +741,7 @@ def query(
|
||||
print_photo_info(photos, cli_json or json_)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@cli.command(cls=ExportCommand)
|
||||
@DB_OPTION
|
||||
@query_options
|
||||
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
|
||||
@@ -746,6 +813,13 @@ def query(
|
||||
"To use this option, exiftool must be installed and in the path. "
|
||||
"exiftool may be installed from https://exiftool.org/",
|
||||
)
|
||||
@click.option(
|
||||
"--directory",
|
||||
metavar="DIRECTORY",
|
||||
default=None,
|
||||
help="Optional template for specifying name of output directory. "
|
||||
"See below for additional details on templating system",
|
||||
)
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -806,6 +880,7 @@ def export(
|
||||
not_selfie,
|
||||
panorama,
|
||||
not_panorama,
|
||||
directory,
|
||||
):
|
||||
""" Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -834,6 +909,7 @@ def export(
|
||||
(hdr, not_hdr),
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
(export_by_date, directory),
|
||||
]
|
||||
if any([all(bb) for bb in exclusive]):
|
||||
click.echo(cli.commands["export"].get_help(ctx), err=True)
|
||||
@@ -943,6 +1019,7 @@ def export(
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
)
|
||||
else:
|
||||
for p in photos:
|
||||
@@ -958,6 +1035,7 @@ def export(
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
)
|
||||
if export_path:
|
||||
click.echo(f"Exported {p.filename} to {export_path}")
|
||||
@@ -1276,6 +1354,7 @@ def export_photo(
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
):
|
||||
""" Helper function for export that does the actual export
|
||||
photo: PhotoInfo object
|
||||
@@ -1289,6 +1368,7 @@ def export_photo(
|
||||
live video will have same name as photo but with .mov extension
|
||||
download_missing: attempt download of missing iCloud photos
|
||||
exiftool: use exiftool to write EXIF metadata directly to exported photo
|
||||
directory: template used to determine output directory
|
||||
returns destination path of exported photo or None if photo was missing
|
||||
"""
|
||||
|
||||
@@ -1322,6 +1402,18 @@ def export_photo(
|
||||
if export_by_date:
|
||||
date_created = photo.date.timetuple()
|
||||
dest = create_path_by_date(dest, date_created)
|
||||
elif directory:
|
||||
dirname, unmatched = render_filename_template(directory, photo)
|
||||
if unmatched:
|
||||
click.echo(
|
||||
f"Possible unmatched substitution in template: {unmatched}", err=True
|
||||
)
|
||||
dirname = sanitize_filepath(dirname)
|
||||
if not is_valid_filepath(dirname):
|
||||
raise ValueError(f"Invalid file path: {dirname}")
|
||||
dest = os.path.join(dest, dirname)
|
||||
if not os.path.isdir(dest):
|
||||
os.makedirs(dest)
|
||||
|
||||
sidecar = [s.lower() for s in sidecar]
|
||||
sidecar_json = sidecar_xmp = False
|
||||
@@ -1382,4 +1474,3 @@ def export_photo(
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli() # pylint: disable=no-value-for-parameter
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.23.2"
|
||||
__version__ = "0.23.3"
|
||||
|
||||
200
osxphotos/template.py
Normal file
200
osxphotos/template.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import datetime
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Tuple # pylint: disable=syntax-error
|
||||
|
||||
from .photoinfo import PhotoInfo
|
||||
|
||||
TEMPLATE_SUBSTITUTIONS = {
|
||||
"{name}": "Filename of the photo",
|
||||
"{original_name}": "Photo's original filename when imported to Photos",
|
||||
"{title}": "Title of the photo",
|
||||
"{descr}": "Description of the photo",
|
||||
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
|
||||
"{created.year}": "4-digit year of file creation time",
|
||||
"{created.yy}": "2-digit year of file creation time",
|
||||
"{created.mm}": "2-digit month of the file creation time (zero padded)",
|
||||
"{created.month}": "Month name in user's locale of the file creation time",
|
||||
"{created.mon}": "Month abbreviation in the user's locale of the file creation time",
|
||||
"{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
|
||||
"{modified.year}": "4-digit year of file modification time",
|
||||
"{modified.yy}": "2-digit year of file modification time",
|
||||
"{modified.mm}": "2-digit month of the file modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the file modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
|
||||
"{place.name}": "Place name from the photo's reverse geolocation data",
|
||||
"{place.names}": "list of place names from the photo's reverse geolocation data, joined with '_', for example, '18th St NW_Washington_DC_United States'",
|
||||
"{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
|
||||
"{place.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
|
||||
"{place.city}": "City part of the postal address, e.g. 'Washington'",
|
||||
"{place.state}": "State part of the postal address, e.g. 'DC'",
|
||||
"{place.postal_code}": "Postal code part of the postal address, e.g. '20009'",
|
||||
"{place.country}": "Country name of the postal code, e.g. 'United States'",
|
||||
"{place.country_code}": "ISO country code of the postal address, e.g. 'US'",
|
||||
}
|
||||
|
||||
|
||||
def render_filename_template(
|
||||
template: str, photo: PhotoInfo, none_str: str = "_"
|
||||
) -> Tuple[str, list]:
|
||||
""" render a filename or directory template """
|
||||
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
if type(photo) is not PhotoInfo:
|
||||
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
|
||||
|
||||
rendered = template
|
||||
original_name = pathlib.Path(photo.original_filename).stem
|
||||
current_name = pathlib.Path(photo.filename).stem
|
||||
created = DateTimeFormatter(photo.date)
|
||||
if photo.date_modified:
|
||||
modified = DateTimeFormatter(photo.date_modified)
|
||||
else:
|
||||
modified = None
|
||||
|
||||
# make substitutions
|
||||
rendered = rendered.replace("{name}", current_name)
|
||||
rendered = rendered.replace("{original_name}", original_name)
|
||||
|
||||
title = photo.title if photo.title is not None else none_str
|
||||
rendered = rendered.replace("{title}", f"{title}")
|
||||
|
||||
descr = photo.description if photo.description is not None else none_str
|
||||
rendered = rendered.replace("{descr}", f"{descr}")
|
||||
|
||||
rendered = rendered.replace("{created.date}", photo.date.date().isoformat())
|
||||
rendered = rendered.replace("{created.year}", created.year)
|
||||
rendered = rendered.replace("{created.yy}", created.yy)
|
||||
rendered = rendered.replace("{created.mm}", created.mm)
|
||||
rendered = rendered.replace("{created.month}", created.month)
|
||||
rendered = rendered.replace("{created.mon}", created.mon)
|
||||
rendered = rendered.replace("{created.doy}", created.doy)
|
||||
|
||||
if modified is not None:
|
||||
rendered = rendered.replace(
|
||||
"{modified.date}", photo.date_modified.date().isoformat()
|
||||
)
|
||||
rendered = rendered.replace("{modified.year}", modified.year)
|
||||
rendered = rendered.replace("{modified.yy}", modified.yy)
|
||||
rendered = rendered.replace("{modified.mm}", modified.mm)
|
||||
rendered = rendered.replace("{modified.month}", modified.month)
|
||||
rendered = rendered.replace("{modified.mon}", modified.mon)
|
||||
rendered = rendered.replace("{modified.doy}", modified.doy)
|
||||
else:
|
||||
rendered = rendered.replace("{modified.year}", none_str)
|
||||
rendered = rendered.replace("{modified.yy}", none_str)
|
||||
rendered = rendered.replace("{modified.mm}", none_str)
|
||||
rendered = rendered.replace("{modified.month}", none_str)
|
||||
rendered = rendered.replace("{modified.mon}", none_str)
|
||||
rendered = rendered.replace("{modified.doy}", none_str)
|
||||
|
||||
place_name = photo.place.name if photo.place and photo.place.name else none_str
|
||||
rendered = rendered.replace("{place.name}", place_name)
|
||||
|
||||
place_names = (
|
||||
"_".join(photo.place.names) if photo.place and photo.place.names else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.names}", place_names)
|
||||
|
||||
address = (
|
||||
photo.place.address_str if photo.place and photo.place.address_str else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.address}", address)
|
||||
|
||||
street = (
|
||||
photo.place.address.street
|
||||
if photo.place and photo.place.address.street
|
||||
else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.street}", street)
|
||||
|
||||
city = (
|
||||
photo.place.address.city
|
||||
if photo.place and photo.place.address.city
|
||||
else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.city}", city)
|
||||
|
||||
state = (
|
||||
photo.place.address.state
|
||||
if photo.place and photo.place.address.state
|
||||
else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.state}", state)
|
||||
|
||||
postal_code = (
|
||||
photo.place.address.state
|
||||
if photo.place and photo.place.address.postal_code
|
||||
else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.postal_code}", postal_code)
|
||||
|
||||
country = (
|
||||
photo.place.address.state
|
||||
if photo.place and photo.place.address.country
|
||||
else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.country}", country)
|
||||
|
||||
country_code = (
|
||||
photo.place.country_code
|
||||
if photo.place and photo.place.country_code
|
||||
else none_str
|
||||
)
|
||||
rendered = rendered.replace("{place.country_code}", country_code)
|
||||
|
||||
# fix any escaped curly braces
|
||||
rendered = re.sub(r"\\{", "{", rendered)
|
||||
rendered = re.sub(r"\\}", "}", rendered)
|
||||
|
||||
# find any {words} that weren't replaced
|
||||
unmatched = re.findall(r"{\w+}", rendered)
|
||||
|
||||
return (rendered, unmatched)
|
||||
|
||||
|
||||
class DateTimeFormatter:
|
||||
""" provides property access to formatted datetime.datetime strftime values """
|
||||
|
||||
def __init__(self, dt: datetime.datetime):
|
||||
self.dt = dt
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" 4 digit year """
|
||||
year = f"{self.dt.year}"
|
||||
return year
|
||||
|
||||
@property
|
||||
def yy(self):
|
||||
""" 2 digit year """
|
||||
yy = f"{self.dt.strftime('%y')}"
|
||||
return yy
|
||||
|
||||
@property
|
||||
def mm(self):
|
||||
""" 2 digit month """
|
||||
mm = f"{self.dt.strftime('%m')}"
|
||||
return mm
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" Month as locale's full name """
|
||||
month = f"{self.dt.strftime('%B')}"
|
||||
return month
|
||||
|
||||
@property
|
||||
def mon(self):
|
||||
""" Month as locale's abbreviated name """
|
||||
mon = f"{self.dt.strftime('%b')}"
|
||||
return mon
|
||||
|
||||
@property
|
||||
def doy(self):
|
||||
""" Julian day of year starting from 001 """
|
||||
doy = f"{self.dt.strftime('%j')}"
|
||||
return doy
|
||||
Reference in New Issue
Block a user