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: __main__.py 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
@ -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
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}'
in a an error and the script will abort.
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
rendered name, use double braces, e.g. '{{' or '}}', thus using
'{created.year}/{{name}}' for --directory would result in output of
2020/{name}/photoname.jpg
You may specify an optional default value to use if the substitution does not
@ -327,6 +324,18 @@ Substitution Description
'United States'
{place.address.country_code} ISO country code of the postal address, e.g.
'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
@ -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}",[])`
| Substitution | Description |
|--------------|-------------|
|{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.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'|
|{album}|Album(s) photo is contained in|
|{keyword}|Keyword(s) assigned to photo|
|{person}|Person(s) / face(s) in a photo|
#### `DateTimeFormatter(dt)`
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 ._version import __version__
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
@ -91,15 +95,13 @@ class ExportCommand(click.Command):
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}' "
+ "an error and the script will abort."
)
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 "
+ "in the rendered name, use double braces, e.g. '{{' or '}}', thus "
+ "using '{created.year}/{{name}}' for --directory "
+ "would result in output of 2020/{name}/photoname.jpg"
)
formatter.write("\n")
@ -126,6 +128,23 @@ class ExportCommand(click.Command):
formatter.write("\n")
templ_tuples = [("Substitution", "Description")]
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)
help_text += formatter.getvalue()
@ -1101,7 +1120,7 @@ def export(
)
else:
for p in photos:
export_path = export_photo(
export_paths = export_photo(
p,
dest,
verbose,
@ -1115,8 +1134,8 @@ def export(
exiftool,
directory,
)
if export_path:
click.echo(f"Exported {p.filename} to {export_path}")
if export_paths:
click.echo(f"Exported {p.filename} to {export_paths}")
else:
click.echo(f"Did not export missing file {p.filename}")
else:
@ -1132,7 +1151,7 @@ def help(ctx, topic, **kw):
click.echo(ctx.parent.get_help())
else:
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):
@ -1483,9 +1502,13 @@ def export_photo(
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
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 photo.ismissing:
space = " " if not verbose else ""
@ -1515,20 +1538,25 @@ def export_photo(
if export_by_date:
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:
dirname, unmatched = render_filepath_template(directory, photo)
dirname = dirname[0]
# got a directory template, render it and check results are valid
dirnames, unmatched = render_filepath_template(directory, photo)
if unmatched:
click.echo(
f"Possible unmatched substitution in template: {unmatched}", err=True
raise click.BadOptionUsage(
"directory",
f"Invalid substitution in template '{directory}': {unmatched}",
)
dirname = sanitize_filepath(dirname, platform="auto")
if not is_valid_filepath(dirname, platform="auto"):
raise ValueError(f"Invalid file path: {dirname}")
dest = os.path.join(dest, dirname)
if not os.path.isdir(dest):
os.makedirs(dest)
dest_paths = []
for dirname in dirnames:
dirname = sanitize_filepath(dirname, platform="auto")
if not is_valid_filepath(dirname, platform="auto"):
raise ValueError(f"Invalid file path: {dirname}")
dest_path = os.path.join(dest, dirname)
if not os.path.isdir(dest_path):
os.makedirs(dest_path)
dest_paths.append(dest_path)
sidecar = [s.lower() for s in sidecar]
sidecar_json = sidecar_xmp = False
@ -1542,49 +1570,56 @@ def export_photo(
use_photos_export = download_missing and (
photo.ismissing or not os.path.exists(photo.path)
)
photo_path = photo.export(
dest,
filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
live_photo=export_live,
overwrite=overwrite,
use_photos_export=use_photos_export,
exiftool=exiftool,
)[0]
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
use_photos_export = download_missing and photo.path_edited is None
if not download_missing and photo.path_edited is None:
click.echo(f"Skipping missing edited photo for {filename}")
else:
edited_name = pathlib.Path(filename)
# check for correct edited suffix
if photo.path_edited is not None:
edited_suffix = pathlib.Path(photo.path_edited).suffix
# export the photo to each path in dest_paths
photo_paths = []
for dest_path in dest_paths:
photo_path = photo.export(
dest_path,
filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
live_photo=export_live,
overwrite=overwrite,
use_photos_export=use_photos_export,
exiftool=exiftool,
)[0]
photo_paths.append(photo_path)
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
use_photos_export = download_missing and photo.path_edited is None
if not download_missing and photo.path_edited is None:
click.echo(f"Skipping missing edited photo for {filename}")
else:
# use filename suffix which might be wrong,
# will be corrected by use_photos_export
edited_suffix = pathlib.Path(photo.filename).suffix
edited_name = f"{edited_name.stem}_edited{edited_suffix}"
if verbose:
click.echo(f"Exporting edited version of {filename} as {edited_name}")
photo.export(
dest,
edited_name,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
overwrite=overwrite,
edited=True,
use_photos_export=use_photos_export,
exiftool=exiftool,
)
edited_name = pathlib.Path(filename)
# check for correct edited suffix
if photo.path_edited is not None:
edited_suffix = pathlib.Path(photo.path_edited).suffix
else:
# use filename suffix which might be wrong,
# will be corrected by use_photos_export
edited_suffix = pathlib.Path(photo.filename).suffix
edited_name = f"{edited_name.stem}_edited{edited_suffix}"
if verbose:
click.echo(
f"Exporting edited version of {filename} as {edited_name}"
)
photo.export(
dest_path,
edited_name,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
overwrite=overwrite,
edited=True,
use_photos_export=use_photos_export,
exiftool=exiftool,
)
return photo_path
return photo_paths
if __name__ == "__main__":

View File

@ -1,3 +1,3 @@
""" 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)
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{album}": "Album photo is contained in",
"{keyword}": "Keywords assigned to photo",
"{person}": "Person / face in a photo",
"{album}": "Album(s) photo is contained in",
"{keyword}": "Keyword(s) assigned to photo",
"{person}": "Person(s) / face(s) in a photo",
}
# 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/keyword2/person1',]
rendered_strings = [rendered]
rendered_strings = set([rendered])
for field in MULTI_VALUE_SUBSTITUTIONS:
if field == "album":
values = photo.albums
@ -320,11 +320,7 @@ def render_filepath_template(template, photo, none_str="_"):
elif field == "person":
values = photo.persons
# remove any _UNKNOWN_PERSON values
try:
values.remove(_UNKNOWN_PERSON)
except:
pass
values = [val for val in values if val != _UNKNOWN_PERSON]
else:
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
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
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 val in values:
@ -351,7 +350,7 @@ def render_filepath_template(template, photo, none_str="_"):
photo, none_str, get_func=get_template_value_multi
)
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
rendered_strings = new_strings

View File

@ -50,6 +50,26 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
"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 = [
"St James's Park, Great Britain, Westminster, England, United Kingdom/St James Park.jpg",
"_/Pumpkins3.jpg",
@ -407,10 +427,66 @@ def test_export_directory_template_3():
"{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 "Possible unmatched substitution in template: ['foo']" in result.output
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))

View File

@ -190,6 +190,22 @@ def test_subst_multi_2_1_1():
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():
""" Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons
@ -206,6 +222,22 @@ def test_subst_multi_0_2_0():
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():
""" Test that substitutions are correct """
# 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 """
from osxphotos.template import TEMPLATE_SUBSTITUTIONS
from osxphotos.template import (
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
)
print("| Substitution | Description |")
print("|--------------|-------------|")
for subst, descr in TEMPLATE_SUBSTITUTIONS.items():
for subst, descr in [
*TEMPLATE_SUBSTITUTIONS.items(),
*TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items(),
]:
print(f"|{subst}|{descr}|")