Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0fdffa9b | ||
|
|
1332e7b45a | ||
|
|
41b23991df |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. Dates are d
|
|||||||
|
|
||||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||||
|
|
||||||
|
#### [v0.35.3](https://github.com/RhetTbull/osxphotos/compare/v0.35.2...v0.35.3)
|
||||||
|
|
||||||
|
> 15 October 2020
|
||||||
|
|
||||||
|
- Fix for issue #235, #236 [`41b2399`](https://github.com/RhetTbull/osxphotos/commit/41b23991df3d1d553b70889ede237f83b6874519)
|
||||||
|
|
||||||
|
#### [v0.35.2](https://github.com/RhetTbull/osxphotos/compare/v0.35.1...v0.35.2)
|
||||||
|
|
||||||
|
> 12 October 2020
|
||||||
|
|
||||||
|
- Fix for issue #234 [`da100f9`](https://github.com/RhetTbull/osxphotos/commit/da100f93a9b849ca4750336d7f90e9023e39dd07)
|
||||||
|
|
||||||
#### [v0.35.1](https://github.com/RhetTbull/osxphotos/compare/v0.35.0...v0.35.1)
|
#### [v0.35.1](https://github.com/RhetTbull/osxphotos/compare/v0.35.0...v0.35.1)
|
||||||
|
|
||||||
> 12 October 2020
|
> 12 October 2020
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
""" command line interface for osxphotos """
|
""" command line interface for osxphotos """
|
||||||
import csv
|
import csv
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -14,12 +12,6 @@ import unicodedata
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
import yaml
|
import yaml
|
||||||
from pathvalidate import (
|
|
||||||
is_valid_filename,
|
|
||||||
is_valid_filepath,
|
|
||||||
sanitize_filename,
|
|
||||||
sanitize_filepath,
|
|
||||||
)
|
|
||||||
|
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
|
||||||
@@ -29,11 +21,12 @@ from ._constants import (
|
|||||||
_UNKNOWN_PLACE,
|
_UNKNOWN_PLACE,
|
||||||
UNICODE_FORMAT,
|
UNICODE_FORMAT,
|
||||||
)
|
)
|
||||||
from .export_db import ExportDB, ExportDBInMemory
|
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
from .datetime_formatter import DateTimeFormatter
|
from .datetime_formatter import DateTimeFormatter
|
||||||
from .exiftool import get_exiftool_path
|
from .exiftool import get_exiftool_path
|
||||||
|
from .export_db import ExportDB, ExportDBInMemory
|
||||||
from .fileutil import FileUtil, FileUtilNoOp
|
from .fileutil import FileUtil, FileUtilNoOp
|
||||||
|
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||||
from .photoinfo import ExportResults
|
from .photoinfo import ExportResults
|
||||||
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||||
|
|
||||||
@@ -1240,10 +1233,10 @@ def query(
|
|||||||
"--jpeg-quality",
|
"--jpeg-quality",
|
||||||
type=click.FloatRange(0.0, 1.0),
|
type=click.FloatRange(0.0, 1.0),
|
||||||
default=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 1.0 specifies best quality, "
|
||||||
"a value of 0.0 specifies maximum compression. "
|
"a value of 0.0 specifies maximum compression. "
|
||||||
"Defaults to 1.0."
|
"Defaults to 1.0.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--sidecar",
|
"--sidecar",
|
||||||
@@ -2409,7 +2402,9 @@ def get_filenames_from_template(photo, filename_template, original_name):
|
|||||||
"""
|
"""
|
||||||
if filename_template:
|
if filename_template:
|
||||||
photo_ext = pathlib.Path(photo.original_filename).suffix
|
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:
|
if not filenames or unmatched:
|
||||||
raise click.BadOptionUsage(
|
raise click.BadOptionUsage(
|
||||||
"filename_template",
|
"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]
|
filenames = [f"{file_}{photo_ext}" for file_ in filenames]
|
||||||
else:
|
else:
|
||||||
filenames = [photo.original_filename] if original_name else [photo.filename]
|
filenames = [photo.original_filename] if original_name else [photo.filename]
|
||||||
|
|
||||||
|
filenames = [sanitize_filename(filename) for filename in filenames]
|
||||||
return filenames
|
return filenames
|
||||||
|
|
||||||
|
|
||||||
@@ -2448,22 +2445,18 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
|||||||
dest_paths = [dest_path]
|
dest_paths = [dest_path]
|
||||||
elif directory:
|
elif directory:
|
||||||
# got a directory template, render it and check results are valid
|
# got a directory template, render it and check results are valid
|
||||||
dirnames, unmatched = photo.render_template(directory)
|
dirnames, unmatched = photo.render_template(directory, dirname=True)
|
||||||
if not dirnames:
|
if not dirnames or unmatched:
|
||||||
raise click.BadOptionUsage(
|
|
||||||
"directory",
|
|
||||||
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
|
|
||||||
)
|
|
||||||
elif unmatched:
|
|
||||||
raise click.BadOptionUsage(
|
raise click.BadOptionUsage(
|
||||||
"directory",
|
"directory",
|
||||||
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
|
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
|
||||||
)
|
)
|
||||||
|
|
||||||
dest_paths = []
|
dest_paths = []
|
||||||
for dirname in dirnames:
|
for dirname in dirnames:
|
||||||
dirname = sanitize_filepath(dirname, platform="auto")
|
dirname = sanitize_filepath(dirname)
|
||||||
dest_path = os.path.join(dest, 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}'")
|
raise ValueError(f"Invalid file path: '{dest_path}'")
|
||||||
if not dry_run and not os.path.isdir(dest_path):
|
if not dry_run and not os.path.isdir(dest_path):
|
||||||
os.makedirs(dest_path)
|
os.makedirs(dest_path)
|
||||||
@@ -2491,7 +2484,7 @@ def find_files_in_branch(pathname, filename):
|
|||||||
files = []
|
files = []
|
||||||
|
|
||||||
# walk down the tree
|
# walk down the tree
|
||||||
for root, directories, filenames in os.walk(pathname):
|
for root, _, filenames in os.walk(pathname):
|
||||||
# for directory in directories:
|
# for directory in directories:
|
||||||
# print(os.path.join(root, directory))
|
# print(os.path.join(root, directory))
|
||||||
for fname in filenames:
|
for fname in filenames:
|
||||||
|
|||||||
@@ -102,3 +102,10 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
|
|||||||
|
|
||||||
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
|
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
|
||||||
SEARCH_CATEGORY_LABEL = 2024
|
SEARCH_CATEGORY_LABEL = 2024
|
||||||
|
|
||||||
|
# Max filename length on MacOS
|
||||||
|
MAX_FILENAME_LEN = 255
|
||||||
|
|
||||||
|
# Max directory name length on MacOS
|
||||||
|
MAX_DIRNAME_LEN = 255
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.35.2"
|
__version__ = "0.35.4"
|
||||||
|
|
||||||
|
|||||||
78
osxphotos/path_utils.py
Normal file
78
osxphotos/path_utils.py
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -592,9 +592,8 @@ def export2(
|
|||||||
export_as_hardlink,
|
export_as_hardlink,
|
||||||
exiftool,
|
exiftool,
|
||||||
touch_file,
|
touch_file,
|
||||||
convert_to_jpeg,
|
False,
|
||||||
fileutil=fileutil,
|
fileutil=fileutil,
|
||||||
jpeg_quality=jpeg_quality,
|
|
||||||
)
|
)
|
||||||
exported_files.extend(results.exported)
|
exported_files.extend(results.exported)
|
||||||
update_new_files.extend(results.new)
|
update_new_files.extend(results.new)
|
||||||
|
|||||||
@@ -545,6 +545,9 @@ class PhotoInfo:
|
|||||||
"""
|
"""
|
||||||
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
||||||
return self._info["raw_pair_info"]["UTI"]
|
return self._info["raw_pair_info"]["UTI"]
|
||||||
|
elif self.shared:
|
||||||
|
# TODO: need reliable way to get original UTI for shared
|
||||||
|
return self.uti
|
||||||
else:
|
else:
|
||||||
return self._info["UTI_original"]
|
return self._info["UTI_original"]
|
||||||
|
|
||||||
@@ -805,6 +808,9 @@ class PhotoInfo:
|
|||||||
path_sep=None,
|
path_sep=None,
|
||||||
expand_inplace=False,
|
expand_inplace=False,
|
||||||
inplace_sep=None,
|
inplace_sep=None,
|
||||||
|
filename=False,
|
||||||
|
dirname=False,
|
||||||
|
replacement=":",
|
||||||
):
|
):
|
||||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||||
|
|
||||||
@@ -817,6 +823,9 @@ class PhotoInfo:
|
|||||||
instead of returning individual strings
|
instead of returning individual strings
|
||||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||||
with expand_inplace; default is ','
|
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:
|
Returns:
|
||||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||||
@@ -828,6 +837,9 @@ class PhotoInfo:
|
|||||||
path_sep=path_sep,
|
path_sep=path_sep,
|
||||||
expand_inplace=expand_inplace,
|
expand_inplace=expand_inplace,
|
||||||
inplace_sep=inplace_sep,
|
inplace_sep=inplace_sep,
|
||||||
|
filename=filename,
|
||||||
|
dirname=dirname,
|
||||||
|
replacement=replacement
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -1908,6 +1908,7 @@ class PhotosDB:
|
|||||||
info["type"] = None
|
info["type"] = None
|
||||||
|
|
||||||
info["UTI"] = row[18]
|
info["UTI"] = row[18]
|
||||||
|
info["UTI_original"] = None # filled in later
|
||||||
|
|
||||||
# handle burst photos
|
# handle burst photos
|
||||||
# if burst photo, determine whether or not it's a selected burst photo
|
# if burst photo, determine whether or not it's a selected burst photo
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
from ._constants import _UNKNOWN_PERSON
|
from ._constants import _UNKNOWN_PERSON
|
||||||
from .datetime_formatter import DateTimeFormatter
|
from .datetime_formatter import DateTimeFormatter
|
||||||
|
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
|
||||||
|
|
||||||
# ensure locale set to user's locale
|
# ensure locale set to user's locale
|
||||||
locale.setlocale(locale.LC_ALL, "")
|
locale.setlocale(locale.LC_ALL, "")
|
||||||
@@ -131,6 +133,9 @@ class PhotoTemplate:
|
|||||||
path_sep=None,
|
path_sep=None,
|
||||||
expand_inplace=False,
|
expand_inplace=False,
|
||||||
inplace_sep=None,
|
inplace_sep=None,
|
||||||
|
filename=False,
|
||||||
|
dirname=False,
|
||||||
|
replacement=":",
|
||||||
):
|
):
|
||||||
""" Render a filename or directory template
|
""" Render a filename or directory template
|
||||||
|
|
||||||
@@ -142,6 +147,9 @@ class PhotoTemplate:
|
|||||||
instead of returning individual strings
|
instead of returning individual strings
|
||||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||||
with expand_inplace; default is ','
|
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:
|
Returns:
|
||||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
([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:
|
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)}")
|
||||||
|
|
||||||
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
|
""" returns: substitution function for use in re.sub
|
||||||
none_str: value to use if substitution lookup is None and no default provided
|
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
|
get_func: function that gets the substitution value for a given template field
|
||||||
default is get_template_value which handles the single-value fields """
|
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):
|
def subst(matchobj):
|
||||||
groups = len(matchobj.groups())
|
groups = len(matchobj.groups())
|
||||||
if groups == 4:
|
if groups == 4:
|
||||||
@@ -186,13 +202,13 @@ class PhotoTemplate:
|
|||||||
return matchobj.group(0)
|
return matchobj.group(0)
|
||||||
|
|
||||||
if val is None:
|
if val is None:
|
||||||
return (
|
val = (
|
||||||
matchobj.group(3)
|
matchobj.group(3)
|
||||||
if matchobj.group(3) is not None
|
if matchobj.group(3) is not None
|
||||||
else none_str
|
else none_str
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
return val
|
return val
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unexpected number of groups: expected 4, got {groups}"
|
f"Unexpected number of groups: expected 4, got {groups}"
|
||||||
@@ -239,7 +255,13 @@ class PhotoTemplate:
|
|||||||
|
|
||||||
for str_template in rendered_strings:
|
for str_template in rendered_strings:
|
||||||
if regex_multi.search(str_template):
|
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:
|
if expand_inplace:
|
||||||
# instead of returning multiple strings, join values into a single string
|
# instead of returning multiple strings, join values into a single string
|
||||||
val = (
|
val = (
|
||||||
@@ -248,11 +270,11 @@ class PhotoTemplate:
|
|||||||
else None
|
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
|
""" Closure passed to make_subst_function get_func
|
||||||
Capture val and field in the closure
|
Capture val and field in the closure
|
||||||
Allows make_subst_function to be re-used w/o modification
|
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:
|
if lookup_value == field:
|
||||||
return val
|
return val
|
||||||
else:
|
else:
|
||||||
@@ -269,11 +291,11 @@ class PhotoTemplate:
|
|||||||
# create a new template string for each value
|
# create a new template string for each value
|
||||||
for val in values:
|
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
|
""" Closure passed to make_subst_function get_func
|
||||||
Capture val and field in the closure
|
Capture val and field in the closure
|
||||||
Allows make_subst_function to be re-used w/o modification
|
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:
|
if lookup_value == field:
|
||||||
return val
|
return val
|
||||||
else:
|
else:
|
||||||
@@ -307,14 +329,24 @@ class PhotoTemplate:
|
|||||||
for rendered_str in rendered_strings
|
for rendered_str in rendered_strings
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if filename:
|
||||||
|
rendered_strings = [
|
||||||
|
sanitize_filename(rendered_str) for rendered_str in rendered_strings
|
||||||
|
]
|
||||||
|
|
||||||
return rendered_strings, unmatched
|
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)
|
"""lookup value for template field (single-value template substitutions)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
field: template field to find value for.
|
field: template field to find value for.
|
||||||
default: the default value provided by the user
|
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:
|
Returns:
|
||||||
The matching template value (which may be None).
|
The matching template value (which may be None).
|
||||||
@@ -327,289 +359,236 @@ class PhotoTemplate:
|
|||||||
if self.today is None:
|
if self.today is None:
|
||||||
self.today = datetime.datetime.now()
|
self.today = datetime.datetime.now()
|
||||||
|
|
||||||
# must be a valid keyword
|
value = None
|
||||||
|
|
||||||
|
# wouldn't a switch/case statement be nice...
|
||||||
if field == "name":
|
if field == "name":
|
||||||
return pathlib.Path(self.photo.filename).stem
|
value = pathlib.Path(self.photo.filename).stem
|
||||||
|
elif field == "original_name":
|
||||||
if field == "original_name":
|
value = pathlib.Path(self.photo.original_filename).stem
|
||||||
return pathlib.Path(self.photo.original_filename).stem
|
elif field == "title":
|
||||||
|
value = self.photo.title
|
||||||
if field == "title":
|
elif field == "descr":
|
||||||
return self.photo.title
|
value = self.photo.description
|
||||||
|
elif field == "created.date":
|
||||||
if field == "descr":
|
value = DateTimeFormatter(self.photo.date).date
|
||||||
return self.photo.description
|
elif field == "created.year":
|
||||||
|
value = DateTimeFormatter(self.photo.date).year
|
||||||
if field == "created.date":
|
elif field == "created.yy":
|
||||||
return DateTimeFormatter(self.photo.date).date
|
value = DateTimeFormatter(self.photo.date).yy
|
||||||
|
elif field == "created.mm":
|
||||||
if field == "created.year":
|
value = DateTimeFormatter(self.photo.date).mm
|
||||||
return DateTimeFormatter(self.photo.date).year
|
elif field == "created.month":
|
||||||
|
value = DateTimeFormatter(self.photo.date).month
|
||||||
if field == "created.yy":
|
elif field == "created.mon":
|
||||||
return DateTimeFormatter(self.photo.date).yy
|
value = DateTimeFormatter(self.photo.date).mon
|
||||||
|
elif field == "created.dd":
|
||||||
if field == "created.mm":
|
value = DateTimeFormatter(self.photo.date).dd
|
||||||
return DateTimeFormatter(self.photo.date).mm
|
elif field == "created.dow":
|
||||||
|
value = DateTimeFormatter(self.photo.date).dow
|
||||||
if field == "created.month":
|
elif field == "created.doy":
|
||||||
return DateTimeFormatter(self.photo.date).month
|
value = DateTimeFormatter(self.photo.date).doy
|
||||||
|
elif field == "created.hour":
|
||||||
if field == "created.mon":
|
value = DateTimeFormatter(self.photo.date).hour
|
||||||
return DateTimeFormatter(self.photo.date).mon
|
elif field == "created.min":
|
||||||
|
value = DateTimeFormatter(self.photo.date).min
|
||||||
if field == "created.dd":
|
elif field == "created.sec":
|
||||||
return DateTimeFormatter(self.photo.date).dd
|
value = DateTimeFormatter(self.photo.date).sec
|
||||||
|
elif field == "created.strftime":
|
||||||
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":
|
|
||||||
if default:
|
if default:
|
||||||
try:
|
try:
|
||||||
return self.photo.date.strftime(default)
|
value = self.photo.date.strftime(default)
|
||||||
except:
|
except:
|
||||||
raise ValueError(f"Invalid strftime template: '{default}'")
|
raise ValueError(f"Invalid strftime template: '{default}'")
|
||||||
else:
|
else:
|
||||||
return None
|
value = None
|
||||||
|
elif field == "modified.date":
|
||||||
if field == "modified.date":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).date
|
DateTimeFormatter(self.photo.date_modified).date
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.year":
|
||||||
if field == "modified.year":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).year
|
DateTimeFormatter(self.photo.date_modified).year
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.yy":
|
||||||
if field == "modified.yy":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).yy
|
DateTimeFormatter(self.photo.date_modified).yy
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.mm":
|
||||||
if field == "modified.mm":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).mm
|
DateTimeFormatter(self.photo.date_modified).mm
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.month":
|
||||||
if field == "modified.month":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).month
|
DateTimeFormatter(self.photo.date_modified).month
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.mon":
|
||||||
if field == "modified.mon":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).mon
|
DateTimeFormatter(self.photo.date_modified).mon
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.dd":
|
||||||
if field == "modified.dd":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).dd
|
DateTimeFormatter(self.photo.date_modified).dd
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.doy":
|
||||||
if field == "modified.doy":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).doy
|
DateTimeFormatter(self.photo.date_modified).doy
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.hour":
|
||||||
if field == "modified.hour":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).hour
|
DateTimeFormatter(self.photo.date_modified).hour
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.min":
|
||||||
if field == "modified.min":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).min
|
DateTimeFormatter(self.photo.date_modified).min
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "modified.sec":
|
||||||
if field == "modified.sec":
|
value = (
|
||||||
return (
|
|
||||||
DateTimeFormatter(self.photo.date_modified).sec
|
DateTimeFormatter(self.photo.date_modified).sec
|
||||||
if self.photo.date_modified
|
if self.photo.date_modified
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "today.date":
|
||||||
# TODO: disabling modified.strftime for now because now clean way to pass
|
value = DateTimeFormatter(self.today).date
|
||||||
# a default value if modified time is None
|
elif field == "today.year":
|
||||||
# if field == "modified.strftime":
|
value = DateTimeFormatter(self.today).year
|
||||||
# if default and self.photo.date_modified:
|
elif field == "today.yy":
|
||||||
# try:
|
value = DateTimeFormatter(self.today).yy
|
||||||
# return self.photo.date_modified.strftime(default)
|
elif field == "today.mm":
|
||||||
# except:
|
value = DateTimeFormatter(self.today).mm
|
||||||
# raise ValueError(f"Invalid strftime template: '{default}'")
|
elif field == "today.month":
|
||||||
# else:
|
value = DateTimeFormatter(self.today).month
|
||||||
# return None
|
elif field == "today.mon":
|
||||||
|
value = DateTimeFormatter(self.today).mon
|
||||||
if field == "today.date":
|
elif field == "today.dd":
|
||||||
return DateTimeFormatter(self.today).date
|
value = DateTimeFormatter(self.today).dd
|
||||||
|
elif field == "today.dow":
|
||||||
if field == "today.year":
|
value = DateTimeFormatter(self.today).dow
|
||||||
return DateTimeFormatter(self.today).year
|
elif field == "today.doy":
|
||||||
|
value = DateTimeFormatter(self.today).doy
|
||||||
if field == "today.yy":
|
elif field == "today.hour":
|
||||||
return DateTimeFormatter(self.today).yy
|
value = DateTimeFormatter(self.today).hour
|
||||||
|
elif field == "today.min":
|
||||||
if field == "today.mm":
|
value = DateTimeFormatter(self.today).min
|
||||||
return DateTimeFormatter(self.today).mm
|
elif field == "today.sec":
|
||||||
|
value = DateTimeFormatter(self.today).sec
|
||||||
if field == "today.month":
|
elif field == "today.strftime":
|
||||||
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":
|
|
||||||
if default:
|
if default:
|
||||||
try:
|
try:
|
||||||
return self.today.strftime(default)
|
value = self.today.strftime(default)
|
||||||
except:
|
except:
|
||||||
raise ValueError(f"Invalid strftime template: '{default}'")
|
raise ValueError(f"Invalid strftime template: '{default}'")
|
||||||
else:
|
else:
|
||||||
return None
|
value = None
|
||||||
|
elif field == "place.name":
|
||||||
if field == "place.name":
|
value = self.photo.place.name if self.photo.place else None
|
||||||
return 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
|
||||||
if field == "place.country_code":
|
elif field == "place.name.country":
|
||||||
return self.photo.place.country_code if self.photo.place else None
|
value = (
|
||||||
|
|
||||||
if field == "place.name.country":
|
|
||||||
return (
|
|
||||||
self.photo.place.names.country[0]
|
self.photo.place.names.country[0]
|
||||||
if self.photo.place and self.photo.place.names.country
|
if self.photo.place and self.photo.place.names.country
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.name.state_province":
|
||||||
if field == "place.name.state_province":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.names.state_province[0]
|
self.photo.place.names.state_province[0]
|
||||||
if self.photo.place and self.photo.place.names.state_province
|
if self.photo.place and self.photo.place.names.state_province
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.name.city":
|
||||||
if field == "place.name.city":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.names.city[0]
|
self.photo.place.names.city[0]
|
||||||
if self.photo.place and self.photo.place.names.city
|
if self.photo.place and self.photo.place.names.city
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.name.area_of_interest":
|
||||||
if field == "place.name.area_of_interest":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.names.area_of_interest[0]
|
self.photo.place.names.area_of_interest[0]
|
||||||
if self.photo.place and self.photo.place.names.area_of_interest
|
if self.photo.place and self.photo.place.names.area_of_interest
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.address":
|
||||||
if field == "place.address":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.address_str
|
self.photo.place.address_str
|
||||||
if self.photo.place and self.photo.place.address_str
|
if self.photo.place and self.photo.place.address_str
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.address.street":
|
||||||
if field == "place.address.street":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.address.street
|
self.photo.place.address.street
|
||||||
if self.photo.place and self.photo.place.address.street
|
if self.photo.place and self.photo.place.address.street
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.address.city":
|
||||||
if field == "place.address.city":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.address.city
|
self.photo.place.address.city
|
||||||
if self.photo.place and self.photo.place.address.city
|
if self.photo.place and self.photo.place.address.city
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.address.state_province":
|
||||||
if field == "place.address.state_province":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.address.state_province
|
self.photo.place.address.state_province
|
||||||
if self.photo.place and self.photo.place.address.state_province
|
if self.photo.place and self.photo.place.address.state_province
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.address.postal_code":
|
||||||
if field == "place.address.postal_code":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.address.postal_code
|
self.photo.place.address.postal_code
|
||||||
if self.photo.place and self.photo.place.address.postal_code
|
if self.photo.place and self.photo.place.address.postal_code
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.address.country":
|
||||||
if field == "place.address.country":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.address.country
|
self.photo.place.address.country
|
||||||
if self.photo.place and self.photo.place.address.country
|
if self.photo.place and self.photo.place.address.country
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
elif field == "place.address.country_code":
|
||||||
if field == "place.address.country_code":
|
value = (
|
||||||
return (
|
|
||||||
self.photo.place.address.iso_country_code
|
self.photo.place.address.iso_country_code
|
||||||
if self.photo.place and self.photo.place.address.iso_country_code
|
if self.photo.place and self.photo.place.address.iso_country_code
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# if here, didn't get a match
|
||||||
|
raise ValueError(f"Unhandled template value: {field}")
|
||||||
|
|
||||||
# if here, didn't get a match
|
if filename:
|
||||||
raise ValueError(f"Unhandled template value: {field}")
|
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)
|
"""lookup value for template field (multi-value template substitutions)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
field: template field to find value for.
|
field: template field to find value for.
|
||||||
path_sep: path separator to use for folder_album field
|
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:
|
Returns:
|
||||||
List of the matching template values or [None].
|
List of the matching template values or [None].
|
||||||
@@ -621,9 +600,6 @@ class PhotoTemplate:
|
|||||||
""" return list of values for a multi-valued template field """
|
""" return list of values for a multi-valued template field """
|
||||||
if field == "album":
|
if field == "album":
|
||||||
values = self.photo.albums
|
values = self.photo.albums
|
||||||
values = [
|
|
||||||
value.replace("/", ":") for value in values
|
|
||||||
] # TODO: temp fix for issue #213
|
|
||||||
elif field == "keyword":
|
elif field == "keyword":
|
||||||
values = self.photo.keywords
|
values = self.photo.keywords
|
||||||
elif field == "person":
|
elif field == "person":
|
||||||
@@ -640,17 +616,42 @@ class PhotoTemplate:
|
|||||||
for album in self.photo.album_info:
|
for album in self.photo.album_info:
|
||||||
if album.folder_names:
|
if album.folder_names:
|
||||||
# album in folder
|
# album in folder
|
||||||
folder = path_sep.join(album.folder_names)
|
if dirname:
|
||||||
folder += path_sep + album.title.replace(
|
# being used as a filepath so sanitize each part
|
||||||
"/", ":"
|
folder = path_sep.join(
|
||||||
) # TODO: temp fix for issue #213
|
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)
|
values.append(folder)
|
||||||
else:
|
else:
|
||||||
# album not in folder
|
# 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:
|
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
|
# If no values, insert None so code below will substite none_str for None
|
||||||
values = values or [None]
|
values = values or [None]
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
|||||||
<key>hostuuid</key>
|
<key>hostuuid</key>
|
||||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||||
<key>pid</key>
|
<key>pid</key>
|
||||||
<integer>2964</integer>
|
<integer>40469</integer>
|
||||||
<key>processname</key>
|
<key>processname</key>
|
||||||
<string>photolibraryd</string>
|
<string>photolibraryd</string>
|
||||||
<key>uid</key>
|
<key>uid</key>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,24 +3,24 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>BackgroundHighlightCollection</key>
|
<key>BackgroundHighlightCollection</key>
|
||||||
<date>2020-06-24T04:02:13Z</date>
|
<date>2020-10-17T23:45:25Z</date>
|
||||||
<key>BackgroundHighlightEnrichment</key>
|
<key>BackgroundHighlightEnrichment</key>
|
||||||
<date>2020-06-24T04:02:12Z</date>
|
<date>2020-10-17T23:45:25Z</date>
|
||||||
<key>BackgroundJobAssetRevGeocode</key>
|
<key>BackgroundJobAssetRevGeocode</key>
|
||||||
<date>2020-06-24T04:02:13Z</date>
|
<date>2020-10-17T23:45:25Z</date>
|
||||||
<key>BackgroundJobSearch</key>
|
<key>BackgroundJobSearch</key>
|
||||||
<date>2020-06-24T04:02:13Z</date>
|
<date>2020-10-17T23:45:25Z</date>
|
||||||
<key>BackgroundPeopleSuggestion</key>
|
<key>BackgroundPeopleSuggestion</key>
|
||||||
<date>2020-06-24T04:02:12Z</date>
|
<date>2020-10-17T23:45:25Z</date>
|
||||||
<key>BackgroundUserBehaviorProcessor</key>
|
<key>BackgroundUserBehaviorProcessor</key>
|
||||||
<date>2020-06-24T04:02:13Z</date>
|
<date>2020-10-17T23:45:25Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||||
<date>2020-05-30T02:16:06Z</date>
|
<date>2020-10-17T23:45:33Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||||
<date>2020-05-29T04:31:37Z</date>
|
<date>2020-10-17T23:45:24Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||||
<date>2020-06-24T04:02:13Z</date>
|
<date>2020-10-17T23:45:26Z</date>
|
||||||
<key>SiriPortraitDonation</key>
|
<key>SiriPortraitDonation</key>
|
||||||
<date>2020-06-24T04:02:13Z</date>
|
<date>2020-10-17T23:45:25Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NumberOfFacesProcessedOnLastRun</key>
|
<key>NumberOfFacesProcessedOnLastRun</key>
|
||||||
<integer>7</integer>
|
<integer>11</integer>
|
||||||
<key>ProcessedInQuiescentState</key>
|
<key>ProcessedInQuiescentState</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>SuggestedMeIdentifier</key>
|
<key>SuggestedMeIdentifier</key>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>FaceIDModelLastGenerationKey</key>
|
<key>FaceIDModelLastGenerationKey</key>
|
||||||
<date>2020-05-29T03:44:04Z</date>
|
<date>2020-10-17T23:45:32Z</date>
|
||||||
<key>LastContactClassificationKey</key>
|
<key>LastContactClassificationKey</key>
|
||||||
<date>2020-05-29T04:31:40Z</date>
|
<date>2020-10-17T23:45:54Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -24,6 +24,7 @@ KEYWORDS = [
|
|||||||
"St. James's Park",
|
"St. James's Park",
|
||||||
"UK",
|
"UK",
|
||||||
"United Kingdom",
|
"United Kingdom",
|
||||||
|
"foo/bar",
|
||||||
]
|
]
|
||||||
# Photos 5 includes blank person for detected face
|
# Photos 5 includes blank person for detected face
|
||||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||||
@@ -47,6 +48,7 @@ KEYWORDS_DICT = {
|
|||||||
"St. James's Park": 1,
|
"St. James's Park": 1,
|
||||||
"UK": 1,
|
"UK": 1,
|
||||||
"United Kingdom": 1,
|
"United Kingdom": 1,
|
||||||
|
"foo/bar": 1,
|
||||||
}
|
}
|
||||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1}
|
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1}
|
||||||
ALBUM_DICT = {
|
ALBUM_DICT = {
|
||||||
|
|||||||
@@ -229,8 +229,23 @@ CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_PATHSEP = [
|
|||||||
"2019-10:11 Paris Clermont/IMG_4547.jpg",
|
"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 = "D79B8D77-BFFC-460B-9312-034F2877D35B"
|
||||||
CLI_EXPORT_UUID_STATUE = "3DD2C897-F19E-4CA6-8C22-B027D5A71907"
|
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"
|
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
|
# build list of uuids we got from the output JSON
|
||||||
json_got = json.loads(result.output)
|
json_got = json.loads(result.output)
|
||||||
uuid_got = []
|
uuid_got = [photo["uuid"] for photo in json_got]
|
||||||
for photo in json_got:
|
|
||||||
uuid_got.append(photo["uuid"])
|
|
||||||
|
|
||||||
assert sorted(UUID_EXPECTED_FROM_FILE) == sorted(uuid_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
|
# build list of uuids we got from the output JSON
|
||||||
json_got = json.loads(result.output)
|
json_got = json.loads(result.output)
|
||||||
uuid_got = []
|
uuid_got = [photo["uuid"] for photo in json_got]
|
||||||
for photo in json_got:
|
|
||||||
uuid_got.append(photo["uuid"])
|
|
||||||
|
|
||||||
uuid_expected = UUID_EXPECTED_FROM_FILE.copy()
|
uuid_expected = UUID_EXPECTED_FROM_FILE.copy()
|
||||||
uuid_expected.append(UUID_NOT_FROM_FILE)
|
uuid_expected.append(UUID_NOT_FROM_FILE)
|
||||||
assert sorted(uuid_expected) == sorted(uuid_got)
|
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)
|
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 """
|
""" export photos using filename template with folder_album and "/" in album name """
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import pathlib
|
import pathlib
|
||||||
import osxphotos
|
|
||||||
from osxphotos.__main__ import export
|
from osxphotos.__main__ import export
|
||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, "en_US")
|
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()
|
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():
|
def test_export_filename_template_3():
|
||||||
""" test --filename with invalid template """
|
""" test --filename with invalid template """
|
||||||
import glob
|
import glob
|
||||||
@@ -2489,9 +2562,8 @@ def test_export_sidecar_keyword_template():
|
|||||||
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
|
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
json_file = open("Pumkins2.jpg.json", "r")
|
with open("Pumkins2.jpg.json", "r") as json_file:
|
||||||
json_got = json.load(json_file)[0]
|
json_got = json.load(json_file)[0]
|
||||||
json_file.close()
|
|
||||||
|
|
||||||
# some gymnastics to account for different sort order in different pythons
|
# some gymnastics to account for different sort order in different pythons
|
||||||
for k, v in json_got.items():
|
for k, v in json_got.items():
|
||||||
|
|||||||
117
tests/test_path_utils.py
Normal file
117
tests/test_path_utils.py
Normal file
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user