Added {album}, {keyword}, and {person} to template system

This commit is contained in:
Rhet Turnbull
2020-04-04 13:58:54 -07:00
parent 6a898886dd
commit 507c4a3740
7 changed files with 245 additions and 85 deletions

View File

@@ -88,7 +88,6 @@ Example: `osxphotos help export`
``` ```
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
Usage: __main__.py export [OPTIONS] [PHOTOS_LIBRARY]... DEST
Export photos from the Photos database. Export path DEST is required. Export photos from the Photos database. Export path DEST is required.
Optionally, query the Photos database using 1 or more search options; if Optionally, query the Photos database using 1 or more search options; if
@@ -245,13 +244,11 @@ be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
In the template, valid template substitutions will be replaced by the In the template, valid template substitutions will be replaced by the
corresponding value from the table below. Invalid substitutions will result 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 in a an error and the script will abort.
template, e.g. '{created.year}/{foo}', the resulting output directory would
look like '/Users/maria/Pictures/export/2020/{foo}'
If you want the actual text of the template substition to appear in the If you want the actual text of the template substition to appear in the
rendered name, escape the curly braces with \, for example, using rendered name, use double braces, e.g. '{{' or '}}', thus using
'{created.year}/\{name\}' for --directory would result in output of '{created.year}/{{name}}' for --directory would result in output of
2020/{name}/photoname.jpg 2020/{name}/photoname.jpg
You may specify an optional default value to use if the substitution does not You may specify an optional default value to use if the substitution does not
@@ -327,6 +324,18 @@ Substitution Description
'United States' 'United States'
{place.address.country_code} ISO country code of the postal address, e.g. {place.address.country_code} ISO country code of the postal address, e.g.
'US' 'US'
The following substitutions may result in multiple values. Thus if specified
for --directory these could result in multiple copies of a photo being being
exported, one to each directory. For example: --directory
'{created.year}/{album}' could result in the same photo being exported to each
of the following directories if the photos were created in 2019 and were in
albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family
Substitution Description
{album} Album(s) photo is contained in
{keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo
``` ```
Example: export all photos to ~/Desktop/export, including edited versions and live photo movies, group in folders by date created Example: export all photos to ~/Desktop/export, including edited versions and live photo movies, group in folders by date created
@@ -1005,7 +1014,6 @@ If you want to include "{" or "}" in the output, use "{{" or "}}"
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `("2020/{foo}",[])` e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `("2020/{foo}",[])`
| Substitution | Description | | Substitution | Description |
|--------------|-------------| |--------------|-------------|
|{name}|Filename of the photo| |{name}|Filename of the photo|
@@ -1039,6 +1047,10 @@ e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(
|{place.address.postal_code}|Postal code part of the postal address, e.g. '20009'| |{place.address.postal_code}|Postal code part of the postal address, e.g. '20009'|
|{place.address.country}|Country name of the postal address, e.g. 'United States'| |{place.address.country}|Country name of the postal address, e.g. 'United States'|
|{place.address.country_code}|ISO country code of the postal address, e.g. 'US'| |{place.address.country_code}|ISO country code of the postal address, e.g. 'US'|
|{album}|Album(s) photo is contained in|
|{keyword}|Keyword(s) assigned to photo|
|{person}|Person(s) / face(s) in a photo|
#### `DateTimeFormatter(dt)` #### `DateTimeFormatter(dt)`
Class that provides easy access to formatted datetime values. Class that provides easy access to formatted datetime values.

View File

@@ -21,7 +21,11 @@ import osxphotos
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION, _UNKNOWN_PLACE from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION, _UNKNOWN_PLACE
from ._version import __version__ from ._version import __version__
from .exiftool import get_exiftool_path from .exiftool import get_exiftool_path
from .template import render_filepath_template, TEMPLATE_SUBSTITUTIONS from .template import (
render_filepath_template,
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
)
from .utils import _copy_file, create_path_by_date from .utils import _copy_file, create_path_by_date
@@ -91,15 +95,13 @@ class ExportCommand(click.Command):
formatter.write_text( formatter.write_text(
"In the template, valid template substitutions will be replaced by " "In the template, valid template substitutions will be replaced by "
+ "the corresponding value from the table below. Invalid substitutions will result in a " + "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, " + "an error and the script will abort."
+ "e.g. '{created.year}/{foo}', the resulting output directory would look like "
+ "'/Users/maria/Pictures/export/2020/{foo}' "
) )
formatter.write("\n") formatter.write("\n")
formatter.write_text( formatter.write_text(
"If you want the actual text of the template substition to appear " "If you want the actual text of the template substition to appear "
+ "in the rendered name, escape the curly braces with \\, for example, " + "in the rendered name, use double braces, e.g. '{{' or '}}', thus "
+ "using '{created.year}/\\{name\\}' for --directory " + "using '{created.year}/{{name}}' for --directory "
+ "would result in output of 2020/{name}/photoname.jpg" + "would result in output of 2020/{name}/photoname.jpg"
) )
formatter.write("\n") formatter.write("\n")
@@ -126,6 +128,23 @@ class ExportCommand(click.Command):
formatter.write("\n") formatter.write("\n")
templ_tuples = [("Substitution", "Description")] templ_tuples = [("Substitution", "Description")]
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items()) templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
formatter.write_dl(templ_tuples)
formatter.write("\n")
formatter.write_text(
"The following substitutions may result in multiple values. Thus "
+ "if specified for --directory these could result in multiple copies of a photo being "
+ "being exported, one to each directory. For example: "
+ "--directory '{created.year}/{album}' could result in the same photo being exported "
+ "to each of the following directories if the photos were created in 2019 "
+ "and were in albums 'Vacation' and 'Family': "
+ "2019/Vacation, 2019/Family"
)
formatter.write("\n")
templ_tuples = [("Substitution", "Description")]
templ_tuples.extend(
(k, v) for k, v in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items()
)
formatter.write_dl(templ_tuples) formatter.write_dl(templ_tuples)
help_text += formatter.getvalue() help_text += formatter.getvalue()
@@ -1101,7 +1120,7 @@ def export(
) )
else: else:
for p in photos: for p in photos:
export_path = export_photo( export_paths = export_photo(
p, p,
dest, dest,
verbose, verbose,
@@ -1115,8 +1134,8 @@ def export(
exiftool, exiftool,
directory, directory,
) )
if export_path: if export_paths:
click.echo(f"Exported {p.filename} to {export_path}") click.echo(f"Exported {p.filename} to {export_paths}")
else: else:
click.echo(f"Did not export missing file {p.filename}") click.echo(f"Did not export missing file {p.filename}")
else: else:
@@ -1132,7 +1151,7 @@ def help(ctx, topic, **kw):
click.echo(ctx.parent.get_help()) click.echo(ctx.parent.get_help())
else: else:
ctx.info_name = topic ctx.info_name = topic
click.echo(cli.commands[topic].get_help(ctx)) click.echo_via_pager(cli.commands[topic].get_help(ctx))
def print_photo_info(photos, json=False): def print_photo_info(photos, json=False):
@@ -1483,9 +1502,13 @@ def export_photo(
download_missing: attempt download of missing iCloud photos download_missing: attempt download of missing iCloud photos
exiftool: use exiftool to write EXIF metadata directly to exported photo exiftool: use exiftool to write EXIF metadata directly to exported photo
directory: template used to determine output directory directory: template used to determine output directory
returns destination path of exported photo or None if photo was missing returns list of path(s) of exported photo or None if photo was missing
""" """
# Can export to multiple paths
# Start with single path [dest] but direcotry and export_by_date will modify dest_paths
dest_paths = [dest]
if not download_missing: if not download_missing:
if photo.ismissing: if photo.ismissing:
space = " " if not verbose else "" space = " " if not verbose else ""
@@ -1515,20 +1538,25 @@ def export_photo(
if export_by_date: if export_by_date:
date_created = photo.date.timetuple() date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created) dest_path = create_path_by_date(dest, date_created)
dest_paths = [dest_path]
elif directory: elif directory:
dirname, unmatched = render_filepath_template(directory, photo) # got a directory template, render it and check results are valid
dirname = dirname[0] dirnames, unmatched = render_filepath_template(directory, photo)
if unmatched: if unmatched:
click.echo( raise click.BadOptionUsage(
f"Possible unmatched substitution in template: {unmatched}", err=True "directory",
f"Invalid substitution in template '{directory}': {unmatched}",
) )
dest_paths = []
for dirname in dirnames:
dirname = sanitize_filepath(dirname, platform="auto") dirname = sanitize_filepath(dirname, platform="auto")
if not is_valid_filepath(dirname, platform="auto"): if not is_valid_filepath(dirname, platform="auto"):
raise ValueError(f"Invalid file path: {dirname}") raise ValueError(f"Invalid file path: {dirname}")
dest = os.path.join(dest, dirname) dest_path = os.path.join(dest, dirname)
if not os.path.isdir(dest): if not os.path.isdir(dest_path):
os.makedirs(dest) os.makedirs(dest_path)
dest_paths.append(dest_path)
sidecar = [s.lower() for s in sidecar] sidecar = [s.lower() for s in sidecar]
sidecar_json = sidecar_xmp = False sidecar_json = sidecar_xmp = False
@@ -1542,8 +1570,12 @@ def export_photo(
use_photos_export = download_missing and ( use_photos_export = download_missing and (
photo.ismissing or not os.path.exists(photo.path) photo.ismissing or not os.path.exists(photo.path)
) )
# export the photo to each path in dest_paths
photo_paths = []
for dest_path in dest_paths:
photo_path = photo.export( photo_path = photo.export(
dest, dest_path,
filename, filename,
sidecar_json=sidecar_json, sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp, sidecar_xmp=sidecar_xmp,
@@ -1552,6 +1584,7 @@ def export_photo(
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
exiftool=exiftool, exiftool=exiftool,
)[0] )[0]
photo_paths.append(photo_path)
# if export-edited, also export the edited version # if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception # verify the photo has adjustments and valid path to avoid raising an exception
@@ -1572,9 +1605,11 @@ def export_photo(
edited_suffix = pathlib.Path(photo.filename).suffix edited_suffix = pathlib.Path(photo.filename).suffix
edited_name = f"{edited_name.stem}_edited{edited_suffix}" edited_name = f"{edited_name.stem}_edited{edited_suffix}"
if verbose: if verbose:
click.echo(f"Exporting edited version of {filename} as {edited_name}") click.echo(
f"Exporting edited version of {filename} as {edited_name}"
)
photo.export( photo.export(
dest, dest_path,
edited_name, edited_name,
sidecar_json=sidecar_json, sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp, sidecar_xmp=sidecar_xmp,
@@ -1584,7 +1619,7 @@ def export_photo(
exiftool=exiftool, exiftool=exiftool,
) )
return photo_path return photo_paths
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.24.5" __version__ = "0.25.0"

View File

@@ -54,9 +54,9 @@ TEMPLATE_SUBSTITUTIONS = {
# Permitted multi-value substitutions (each of these returns None or 1 or more values) # Permitted multi-value substitutions (each of these returns None or 1 or more values)
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = { TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{album}": "Album photo is contained in", "{album}": "Album(s) photo is contained in",
"{keyword}": "Keywords assigned to photo", "{keyword}": "Keyword(s) assigned to photo",
"{person}": "Person / face in a photo", "{person}": "Person(s) / face(s) in a photo",
} }
# Just the multi-valued substitution names without the braces # Just the multi-valued substitution names without the braces
@@ -311,7 +311,7 @@ def render_filepath_template(template, photo, none_str="_"):
# '2011/Album2/keyword1/person1', # '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',] # '2011/Album2/keyword2/person1',]
rendered_strings = [rendered] rendered_strings = set([rendered])
for field in MULTI_VALUE_SUBSTITUTIONS: for field in MULTI_VALUE_SUBSTITUTIONS:
if field == "album": if field == "album":
values = photo.albums values = photo.albums
@@ -320,11 +320,7 @@ def render_filepath_template(template, photo, none_str="_"):
elif field == "person": elif field == "person":
values = photo.persons values = photo.persons
# remove any _UNKNOWN_PERSON values # remove any _UNKNOWN_PERSON values
try: values = [val for val in values if val != _UNKNOWN_PERSON]
values.remove(_UNKNOWN_PERSON)
except:
pass
else: else:
raise ValueError(f"Unhandleded template value: {field}") raise ValueError(f"Unhandleded template value: {field}")
@@ -334,7 +330,10 @@ def render_filepath_template(template, photo, none_str="_"):
# Build a regex that matches only the field being processed # Build a regex that matches only the field being processed
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}" re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
regex_multi = re.compile(re_str) regex_multi = re.compile(re_str)
new_strings = [] # holds each of the new rendered_strings
# holds each of the new rendered_strings, set() to avoid duplicates
new_strings = set()
for str_template in rendered_strings: for str_template in rendered_strings:
for val in values: for val in values:
@@ -351,7 +350,7 @@ def render_filepath_template(template, photo, none_str="_"):
photo, none_str, get_func=get_template_value_multi photo, none_str, get_func=get_template_value_multi
) )
new_string = regex_multi.sub(subst, str_template) new_string = regex_multi.sub(subst, str_template)
new_strings.append(new_string) new_strings.add(new_string)
# update rendered_strings for the next field to process # update rendered_strings for the next field to process
rendered_strings = new_strings rendered_strings = new_strings

View File

@@ -50,6 +50,26 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
"2018/September/Pumkins1.jpg", "2018/September/Pumkins1.jpg",
] ]
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [
"_/wedding.jpg",
"_/Tulips.jpg",
"_/St James Park.jpg",
"Pumpkin Farm/Pumpkins3.jpg",
"Pumpkin Farm/Pumkins2.jpg",
"Pumpkin Farm/Pumkins1.jpg",
"Test Album/Pumkins1.jpg",
]
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM2 = [
"NOALBUM/wedding.jpg",
"NOALBUM/Tulips.jpg",
"NOALBUM/St James Park.jpg",
"Pumpkin Farm/Pumpkins3.jpg",
"Pumpkin Farm/Pumkins2.jpg",
"Pumpkin Farm/Pumkins1.jpg",
"Test Album/Pumkins1.jpg",
]
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES2 = [ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES2 = [
"St James's Park, Great Britain, Westminster, England, United Kingdom/St James Park.jpg", "St James's Park, Great Britain, Westminster, England, United Kingdom/St James Park.jpg",
"_/Pumpkins3.jpg", "_/Pumpkins3.jpg",
@@ -407,10 +427,66 @@ def test_export_directory_template_3():
"{created.year}/{foo}", "{created.year}/{foo}",
], ],
) )
assert result.exit_code == 2
assert "Error: Invalid substitution in template" in result.output
def test_export_directory_template_album_1():
# test export using directory template with multiple albums
import glob
import os
import os.path
import osxphotos
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),
".",
"--original-name",
"-V",
"--directory",
"{album}",
],
)
assert result.exit_code == 0 assert result.exit_code == 0
assert "Possible unmatched substitution in template: ['foo']" in result.output
workdir = os.getcwd() workdir = os.getcwd()
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3: for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1:
assert os.path.isfile(os.path.join(workdir, filepath))
def test_export_directory_template_album_2():
# test export using directory template with multiple albums
# specify default value
import glob
import os
import os.path
import osxphotos
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),
".",
"--original-name",
"-V",
"--directory",
"{album,NOALBUM}",
],
)
assert result.exit_code == 0
workdir = os.getcwd()
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM2:
assert os.path.isfile(os.path.join(workdir, filepath)) assert os.path.isfile(os.path.join(workdir, filepath))

