diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py
index 074b68f9..2ade46bc 100644
--- a/osxphotos/__main__.py
+++ b/osxphotos/__main__.py
@@ -1,9 +1,7 @@
""" command line interface for osxphotos """
import csv
import datetime
-import functools
import json
-import logging
import os
import os.path
import pathlib
@@ -14,12 +12,6 @@ import unicodedata
import click
import yaml
-from pathvalidate import (
- is_valid_filename,
- is_valid_filepath,
- sanitize_filename,
- sanitize_filepath,
-)
import osxphotos
@@ -29,11 +21,12 @@ from ._constants import (
_UNKNOWN_PLACE,
UNICODE_FORMAT,
)
-from .export_db import ExportDB, ExportDBInMemory
from ._version import __version__
from .datetime_formatter import DateTimeFormatter
from .exiftool import get_exiftool_path
+from .export_db import ExportDB, ExportDBInMemory
from .fileutil import FileUtil, FileUtilNoOp
+from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
@@ -1240,10 +1233,10 @@ def query(
"--jpeg-quality",
type=click.FloatRange(0.0, 1.0),
default=1.0,
- help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
+ help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
"A value of 1.0 specifies best quality, "
"a value of 0.0 specifies maximum compression. "
- "Defaults to 1.0."
+ "Defaults to 1.0.",
)
@click.option(
"--sidecar",
@@ -2409,7 +2402,9 @@ def get_filenames_from_template(photo, filename_template, original_name):
"""
if filename_template:
photo_ext = pathlib.Path(photo.original_filename).suffix
- filenames, unmatched = photo.render_template(filename_template, path_sep="_")
+ filenames, unmatched = photo.render_template(
+ filename_template, path_sep="_", filename=True
+ )
if not filenames or unmatched:
raise click.BadOptionUsage(
"filename_template",
@@ -2418,6 +2413,8 @@ def get_filenames_from_template(photo, filename_template, original_name):
filenames = [f"{file_}{photo_ext}" for file_ in filenames]
else:
filenames = [photo.original_filename] if original_name else [photo.filename]
+
+ filenames = [sanitize_filename(filename) for filename in filenames]
return filenames
@@ -2448,22 +2445,18 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
dest_paths = [dest_path]
elif directory:
# got a directory template, render it and check results are valid
- dirnames, unmatched = photo.render_template(directory)
- if not dirnames:
- raise click.BadOptionUsage(
- "directory",
- f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
- )
- elif unmatched:
+ dirnames, unmatched = photo.render_template(directory, dirname=True)
+ if not dirnames or unmatched:
raise click.BadOptionUsage(
"directory",
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
)
+
dest_paths = []
for dirname in dirnames:
- dirname = sanitize_filepath(dirname, platform="auto")
+ dirname = sanitize_filepath(dirname)
dest_path = os.path.join(dest, dirname)
- if not is_valid_filepath(dest_path, platform="auto"):
+ if not is_valid_filepath(dest_path):
raise ValueError(f"Invalid file path: '{dest_path}'")
if not dry_run and not os.path.isdir(dest_path):
os.makedirs(dest_path)
@@ -2491,7 +2484,7 @@ def find_files_in_branch(pathname, filename):
files = []
# walk down the tree
- for root, directories, filenames in os.walk(pathname):
+ for root, _, filenames in os.walk(pathname):
# for directory in directories:
# print(os.path.join(root, directory))
for fname in filenames:
diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py
index fde74c14..56587836 100644
--- a/osxphotos/_constants.py
+++ b/osxphotos/_constants.py
@@ -102,3 +102,10 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
SEARCH_CATEGORY_LABEL = 2024
+
+# Max filename length on MacOS
+MAX_FILENAME_LEN = 255
+
+# Max directory name length on MacOS
+MAX_DIRNAME_LEN = 255
+
diff --git a/osxphotos/_version.py b/osxphotos/_version.py
index 073f0b6c..08db0c61 100644
--- a/osxphotos/_version.py
+++ b/osxphotos/_version.py
@@ -1,4 +1,4 @@
""" version info """
-__version__ = "0.35.3"
+__version__ = "0.35.4"
diff --git a/osxphotos/path_utils.py b/osxphotos/path_utils.py
new file mode 100644
index 00000000..e937a2a8
--- /dev/null
+++ b/osxphotos/path_utils.py
@@ -0,0 +1,78 @@
+""" utility functions for validating/sanitizing path components """
+
+from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
+import pathvalidate
+
+
+def sanitize_filepath(filepath):
+ """ sanitize a filepath """
+ return pathvalidate.sanitize_filepath(filepath, platform="macos")
+
+
+def is_valid_filepath(filepath):
+ """ returns True if a filepath is valid otherwise False """
+ return pathvalidate.is_valid_filepath(filepath, platform="macos")
+
+
+def sanitize_filename(filename, replacement=":"):
+ """ replace any illegal characters in a filename and truncate filename if needed
+
+ Args:
+ filename: str, filename to sanitze
+ replacement: str, value to replace any illegal characters with; default = ":"
+
+ Returns:
+ filename with any illegal characters replaced by replacement and truncated if necessary
+ """
+
+ if filename:
+ filename = filename.replace("/", replacement)
+ if len(filename) > MAX_FILENAME_LEN:
+ parts = filename.split(".")
+ drop = len(filename) - MAX_FILENAME_LEN
+ if len(parts) > 1:
+ # has an extension
+ ext = parts.pop(-1)
+ stem = ".".join(parts)
+ if drop > len(stem):
+ ext = ext[:-drop]
+ else:
+ stem = stem[:-drop]
+ filename = f"{stem}.{ext}"
+ else:
+ filename = filename[:-drop]
+ return filename
+
+
+def sanitize_dirname(dirname, replacement=":"):
+ """ replace any illegal characters in a directory name and truncate directory name if needed
+
+ Args:
+ dirname: str, directory name to sanitze
+ replacement: str, value to replace any illegal characters with; default = ":"
+
+ Returns:
+ dirname with any illegal characters replaced by replacement and truncated if necessary
+ """
+ if dirname:
+ dirname = sanitize_pathpart(dirname, replacement=replacement)
+ return dirname
+
+
+def sanitize_pathpart(pathpart, replacement=":"):
+ """ replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
+
+ Args:
+ pathpart: str, path part to sanitze
+ replacement: str, value to replace any illegal characters with; default = ":"
+
+ Returns:
+ pathpart with any illegal characters replaced by replacement and truncated if necessary
+ """
+ if pathpart:
+ pathpart = pathpart.replace("/", replacement)
+ if len(pathpart) > MAX_DIRNAME_LEN:
+ drop = len(pathpart) - MAX_DIRNAME_LEN
+ pathpart = pathpart[:-drop]
+ return pathpart
+
diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py
index 0fb116b6..fd152cbf 100644
--- a/osxphotos/photoinfo/photoinfo.py
+++ b/osxphotos/photoinfo/photoinfo.py
@@ -808,6 +808,9 @@ class PhotoInfo:
path_sep=None,
expand_inplace=False,
inplace_sep=None,
+ filename=False,
+ dirname=False,
+ replacement=":",
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
@@ -820,6 +823,9 @@ class PhotoInfo:
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
+ filename: if True, template output will be sanitized to produce valid file name
+ dirname: if True, template output will be sanitized to produce valid directory name
+ replacement: str, value to replace any illegal file path characters with; default = ":"
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
@@ -831,6 +837,9 @@ class PhotoInfo:
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
+ filename=filename,
+ dirname=dirname,
+ replacement=replacement
)
@property
diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py
index 377bf9d0..fbc5d587 100644
--- a/osxphotos/phototemplate.py
+++ b/osxphotos/phototemplate.py
@@ -12,11 +12,13 @@
import datetime
import locale
import os
-import re
import pathlib
+import re
+from functools import partial
from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
+from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
# ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "")
@@ -131,6 +133,9 @@ class PhotoTemplate:
path_sep=None,
expand_inplace=False,
inplace_sep=None,
+ filename=False,
+ dirname=False,
+ replacement=":",
):
""" Render a filename or directory template
@@ -142,6 +147,9 @@ class PhotoTemplate:
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
+ filename: if True, template output will be sanitized to produce valid file name
+ dirname: if True, template output will be sanitized to produce valid directory name
+ replacement: str, value to replace any illegal file path characters with; default = ":"
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
@@ -170,13 +178,21 @@ class PhotoTemplate:
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
- def make_subst_function(self, none_str, get_func=self.get_template_value):
+ # used by make_subst_function to get the value for a template substitution
+ get_func = partial(
+ self.get_template_value,
+ filename=filename,
+ dirname=dirname,
+ replacement=replacement,
+ )
+
+ def make_subst_function(self, none_str, get_func=get_func):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
- # closure to capture photo, none_str in subst
+ # closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
@@ -186,13 +202,13 @@ class PhotoTemplate:
return matchobj.group(0)
if val is None:
- return (
+ val = (
matchobj.group(3)
if matchobj.group(3) is not None
else none_str
)
- else:
- return val
+
+ return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
@@ -239,7 +255,13 @@ class PhotoTemplate:
for str_template in rendered_strings:
if regex_multi.search(str_template):
- values = self.get_template_value_multi(field, path_sep)
+ values = self.get_template_value_multi(
+ field,
+ path_sep,
+ filename=filename,
+ dirname=dirname,
+ replacement=replacement,
+ )
if expand_inplace:
# instead of returning multiple strings, join values into a single string
val = (
@@ -248,11 +270,11 @@ class PhotoTemplate:
else None
)
- def lookup_template_value_multi(lookup_value, default):
+ def lookup_template_value_multi(lookup_value, _):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
- default is not used but required so signature matches get_template_value """
+ _ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
@@ -269,11 +291,11 @@ class PhotoTemplate:
# create a new template string for each value
for val in values:
- def lookup_template_value_multi(lookup_value, default):
+ def lookup_template_value_multi(lookup_value, _):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
- default is not used but required so signature matches get_template_value """
+ _ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
@@ -307,14 +329,24 @@ class PhotoTemplate:
for rendered_str in rendered_strings
]
+ if filename:
+ rendered_strings = [
+ sanitize_filename(rendered_str) for rendered_str in rendered_strings
+ ]
+
return rendered_strings, unmatched
- def get_template_value(self, field, default):
+ def get_template_value(
+ self, field, default, filename=False, dirname=False, replacement=":"
+ ):
"""lookup value for template field (single-value template substitutions)
Args:
field: template field to find value for.
default: the default value provided by the user
+ filename: if True, template output will be sanitized to produce valid file name
+ dirname: if True, template output will be sanitized to produce valid directory name
+ replacement: str, value to replace any illegal file path characters with; default = ":"
Returns:
The matching template value (which may be None).
@@ -327,289 +359,236 @@ class PhotoTemplate:
if self.today is None:
self.today = datetime.datetime.now()
- # must be a valid keyword
+ value = None
+
+ # wouldn't a switch/case statement be nice...
if field == "name":
- return pathlib.Path(self.photo.filename).stem
-
- if field == "original_name":
- return pathlib.Path(self.photo.original_filename).stem
-
- if field == "title":
- return self.photo.title
-
- if field == "descr":
- return self.photo.description
-
- if field == "created.date":
- return DateTimeFormatter(self.photo.date).date
-
- if field == "created.year":
- return DateTimeFormatter(self.photo.date).year
-
- if field == "created.yy":
- return DateTimeFormatter(self.photo.date).yy
-
- if field == "created.mm":
- return DateTimeFormatter(self.photo.date).mm
-
- if field == "created.month":
- return DateTimeFormatter(self.photo.date).month
-
- if field == "created.mon":
- return DateTimeFormatter(self.photo.date).mon
-
- if field == "created.dd":
- return DateTimeFormatter(self.photo.date).dd
-
- if field == "created.dow":
- return DateTimeFormatter(self.photo.date).dow
-
- if field == "created.doy":
- return DateTimeFormatter(self.photo.date).doy
-
- if field == "created.hour":
- return DateTimeFormatter(self.photo.date).hour
-
- if field == "created.min":
- return DateTimeFormatter(self.photo.date).min
-
- if field == "created.sec":
- return DateTimeFormatter(self.photo.date).sec
-
- if field == "created.strftime":
+ value = pathlib.Path(self.photo.filename).stem
+ elif field == "original_name":
+ value = pathlib.Path(self.photo.original_filename).stem
+ elif field == "title":
+ value = self.photo.title
+ elif field == "descr":
+ value = self.photo.description
+ elif field == "created.date":
+ value = DateTimeFormatter(self.photo.date).date
+ elif field == "created.year":
+ value = DateTimeFormatter(self.photo.date).year
+ elif field == "created.yy":
+ value = DateTimeFormatter(self.photo.date).yy
+ elif field == "created.mm":
+ value = DateTimeFormatter(self.photo.date).mm
+ elif field == "created.month":
+ value = DateTimeFormatter(self.photo.date).month
+ elif field == "created.mon":
+ value = DateTimeFormatter(self.photo.date).mon
+ elif field == "created.dd":
+ value = DateTimeFormatter(self.photo.date).dd
+ elif field == "created.dow":
+ value = DateTimeFormatter(self.photo.date).dow
+ elif field == "created.doy":
+ value = DateTimeFormatter(self.photo.date).doy
+ elif field == "created.hour":
+ value = DateTimeFormatter(self.photo.date).hour
+ elif field == "created.min":
+ value = DateTimeFormatter(self.photo.date).min
+ elif field == "created.sec":
+ value = DateTimeFormatter(self.photo.date).sec
+ elif field == "created.strftime":
if default:
try:
- return self.photo.date.strftime(default)
+ value = self.photo.date.strftime(default)
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
- return None
-
- if field == "modified.date":
- return (
+ value = None
+ elif field == "modified.date":
+ value = (
DateTimeFormatter(self.photo.date_modified).date
if self.photo.date_modified
else None
)
-
- if field == "modified.year":
- return (
+ elif field == "modified.year":
+ value = (
DateTimeFormatter(self.photo.date_modified).year
if self.photo.date_modified
else None
)
-
- if field == "modified.yy":
- return (
+ elif field == "modified.yy":
+ value = (
DateTimeFormatter(self.photo.date_modified).yy
if self.photo.date_modified
else None
)
-
- if field == "modified.mm":
- return (
+ elif field == "modified.mm":
+ value = (
DateTimeFormatter(self.photo.date_modified).mm
if self.photo.date_modified
else None
)
-
- if field == "modified.month":
- return (
+ elif field == "modified.month":
+ value = (
DateTimeFormatter(self.photo.date_modified).month
if self.photo.date_modified
else None
)
-
- if field == "modified.mon":
- return (
+ elif field == "modified.mon":
+ value = (
DateTimeFormatter(self.photo.date_modified).mon
if self.photo.date_modified
else None
)
-
- if field == "modified.dd":
- return (
+ elif field == "modified.dd":
+ value = (
DateTimeFormatter(self.photo.date_modified).dd
if self.photo.date_modified
else None
)
-
- if field == "modified.doy":
- return (
+ elif field == "modified.doy":
+ value = (
DateTimeFormatter(self.photo.date_modified).doy
if self.photo.date_modified
else None
)
-
- if field == "modified.hour":
- return (
+ elif field == "modified.hour":
+ value = (
DateTimeFormatter(self.photo.date_modified).hour
if self.photo.date_modified
else None
)
-
- if field == "modified.min":
- return (
+ elif field == "modified.min":
+ value = (
DateTimeFormatter(self.photo.date_modified).min
if self.photo.date_modified
else None
)
-
- if field == "modified.sec":
- return (
+ elif field == "modified.sec":
+ value = (
DateTimeFormatter(self.photo.date_modified).sec
if self.photo.date_modified
else None
)
-
- # TODO: disabling modified.strftime for now because now clean way to pass
- # a default value if modified time is None
- # if field == "modified.strftime":
- # if default and self.photo.date_modified:
- # try:
- # return self.photo.date_modified.strftime(default)
- # except:
- # raise ValueError(f"Invalid strftime template: '{default}'")
- # else:
- # return None
-
- if field == "today.date":
- return DateTimeFormatter(self.today).date
-
- if field == "today.year":
- return DateTimeFormatter(self.today).year
-
- if field == "today.yy":
- return DateTimeFormatter(self.today).yy
-
- if field == "today.mm":
- return DateTimeFormatter(self.today).mm
-
- if field == "today.month":
- return DateTimeFormatter(self.today).month
-
- if field == "today.mon":
- return DateTimeFormatter(self.today).mon
-
- if field == "today.dd":
- return DateTimeFormatter(self.today).dd
-
- if field == "today.dow":
- return DateTimeFormatter(self.today).dow
-
- if field == "today.doy":
- return DateTimeFormatter(self.today).doy
-
- if field == "today.hour":
- return DateTimeFormatter(self.today).hour
-
- if field == "today.min":
- return DateTimeFormatter(self.today).min
-
- if field == "today.sec":
- return DateTimeFormatter(self.today).sec
-
- if field == "today.strftime":
+ elif field == "today.date":
+ value = DateTimeFormatter(self.today).date
+ elif field == "today.year":
+ value = DateTimeFormatter(self.today).year
+ elif field == "today.yy":
+ value = DateTimeFormatter(self.today).yy
+ elif field == "today.mm":
+ value = DateTimeFormatter(self.today).mm
+ elif field == "today.month":
+ value = DateTimeFormatter(self.today).month
+ elif field == "today.mon":
+ value = DateTimeFormatter(self.today).mon
+ elif field == "today.dd":
+ value = DateTimeFormatter(self.today).dd
+ elif field == "today.dow":
+ value = DateTimeFormatter(self.today).dow
+ elif field == "today.doy":
+ value = DateTimeFormatter(self.today).doy
+ elif field == "today.hour":
+ value = DateTimeFormatter(self.today).hour
+ elif field == "today.min":
+ value = DateTimeFormatter(self.today).min
+ elif field == "today.sec":
+ value = DateTimeFormatter(self.today).sec
+ elif field == "today.strftime":
if default:
try:
- return self.today.strftime(default)
+ value = self.today.strftime(default)
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
- return None
-
- if field == "place.name":
- return self.photo.place.name if self.photo.place else None
-
- if field == "place.country_code":
- return self.photo.place.country_code if self.photo.place else None
-
- if field == "place.name.country":
- return (
+ value = None
+ elif field == "place.name":
+ value = self.photo.place.name if self.photo.place else None
+ elif field == "place.country_code":
+ value = self.photo.place.country_code if self.photo.place else None
+ elif field == "place.name.country":
+ value = (
self.photo.place.names.country[0]
if self.photo.place and self.photo.place.names.country
else None
)
-
- if field == "place.name.state_province":
- return (
+ elif field == "place.name.state_province":
+ value = (
self.photo.place.names.state_province[0]
if self.photo.place and self.photo.place.names.state_province
else None
)
-
- if field == "place.name.city":
- return (
+ elif field == "place.name.city":
+ value = (
self.photo.place.names.city[0]
if self.photo.place and self.photo.place.names.city
else None
)
-
- if field == "place.name.area_of_interest":
- return (
+ elif field == "place.name.area_of_interest":
+ value = (
self.photo.place.names.area_of_interest[0]
if self.photo.place and self.photo.place.names.area_of_interest
else None
)
-
- if field == "place.address":
- return (
+ elif field == "place.address":
+ value = (
self.photo.place.address_str
if self.photo.place and self.photo.place.address_str
else None
)
-
- if field == "place.address.street":
- return (
+ elif field == "place.address.street":
+ value = (
self.photo.place.address.street
if self.photo.place and self.photo.place.address.street
else None
)
-
- if field == "place.address.city":
- return (
+ elif field == "place.address.city":
+ value = (
self.photo.place.address.city
if self.photo.place and self.photo.place.address.city
else None
)
-
- if field == "place.address.state_province":
- return (
+ elif field == "place.address.state_province":
+ value = (
self.photo.place.address.state_province
if self.photo.place and self.photo.place.address.state_province
else None
)
-
- if field == "place.address.postal_code":
- return (
+ elif field == "place.address.postal_code":
+ value = (
self.photo.place.address.postal_code
if self.photo.place and self.photo.place.address.postal_code
else None
)
-
- if field == "place.address.country":
- return (
+ elif field == "place.address.country":
+ value = (
self.photo.place.address.country
if self.photo.place and self.photo.place.address.country
else None
)
-
- if field == "place.address.country_code":
- return (
+ elif field == "place.address.country_code":
+ value = (
self.photo.place.address.iso_country_code
if self.photo.place and self.photo.place.address.iso_country_code
else None
)
+ else:
+ # if here, didn't get a match
+ raise ValueError(f"Unhandled template value: {field}")
- # if here, didn't get a match
- raise ValueError(f"Unhandled template value: {field}")
+ if filename:
+ value = sanitize_pathpart(value, replacement=replacement)
+ elif dirname:
+ value = sanitize_dirname(value, replacement=replacement)
+ return value
- def get_template_value_multi(self, field, path_sep):
+ def get_template_value_multi(
+ self, field, path_sep, filename=False, dirname=False, replacement=":"
+ ):
"""lookup value for template field (multi-value template substitutions)
Args:
field: template field to find value for.
path_sep: path separator to use for folder_album field
+ dirname: if True, values will be sanitized to be valid directory names; default = False
Returns:
List of the matching template values or [None].
@@ -621,9 +600,6 @@ class PhotoTemplate:
""" return list of values for a multi-valued template field """
if field == "album":
values = self.photo.albums
- values = [
- value.replace("/", ":") for value in values
- ] # TODO: temp fix for issue #213
elif field == "keyword":
values = self.photo.keywords
elif field == "person":
@@ -640,17 +616,42 @@ class PhotoTemplate:
for album in self.photo.album_info:
if album.folder_names:
# album in folder
- folder = path_sep.join(album.folder_names)
- folder += path_sep + album.title.replace(
- "/", ":"
- ) # TODO: temp fix for issue #213
+ if dirname:
+ # being used as a filepath so sanitize each part
+ folder = path_sep.join(
+ sanitize_dirname(f, replacement=replacement)
+ for f in album.folder_names
+ )
+ folder += path_sep + sanitize_dirname(
+ album.title, replacement=replacement
+ )
+ else:
+ folder = path_sep.join(album.folder_names)
+ folder += path_sep + album.title
values.append(folder)
else:
# album not in folder
- values.append(album.title.replace("/", ":"))
+ if dirname:
+ values.append(
+ sanitize_dirname(album.title, replacement=replacement)
+ )
+ else:
+ values.append(album.title)
else:
- raise ValueError(f"Unhandleded template value: {field}")
+ raise ValueError(f"Unhandled template value: {field}")
+
+ # sanitize directory names if needed, folder_album handled differently above
+ if filename:
+ values = [
+ sanitize_pathpart(value, replacement=replacement) for value in values
+ ]
+ elif dirname and field != "folder_album":
+ # skip folder_album because it would have been handled above
+ values = [
+ sanitize_dirname(value, replacement=replacement) for value in values
+ ]
# If no values, insert None so code below will substite none_str for None
values = values or [None]
return values
+
diff --git a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite
index 191a0e3f..c79bbeca 100644
Binary files a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite and b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite differ
diff --git a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-shm b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-shm
index 3d8e0438..ca0ac79e 100644
Binary files a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-wal b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-wal
index 88518648..60449fff 100644
Binary files a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite.lock b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite.lock
index 480501c1..440ec592 100644
--- a/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite.lock
+++ b/tests/Test-10.15.6.photoslibrary/database/Photos.sqlite.lock
@@ -7,7 +7,7 @@
hostuuid
9575E48B-8D5F-5654-ABAC-4431B1167324
pid
- 2964
+ 40469
processname
photolibraryd
uid
diff --git a/tests/Test-10.15.6.photoslibrary/database/search/psi.sqlite b/tests/Test-10.15.6.photoslibrary/database/search/psi.sqlite
index f3c95ffd..873692af 100644
Binary files a/tests/Test-10.15.6.photoslibrary/database/search/psi.sqlite and b/tests/Test-10.15.6.photoslibrary/database/search/psi.sqlite differ
diff --git a/tests/Test-10.15.6.photoslibrary/database/search/synonymsProcess.plist b/tests/Test-10.15.6.photoslibrary/database/search/synonymsProcess.plist
index 58e48cc1..5a655aa8 100644
Binary files a/tests/Test-10.15.6.photoslibrary/database/search/synonymsProcess.plist and b/tests/Test-10.15.6.photoslibrary/database/search/synonymsProcess.plist differ
diff --git a/tests/Test-10.15.6.photoslibrary/database/search/zeroKeywords.data b/tests/Test-10.15.6.photoslibrary/database/search/zeroKeywords.data
index 5d03620c..29da8dfe 100644
Binary files a/tests/Test-10.15.6.photoslibrary/database/search/zeroKeywords.data and b/tests/Test-10.15.6.photoslibrary/database/search/zeroKeywords.data differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db b/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db
index 9f719ca2..80f93dd8 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db and b/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db-shm
index 28af3ebf..c4bba1ba 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.mediaanalysisd/MediaAnalysis/mediaanalysis.db-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-shm
index ddf44042..470583de 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-wal b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-wal
index f3f296d2..396aafcb 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.AOI.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm
index 5fd01404..6fea7bbf 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal
index fd9a5f60..6fe5a185 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm
index 0edff070..78744903 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal
index 465a7269..9b8d92d4 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite
index 4ab71e6c..f7ecf813 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-shm
index 73656d14..a061f761 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-wal b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-wal
index eab46f86..eb3c46ed 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSContactCache.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm
index 69218fab..a099e375 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal
index 47c8e394..7914ee62 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm
index 3126e34e..ee2bbd34 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal
index 9ef5f028..5691e3be 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm
index dda17c2e..e4f6b1a2 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal
index 4e26de8e..e430a188 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist
index 0ffe7e55..9dfd418a 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotoAnalysisServicePreferences.plist b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotoAnalysisServicePreferences.plist
index 1ef03cbf..c4cf5f46 100644
--- a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotoAnalysisServicePreferences.plist
+++ b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotoAnalysisServicePreferences.plist
@@ -3,24 +3,24 @@
BackgroundHighlightCollection
- 2020-06-24T04:02:13Z
+ 2020-10-17T23:45:25Z
BackgroundHighlightEnrichment
- 2020-06-24T04:02:12Z
+ 2020-10-17T23:45:25Z
BackgroundJobAssetRevGeocode
- 2020-06-24T04:02:13Z
+ 2020-10-17T23:45:25Z
BackgroundJobSearch
- 2020-06-24T04:02:13Z
+ 2020-10-17T23:45:25Z
BackgroundPeopleSuggestion
- 2020-06-24T04:02:12Z
+ 2020-10-17T23:45:25Z
BackgroundUserBehaviorProcessor
- 2020-06-24T04:02:13Z
+ 2020-10-17T23:45:25Z
PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey
- 2020-05-30T02:16:06Z
+ 2020-10-17T23:45:33Z
PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate
- 2020-05-29T04:31:37Z
+ 2020-10-17T23:45:24Z
PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate
- 2020-06-24T04:02:13Z
+ 2020-10-17T23:45:26Z
SiriPortraitDonation
- 2020-06-24T04:02:13Z
+ 2020-10-17T23:45:25Z
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb
index f6b8a764..2308a5c6 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist
index f35dce33..e6d847a0 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/03632B38-194C-45C6-9FF7-6586DF36F8E0.cmap b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/03632B38-194C-45C6-9FF7-6586DF36F8E0.cmap
new file mode 100644
index 00000000..093a69f8
Binary files /dev/null and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/03632B38-194C-45C6-9FF7-6586DF36F8E0.cmap differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/4E294112-DC9D-4B49-9561-1946B53A4E19.cmap b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/4E294112-DC9D-4B49-9561-1946B53A4E19.cmap
deleted file mode 100644
index cca93d7c..00000000
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/4E294112-DC9D-4B49-9561-1946B53A4E19.cmap and /dev/null differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/9A5E0437-04AA-45DC-AAA7-FDC74A91F170.cmap b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/7DF33429-F03C-4A82-95C8-89D32BDB785C.cmap
similarity index 98%
rename from tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/9A5E0437-04AA-45DC-AAA7-FDC74A91F170.cmap
rename to tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/7DF33429-F03C-4A82-95C8-89D32BDB785C.cmap
index 40018f76..4555aae6 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/9A5E0437-04AA-45DC-AAA7-FDC74A91F170.cmap and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/7DF33429-F03C-4A82-95C8-89D32BDB785C.cmap differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/AlgoFaceClusterCache.data b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/AlgoFaceClusterCache.data
index f16b4a76..dc78ca7f 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/AlgoFaceClusterCache.data and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/AlgoFaceClusterCache.data differ
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PersonPromoter b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PersonPromoter
index 5feb64f2..1f8dd014 100644
--- a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PersonPromoter
+++ b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PersonPromoter
@@ -3,7 +3,7 @@
NumberOfFacesProcessedOnLastRun
- 7
+ 11
ProcessedInQuiescentState
SuggestedMeIdentifier
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PhotoAnalysisServicePreferences.plist b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PhotoAnalysisServicePreferences.plist
index bdd3d753..83f200a6 100644
--- a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PhotoAnalysisServicePreferences.plist
+++ b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/PhotoAnalysisServicePreferences.plist
@@ -3,8 +3,8 @@
FaceIDModelLastGenerationKey
- 2020-05-29T03:44:04Z
+ 2020-10-17T23:45:32Z
LastContactClassificationKey
- 2020-05-29T04:31:40Z
+ 2020-10-17T23:45:54Z
diff --git a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/vnpersonsmodel.bin b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/vnpersonsmodel.bin
index 211a83f7..e71756fa 100644
Binary files a/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/vnpersonsmodel.bin and b/tests/Test-10.15.6.photoslibrary/private/com.apple.photoanalysisd/caches/vision/vnpersonsmodel.bin differ
diff --git a/tests/Test-10.15.6.photoslibrary/resources/journals/Asset-change.plj b/tests/Test-10.15.6.photoslibrary/resources/journals/Asset-change.plj
index 7d4df25e..80a20dd6 100644
Binary files a/tests/Test-10.15.6.photoslibrary/resources/journals/Asset-change.plj and b/tests/Test-10.15.6.photoslibrary/resources/journals/Asset-change.plj differ
diff --git a/tests/Test-10.15.6.photoslibrary/resources/journals/HistoryToken.plist b/tests/Test-10.15.6.photoslibrary/resources/journals/HistoryToken.plist
index 46dcaaa9..b09309c6 100644
Binary files a/tests/Test-10.15.6.photoslibrary/resources/journals/HistoryToken.plist and b/tests/Test-10.15.6.photoslibrary/resources/journals/HistoryToken.plist differ
diff --git a/tests/Test-10.15.6.photoslibrary/resources/journals/Keyword-change.plj b/tests/Test-10.15.6.photoslibrary/resources/journals/Keyword-change.plj
index bc1570ab..c605f9de 100644
Binary files a/tests/Test-10.15.6.photoslibrary/resources/journals/Keyword-change.plj and b/tests/Test-10.15.6.photoslibrary/resources/journals/Keyword-change.plj differ
diff --git a/tests/test_catalina_10_15_6.py b/tests/test_catalina_10_15_6.py
index 4b9bf53a..e7fe814d 100644
--- a/tests/test_catalina_10_15_6.py
+++ b/tests/test_catalina_10_15_6.py
@@ -24,6 +24,7 @@ KEYWORDS = [
"St. James's Park",
"UK",
"United Kingdom",
+ "foo/bar",
]
# Photos 5 includes blank person for detected face
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
@@ -47,6 +48,7 @@ KEYWORDS_DICT = {
"St. James's Park": 1,
"UK": 1,
"United Kingdom": 1,
+ "foo/bar": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1}
ALBUM_DICT = {
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 47712029..aab11a09 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -229,8 +229,23 @@ CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_PATHSEP = [
"2019-10:11 Paris Clermont/IMG_4547.jpg",
]
+
+CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP = [
+ "foo:bar/foo:bar_IMG_3092.heic"
+]
+
+CLI_EXPORTED_FILENAME_TEMPLATE_LONG_DESCRIPTION = [
+ "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. "
+ "Aenean commodo ligula eget dolor. Aenean massa. "
+ "Cum sociis natoque penatibus et magnis dis parturient montes, "
+ "nascetur ridiculus mus. Donec quam felis, ultricies nec, "
+ "pellentesque eu, pretium q.tif"
+]
+
CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B"
CLI_EXPORT_UUID_STATUE = "3DD2C897-F19E-4CA6-8C22-B027D5A71907"
+CLI_EXPORT_UUID_KEYWORD_PATHSEP = "7783E8E6-9CAC-40F3-BE22-81FB7051C266"
+CLI_EXPORT_UUID_LONG_DESCRIPTION = "8846E3E6-8AC8-4857-8448-E3D025784410"
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
@@ -568,10 +583,7 @@ def test_query_uuid_from_file_1():
# build list of uuids we got from the output JSON
json_got = json.loads(result.output)
- uuid_got = []
- for photo in json_got:
- uuid_got.append(photo["uuid"])
-
+ uuid_got = [photo["uuid"] for photo in json_got]
assert sorted(UUID_EXPECTED_FROM_FILE) == sorted(uuid_got)
@@ -601,10 +613,7 @@ def test_query_uuid_from_file_2():
# build list of uuids we got from the output JSON
json_got = json.loads(result.output)
- uuid_got = []
- for photo in json_got:
- uuid_got.append(photo["uuid"])
-
+ uuid_got = [photo["uuid"] for photo in json_got]
uuid_expected = UUID_EXPECTED_FROM_FILE.copy()
uuid_expected.append(UUID_NOT_FROM_FILE)
assert sorted(uuid_expected) == sorted(uuid_got)
@@ -1965,13 +1974,12 @@ def test_export_filename_template_2():
assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2)
-def test_export_filename_template_pathsep_in_name():
+def test_export_filename_template_pathsep_in_name_1():
""" export photos using filename template with folder_album and "/" in album name """
import locale
import os
import os.path
import pathlib
- import osxphotos
from osxphotos.__main__ import export
locale.setlocale(locale.LC_ALL, "en_US")
@@ -1998,6 +2006,71 @@ def test_export_filename_template_pathsep_in_name():
assert pathlib.Path(fname).is_file()
+def test_export_filename_template_pathsep_in_name_2():
+ """ export photos using filename template with keyword and "/" in keyword """
+ import locale
+ import os
+ import os.path
+ import pathlib
+ from osxphotos.__main__ import export
+
+ locale.setlocale(locale.LC_ALL, "en_US")
+
+ runner = CliRunner()
+ cwd = os.getcwd()
+ # pylint: disable=not-context-manager
+ with runner.isolated_filesystem():
+ result = runner.invoke(
+ export,
+ [
+ os.path.join(cwd, PHOTOS_DB_15_6),
+ ".",
+ "-V",
+ "--directory",
+ "{keyword}",
+ "--filename",
+ "{keyword}_{original_name}",
+ "--uuid",
+ CLI_EXPORT_UUID_KEYWORD_PATHSEP,
+ ],
+ )
+ assert result.exit_code == 0
+ for fname in CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP:
+ assert pathlib.Path(fname).is_file()
+
+
+def test_export_filename_template_long_description():
+ """ export photos using filename template with description that exceeds max length """
+ import locale
+ import os
+ import os.path
+ import pathlib
+ import osxphotos
+ from osxphotos.__main__ import export
+
+ locale.setlocale(locale.LC_ALL, "en_US")
+
+ runner = CliRunner()
+ cwd = os.getcwd()
+ # pylint: disable=not-context-manager
+ with runner.isolated_filesystem():
+ result = runner.invoke(
+ export,
+ [
+ os.path.join(cwd, PHOTOS_DB_15_6),
+ ".",
+ "-V",
+ "--filename",
+ "{descr}",
+ "--uuid",
+ CLI_EXPORT_UUID_LONG_DESCRIPTION,
+ ],
+ )
+ assert result.exit_code == 0
+ for fname in CLI_EXPORTED_FILENAME_TEMPLATE_LONG_DESCRIPTION:
+ assert pathlib.Path(fname).is_file()
+
+
def test_export_filename_template_3():
""" test --filename with invalid template """
import glob
@@ -2489,9 +2562,8 @@ def test_export_sidecar_keyword_template():
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
)[0]
- json_file = open("Pumkins2.jpg.json", "r")
- json_got = json.load(json_file)[0]
- json_file.close()
+ with open("Pumkins2.jpg.json", "r") as json_file:
+ json_got = json.load(json_file)[0]
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py
new file mode 100644
index 00000000..137d7dc7
--- /dev/null
+++ b/tests/test_path_utils.py
@@ -0,0 +1,117 @@
+""" Test path_utils.py """
+
+def test_sanitize_filename():
+ from osxphotos.path_utils import sanitize_filename
+ from osxphotos._constants import MAX_FILENAME_LEN
+
+ # basic sanitize
+ filenames = {
+ "Foobar.txt": "Foobar.txt",
+ "Foo:bar.txt": "Foo:bar.txt",
+ "Foo/bar.txt": "Foo:bar.txt",
+ "Foo//.txt": "Foo::.txt",
+ }
+ for filename, sanitized in filenames.items():
+ filename = sanitize_filename(filename)
+ assert filename == sanitized
+
+ # sanitize with replacement
+ filenames = {
+ "Foobar.txt": "Foobar.txt",
+ "Foo:bar.txt": "Foo:bar.txt",
+ "Foo/bar.txt": "Foo_bar.txt",
+ "Foo//.txt": "Foo__.txt",
+ }
+ for filename, sanitized in filenames.items():
+ filename = sanitize_filename(filename, replacement="_")
+ assert filename == sanitized
+
+ # filename too long
+ filename = "foo" + "x" * 512
+ new_filename = sanitize_filename(filename)
+ assert len(new_filename) == MAX_FILENAME_LEN
+ assert new_filename == "foo" + "x" * 252
+
+ # filename too long with extension
+ filename = "x" * 512 + ".jpeg"
+ new_filename = sanitize_filename(filename)
+ assert len(new_filename) == MAX_FILENAME_LEN
+ assert new_filename == "x" * 250 + ".jpeg"
+
+ # more than one extension
+ filename = "foo.bar" + "x" * 255 + ".foo.bar.jpeg"
+ new_filename = sanitize_filename(filename)
+ assert len(new_filename) == MAX_FILENAME_LEN
+ assert new_filename == "foo.bar" + "x" * 243 + ".jpeg"
+
+ # shorter than drop count
+ filename = "foo." + "x" * 256
+ new_filename = sanitize_filename(filename)
+ assert len(new_filename) == MAX_FILENAME_LEN
+ assert new_filename == "foo." + "x" * 251
+
+
+def test_sanitize_dirname():
+ from osxphotos.path_utils import sanitize_dirname
+ from osxphotos._constants import MAX_DIRNAME_LEN
+
+ # basic sanitize
+ dirnames = {
+ "Foobar": "Foobar",
+ "Foo:bar": "Foo:bar",
+ "Foo/bar": "Foo:bar",
+ "Foo//": "Foo::",
+ }
+ for dirname, sanitized in dirnames.items():
+ dirname = sanitize_dirname(dirname)
+ assert dirname == sanitized
+
+ # sanitize with replacement
+ dirnames = {
+ "Foobar": "Foobar",
+ "Foo:bar": "Foo:bar",
+ "Foo/bar": "Foo_bar",
+ "Foo//": "Foo__",
+ }
+ for dirname, sanitized in dirnames.items():
+ dirname = sanitize_dirname(dirname, replacement="_")
+ assert dirname == sanitized
+
+ # dirname too long
+ dirname = "foo" + "x" * 512 + "bar"
+ new_dirname = sanitize_dirname(dirname)
+ assert len(new_dirname) == MAX_DIRNAME_LEN
+ assert new_dirname == "foo" + "x" * 252
+
+def test_sanitize_pathpart():
+ from osxphotos.path_utils import sanitize_pathpart
+ from osxphotos._constants import MAX_DIRNAME_LEN
+
+ # basic sanitize
+ dirnames = {
+ "Foobar": "Foobar",
+ "Foo:bar": "Foo:bar",
+ "Foo/bar": "Foo:bar",
+ "Foo//": "Foo::",
+ }
+ for dirname, sanitized in dirnames.items():
+ dirname = sanitize_pathpart(dirname)
+ assert dirname == sanitized
+
+ # sanitize with replacement
+ dirnames = {
+ "Foobar": "Foobar",
+ "Foo:bar": "Foo:bar",
+ "Foo/bar": "Foo_bar",
+ "Foo//": "Foo__",
+ }
+ for dirname, sanitized in dirnames.items():
+ dirname = sanitize_pathpart(dirname, replacement="_")
+ assert dirname == sanitized
+
+ # dirname too long
+ dirname = "foo" + "x" * 512 + "bar"
+ new_dirname = sanitize_pathpart(dirname)
+ assert len(new_dirname) == MAX_DIRNAME_LEN
+ assert new_dirname == "foo" + "x" * 252
+