Added {album_seq} and {folder_album_seq}, #496

This commit is contained in:
Rhet Turnbull
2021-07-24 20:41:31 -07:00
parent 6e9f709279
commit 12f39dbaf5
5 changed files with 242 additions and 28 deletions

View File

@@ -1803,11 +1803,55 @@ Substitution Description
{id} A unique number for the photo based on its {id} A unique number for the photo based on its
primary key in the Photos database. A primary key in the Photos database. A
sequential integer, e.g. 1, 2, 3...etc. May be sequential integer, e.g. 1, 2, 3...etc. Each
asset associated with a photo (e.g. an image
and Live Photo preview) will share the same
id. May be formatted using a python string
format code. For example, to format as a
5-digit integer and pad with zeros, use
'{id:05d}' which results in 00001, 00002,
00003...etc.
{album_seq} An integer, starting at 0, indicating the
photo's index (sequence) in the containing
album. Only valid when used in a '--filename'
template and only when '{album}' or
'{folder_album}' is used in the '--directory'
template. For example '--directory
"{folder_album}" --filename
"{album_seq}_{original_name}"'. To start
counting at a value other than 0, append
append a period and the starting value to the
field name. For example, to start counting at
1 instead of 0: '{album_seq.1}'. May be
formatted using a python string format code. formatted using a python string format code.
For example, to format as a 5-digit integer For example, to format as a 5-digit integer
and pad with zeros, use '{id:05d}' which and pad with zeros, use '{album_seq:05d}'
results in 00001, 00002, 00003...etc. which results in 00001, 00002, 00003...etc.
This may result in incorrect sequences if you
have duplicate albums with the same name; see
also '{folder_album_seq}'.
{folder_album_seq} An integer, starting at 0, indicating the
photo's index (sequence) in the containing
album and folder path. Only valid when used in
a '--filename' template and only when
'{folder_album}' is used in the '--directory'
template. For example '--directory
"{folder_album}" --filename
"{folder_album_seq}_{original_name}"'. To
start counting at a value other than 0, append
append a period and the starting value to the
field name. For example, to start counting at
1 instead of 0: '{folder_album_seq.1}' May be
formatted using a python string format code.
For example, to format as a 5-digit integer
and pad with zeros, use
'{folder_album_seq:05d}' which results in
00001, 00002, 00003...etc. This may result in
incorrect sequences if you have duplicate
albums with the same name in the same folder;
see also '{album_seq}'.
{comma} A comma: ',' {comma} A comma: ','
{semicolon} A semicolon: ';' {semicolon} A semicolon: ';'
@@ -1823,7 +1867,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline} {lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r' {cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n' {crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.42.66' {osxphotos_version} The osxphotos version, e.g. '0.42.67'
{osxphotos_cmd_line} The full command line used to run osxphotos {osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for The following substitutions may result in multiple values. Thus if specified for
@@ -3657,7 +3701,9 @@ The following template field substitutions are availabe for use the templating s
|{exif.camera_model}|Camera model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s'| |{exif.camera_model}|Camera model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s'|
|{exif.lens_model}|Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'| |{exif.lens_model}|Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'|
|{uuid}|Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'| |{uuid}|Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'|
|{id}|A unique number for the photo based on its primary key in the Photos database. A sequential integer, e.g. 1, 2, 3...etc. May be formatted using a python string format code. For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in 00001, 00002, 00003...etc. | |{id}|A unique number for the photo based on its primary key in the Photos database. A sequential integer, e.g. 1, 2, 3...etc. Each asset associated with a photo (e.g. an image and Live Photo preview) will share the same id. May be formatted using a python string format code. For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in 00001, 00002, 00003...etc. |
|{album_seq}|An integer, starting at 0, indicating the photo's index (sequence) in the containing album. Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. For example '--directory "{folder_album}" --filename "{album_seq}_{original_name}"'. To start counting at a value other than 0, append append a period and the starting value to the field name. For example, to start counting at 1 instead of 0: '{album_seq.1}'. May be formatted using a python string format code. For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in 00001, 00002, 00003...etc. This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'.|
|{folder_album_seq}|An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. For example '--directory "{folder_album}" --filename "{folder_album_seq}_{original_name}"'. To start counting at a value other than 0, append append a period and the starting value to the field name. For example, to start counting at 1 instead of 0: '{folder_album_seq.1}' May be formatted using a python string format code. For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in 00001, 00002, 00003...etc. This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'.|
|{comma}|A comma: ','| |{comma}|A comma: ','|
|{semicolon}|A semicolon: ';'| |{semicolon}|A semicolon: ';'|
|{questionmark}|A question mark: '?'| |{questionmark}|A question mark: '?'|
@@ -3672,7 +3718,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}| |{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'| |{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'| |{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.66'| |{osxphotos_version}|The osxphotos version, e.g. '0.42.67'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos| |{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in| |{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder| |{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.42.66" __version__ = "0.42.67"

View File

@@ -128,9 +128,28 @@ TEMPLATE_SUBSTITUTIONS = {
"{exif.lens_model}": "Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'", "{exif.lens_model}": "Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'",
"{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'", "{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'",
"{id}": "A unique number for the photo based on its primary key in the Photos database. " "{id}": "A unique number for the photo based on its primary key in the Photos database. "
+ "A sequential integer, e.g. 1, 2, 3...etc. May be formatted using a python string format code. " + "A sequential integer, e.g. 1, 2, 3...etc. Each asset associated with a photo (e.g. an image and Live Photo preview) will share the same id. "
+ "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in " + "For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in "
+ "00001, 00002, 00003...etc. ", + "00001, 00002, 00003...etc. ",
"{album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album. "
+ "Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. "
+ 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. '
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. "
+ "For example, to start counting at 1 instead of 0: '{album_seq.1}'. "
+ "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in "
+ "00001, 00002, 00003...etc. "
+ "This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'.",
"{folder_album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. "
+ "Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. "
+ 'For example \'--directory "{folder_album}" --filename "{folder_album_seq}_{original_name}"\'. '
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. "
+ "For example, to start counting at 1 instead of 0: '{folder_album_seq.1}' "
+ "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in "
+ "00001, 00002, 00003...etc. "
+ "This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'.",
"{comma}": "A comma: ','", "{comma}": "A comma: ','",
"{semicolon}": "A semicolon: ';'", "{semicolon}": "A semicolon: ';'",
"{questionmark}": "A question mark: '?'", "{questionmark}": "A question mark: '?'",
@@ -297,13 +316,10 @@ class PhotoTemplateParser:
"""Parse a template_statement string""" """Parse a template_statement string"""
return self.metamodel.model_from_str(template_statement) return self.metamodel.model_from_str(template_statement)
def fields(self, template_statement):
def format_id_str(value, format_str): """Return list of fields found in a template statement; does not verify that fields are valid"""
"""Format value based on format code in field in format id:02d""" model = self.parse(template_statement)
if not format_str: return [ts.template.field for ts in model.template_strings if ts.template]
return str(value)
format_str = "{0:" + f"{format_str}" + "}"
return format_str.format(value)
class PhotoTemplate: class PhotoTemplate:
@@ -329,6 +345,7 @@ class PhotoTemplate:
# initialize render options # initialize render options
# this will be done in render() but for testing, some of the lookup functions are called directly # this will be done in render() but for testing, some of the lookup functions are called directly
options = RenderOptions() options = RenderOptions()
self.options = options
self.path_sep = options.path_sep self.path_sep = options.path_sep
self.inplace_sep = options.inplace_sep self.inplace_sep = options.inplace_sep
self.edited_version = options.edited_version self.edited_version = options.edited_version
@@ -340,6 +357,7 @@ class PhotoTemplate:
self.export_dir = options.export_dir self.export_dir = options.export_dir
self.filepath = options.filepath self.filepath = options.filepath
self.quote = options.quote self.quote = options.quote
self.dest_path = options.dest_path
def render( def render(
self, self,
@@ -359,6 +377,7 @@ class PhotoTemplate:
if type(template) is not str: if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}") raise TypeError(f"template must be type str, not {type(template)}")
self.options = options
self.path_sep = options.path_sep self.path_sep = options.path_sep
self.inplace_sep = options.inplace_sep self.inplace_sep = options.inplace_sep
self.edited_version = options.edited_version self.edited_version = options.edited_version
@@ -371,7 +390,7 @@ class PhotoTemplate:
self.dest_path = options.dest_path self.dest_path = options.dest_path
self.filepath = options.filepath self.filepath = options.filepath
self.quote = options.quote self.quote = options.quote
self.options = options self.dest_path = options.dest_path
try: try:
model = self.parser.parse(template) model = self.parser.parse(template)
@@ -499,7 +518,10 @@ class PhotoTemplate:
conditional_value = [] conditional_value = []
vals = [] vals = []
if field in SINGLE_VALUE_SUBSTITUTIONS: if (
field in SINGLE_VALUE_SUBSTITUTIONS
or field.split(".")[0] in SINGLE_VALUE_SUBSTITUTIONS
):
vals = self.get_template_value( vals = self.get_template_value(
field, field,
default=default, default=default,
@@ -578,12 +600,8 @@ class PhotoTemplate:
f"comparison operators may only be used with a single value: {vals} {conditional_value}" f"comparison operators may only be used with a single value: {vals} {conditional_value}"
) )
try: try:
match = ( match = bool(
True test_function(float(vals[0]), float(conditional_value[0]))
if test_function(
float(vals[0]), float(conditional_value[0])
)
else False
) )
if (match and not negation) or (negation and not match): if (match and not negation) or (negation and not match):
return ["True"] return ["True"]
@@ -683,9 +701,6 @@ class PhotoTemplate:
if self.photo.uuid is None: if self.photo.uuid is None:
return [] return []
if field not in FIELD_NAMES:
raise ValueError(f"SyntaxError: Unknown field: {field}")
# initialize today with current date/time if needed # initialize today with current date/time if needed
if self.today is None: if self.today is None:
self.today = datetime.datetime.now() self.today = datetime.datetime.now()
@@ -938,8 +953,26 @@ class PhotoTemplate:
value = self.photo.exif_info.lens_model if self.photo.exif_info else None value = self.photo.exif_info.lens_model if self.photo.exif_info else None
elif field == "uuid": elif field == "uuid":
value = self.photo.uuid value = self.photo.uuid
elif field.startswith("id"): elif field == "id":
value = format_id_str(self.photo._info["pk"], subfield) value = format_str_value(self.photo._info["pk"], subfield)
elif field.startswith("album_seq") or field.startswith("folder_album_seq"):
dest_path = self.dest_path
if not dest_path:
value = None
else:
if field.startswith("album_seq"):
album = pathlib.Path(dest_path).name
album_info = _get_album_by_name(self.photo, album)
else:
album_info = _get_album_by_path(self.photo, dest_path)
value = album_info.photo_index(self.photo) if album_info else None
if value is not None:
try:
start_id = field.split(".", 1)
value = int(value) + int(start_id[1])
except IndexError:
pass
value = format_str_value(value, subfield)
elif field in PUNCTUATION: elif field in PUNCTUATION:
value = PUNCTUATION[field] value = PUNCTUATION[field]
elif field == "osxphotos_version": elif field == "osxphotos_version":
@@ -1353,3 +1386,31 @@ def _get_pathlib_value(field, value, quote):
return val_str return val_str
except AttributeError: except AttributeError:
raise ValueError("Illegal value for path template: {attribute}") raise ValueError("Illegal value for path template: {attribute}")
def format_str_value(value, format_str):
"""Format value based on format code in field in format id:02d"""
if not format_str:
return str(value)
format_str = "{0:" + f"{format_str}" + "}"
return format_str.format(value)
def _get_album_by_name(photo, album):
"""Finds first album named album that photo is in and returns the AlbumInfo object, otherwise returns None"""
for album_info in photo.album_info:
if album_info.title == album:
return album_info
return None
def _get_album_by_path(photo, folder_album_path):
"""finds the first album whose folder_album path matches and folder_album_path and returns the AlbumInfo object, otherwise, returns None"""
for album_info in photo.album_info:
# following code is how {folder_album} builds the folder path
folder = "/".join(sanitize_dirname(f) for f in album_info.folder_names)
folder += "/" + sanitize_dirname(album_info.title)
if folder_album_path.endswith(folder):
return album_info
return None

View File

@@ -778,6 +778,21 @@ UUID_DICT_MISSING = {
"D79B8D77-BFFC-460B-9312-034F2877D35B": "Pumkins2.jpg", # not missing "D79B8D77-BFFC-460B-9312-034F2877D35B": "Pumkins2.jpg", # not missing
} }
UUID_DICT_FOLDER_ALBUM_SEQ = {
"7783E8E6-9CAC-40F3-BE22-81FB7051C266": {
"directory": "{folder_album}",
"album": "Sorted Oldest First",
"filename": "{album?{folder_album_seq.1}_,}{original_name}",
"result": "3_IMG_3092.heic",
},
"3DD2C897-F19E-4CA6-8C22-B027D5A71907": {
"directory": "{album}",
"album": "Sorted Oldest First",
"filename": "{album?{album_seq}_,}{original_name}",
"result": "0_IMG_4547.jpg",
},
}
def modify_file(filename): def modify_file(filename):
"""appends data to a file to modify it""" """appends data to a file to modify it"""
@@ -7070,3 +7085,39 @@ def test_export_query_function():
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "exported: 1" in result.output assert "exported: 1" in result.output
def test_export_album_seq():
"""Test {album_seq} template"""
import glob
from osxphotos.cli import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in UUID_DICT_FOLDER_ALBUM_SEQ:
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--album",
UUID_DICT_FOLDER_ALBUM_SEQ[uuid]["album"],
"--directory",
UUID_DICT_FOLDER_ALBUM_SEQ[uuid]["directory"],
"--filename",
UUID_DICT_FOLDER_ALBUM_SEQ[uuid]["filename"],
"--uuid",
uuid,
],
)
assert result.exit_code == 0
files = glob.glob(f"{UUID_DICT_FOLDER_ALBUM_SEQ[uuid]['album']}/*")
assert (
f"{UUID_DICT_FOLDER_ALBUM_SEQ[uuid]['album']}/{UUID_DICT_FOLDER_ALBUM_SEQ[uuid]['result']}"
in files
)

View File

@@ -343,6 +343,49 @@ UUID_CONDITIONAL = {
}, },
} }
UUID_ALBUM_SEQ = {
"7783E8E6-9CAC-40F3-BE22-81FB7051C266": {
"album": "/Sorted Manual",
"templates": {
"{album_seq}": "0",
"{album_seq:02d}": "00",
"{album_seq.1}": "1",
"{album_seq.1:03d}": "001",
"{folder_album_seq}": "0",
"{folder_album_seq:02d}": "00",
"{folder_album_seq.1}": "1",
"{folder_album_seq.1:03d}": "001",
},
},
"F12384F6-CD17-4151-ACBA-AE0E3688539E": {
"album": "/Sorted Manual",
"templates": {
"{album_seq}": "2",
"{album_seq:02d}": "02",
"{album_seq.1}": "3",
"{album_seq.1:03d}": "003",
"{folder_album_seq}": "2",
"{folder_album_seq:02d}": "02",
"{folder_album_seq.1}": "3",
"{folder_album_seq.1:03d}": "003",
},
},
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
"album": "/Folder1/SubFolder2/AlbumInFolder",
"templates": {
"{album_seq}": "1",
"{album_seq:02d}": "01",
"{album_seq.1}": "2",
"{album_seq.1:03d}": "002",
"{folder_album_seq}": "1",
"{folder_album_seq:02d}": "01",
"{folder_album_seq.1}": "2",
"{folder_album_seq.0}": "1",
"{folder_album_seq.1:03d}": "002",
},
},
}
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def photosdb_places(): def photosdb_places():
@@ -1105,3 +1148,16 @@ def test_id(photosdb):
rendered, _ = photo.render_template("{id:03d}") rendered, _ = photo.render_template("{id:03d}")
assert rendered[0] == "007" assert rendered[0] == "007"
def test_album_seq(photosdb):
"""Test {album_seq} and {folder_album_seq} templates"""
from osxphotos.phototemplate import RenderOptions
for uuid in UUID_ALBUM_SEQ:
photo = photosdb.get_photo(uuid)
album = UUID_ALBUM_SEQ[uuid]["album"]
options = RenderOptions(dest_path=album)
for template, value in UUID_ALBUM_SEQ[uuid]["templates"].items():
rendered, _ = photo.render_template(template, options=options)
assert rendered[0] == value