View File

@@ -190,6 +190,22 @@ def test_subst_multi_2_1_1():
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
def test_subst_multi_2_1_1_single():
""" Test that substitutions are correct """
# 2 albums, 1 keyword, 1 person but only do keywords
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["2_1_1"]])[0]
template = "{keyword}"
expected = ["Kids"]
rendered, _ = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
def test_subst_multi_0_2_0(): def test_subst_multi_0_2_0():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons # 0 albums, 2 keywords, 0 persons
@@ -206,6 +222,22 @@ def test_subst_multi_0_2_0():
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
def test_subst_multi_0_2_0_single():
""" Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, but only do albums
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
template = "{created.year}/{album}"
expected = ["2019/_"]
rendered, _ = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
def test_subst_multi_0_2_0_default_val(): def test_subst_multi_0_2_0_default_val():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, default vals provided # 0 albums, 2 keywords, 0 persons, default vals provided

View File

@@ -1,8 +1,14 @@
""" Builds the template table in markdown format for README.md """ """ Builds the template table in markdown format for README.md """
from osxphotos.template import TEMPLATE_SUBSTITUTIONS from osxphotos.template import (
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
)
print("| Substitution | Description |") print("| Substitution | Description |")
print("|--------------|-------------|") print("|--------------|-------------|")
for subst, descr in TEMPLATE_SUBSTITUTIONS.items(): for subst, descr in [
*TEMPLATE_SUBSTITUTIONS.items(),
*TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items(),
]:
print(f"|{subst}|{descr}|") print(f"|{subst}|{descr}|")