Added {album_seq} and {folder_album_seq}, #496
This commit is contained in:
58
README.md
58
README.md
@@ -1803,11 +1803,55 @@ Substitution Description
|
||||
|
||||
{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
|
||||
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 '{id:05d}' which
|
||||
results in 00001, 00002, 00003...etc.
|
||||
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: ','
|
||||
{semicolon} A semicolon: ';'
|
||||
@@ -1823,7 +1867,7 @@ Substitution Description
|
||||
{lf} A line feed: '\n', alias for {newline}
|
||||
{cr} A carriage return: '\r'
|
||||
{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
|
||||
|
||||
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.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'|
|
||||
|{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: ','|
|
||||
|{semicolon}|A semicolon: ';'|
|
||||
|{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}|
|
||||
|{cr}|A carriage return: '\r'|
|
||||
|{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|
|
||||
|{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|
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.66"
|
||||
__version__ = "0.42.67"
|
||||
|
||||
@@ -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'",
|
||||
"{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. "
|
||||
+ "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: ','",
|
||||
"{semicolon}": "A semicolon: ';'",
|
||||
"{questionmark}": "A question mark: '?'",
|
||||
@@ -297,13 +316,10 @@ class PhotoTemplateParser:
|
||||
"""Parse a template_statement string"""
|
||||
return self.metamodel.model_from_str(template_statement)
|
||||
|
||||
|
||||
def format_id_str(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 fields(self, template_statement):
|
||||
"""Return list of fields found in a template statement; does not verify that fields are valid"""
|
||||
model = self.parse(template_statement)
|
||||
return [ts.template.field for ts in model.template_strings if ts.template]
|
||||
|
||||
|
||||
class PhotoTemplate:
|
||||
@@ -329,6 +345,7 @@ class PhotoTemplate:
|
||||
# initialize render options
|
||||
# this will be done in render() but for testing, some of the lookup functions are called directly
|
||||
options = RenderOptions()
|
||||
self.options = options
|
||||
self.path_sep = options.path_sep
|
||||
self.inplace_sep = options.inplace_sep
|
||||
self.edited_version = options.edited_version
|
||||
@@ -340,6 +357,7 @@ class PhotoTemplate:
|
||||
self.export_dir = options.export_dir
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
self.dest_path = options.dest_path
|
||||
|
||||
def render(
|
||||
self,
|
||||
@@ -359,6 +377,7 @@ class PhotoTemplate:
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
self.options = options
|
||||
self.path_sep = options.path_sep
|
||||
self.inplace_sep = options.inplace_sep
|
||||
self.edited_version = options.edited_version
|
||||
@@ -371,7 +390,7 @@ class PhotoTemplate:
|
||||
self.dest_path = options.dest_path
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
self.options = options
|
||||
self.dest_path = options.dest_path
|
||||
|
||||
try:
|
||||
model = self.parser.parse(template)
|
||||
@@ -499,7 +518,10 @@ class PhotoTemplate:
|
||||
conditional_value = []
|
||||
|
||||
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(
|
||||
field,
|
||||
default=default,
|
||||
@@ -578,12 +600,8 @@ class PhotoTemplate:
|
||||
f"comparison operators may only be used with a single value: {vals} {conditional_value}"
|
||||
)
|
||||
try:
|
||||
match = (
|
||||
True
|
||||
if test_function(
|
||||
float(vals[0]), float(conditional_value[0])
|
||||
)
|
||||
else False
|
||||
match = bool(
|
||||
test_function(float(vals[0]), float(conditional_value[0]))
|
||||
)
|
||||
if (match and not negation) or (negation and not match):
|
||||
return ["True"]
|
||||
@@ -683,9 +701,6 @@ class PhotoTemplate:
|
||||
if self.photo.uuid is None:
|
||||
return []
|
||||
|
||||
if field not in FIELD_NAMES:
|
||||
raise ValueError(f"SyntaxError: Unknown field: {field}")
|
||||
|
||||
# initialize today with current date/time if needed
|
||||
if self.today is None:
|
||||
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
|
||||
elif field == "uuid":
|
||||
value = self.photo.uuid
|
||||
elif field.startswith("id"):
|
||||
value = format_id_str(self.photo._info["pk"], subfield)
|
||||
elif field == "id":
|
||||
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:
|
||||
value = PUNCTUATION[field]
|
||||
elif field == "osxphotos_version":
|
||||
@@ -1353,3 +1386,31 @@ def _get_pathlib_value(field, value, quote):
|
||||
return val_str
|
||||
except AttributeError:
|
||||
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
|
||||
|
||||
@@ -778,6 +778,21 @@ UUID_DICT_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):
|
||||
"""appends data to a file to modify it"""
|
||||
@@ -7070,3 +7085,39 @@ def test_export_query_function():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
def photosdb_places():
|
||||
@@ -1105,3 +1148,16 @@ def test_id(photosdb):
|
||||
|
||||
rendered, _ = photo.render_template("{id:03d}")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user