Added {album}, {keyword}, and {person} to template system
This commit is contained in:
parent
6a898886dd
commit
507c4a3740
26
README.md
26
README.md
@ -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.
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.24.5"
|
||||
__version__ = "0.25.0"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}|")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user