1073 lines
40 KiB
Python
1073 lines
40 KiB
Python
"""import command for osxphotos CLI to import photos into Photos"""
|
|
|
|
import datetime
|
|
import fnmatch
|
|
import logging
|
|
import os
|
|
import os.path
|
|
import sys
|
|
import uuid
|
|
from collections import namedtuple
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
from typing import Callable, List, Optional, Tuple, Union
|
|
|
|
import click
|
|
from photoscript import Photo, PhotosLibrary
|
|
from rich.console import Console
|
|
from rich.markdown import Markdown
|
|
|
|
from osxphotos._constants import _OSXPHOTOS_NONE_SENTINEL
|
|
from osxphotos.cli.help import HELP_WIDTH
|
|
from osxphotos.datetime_utils import datetime_naive_to_local
|
|
from osxphotos.exiftool import ExifToolCaching, get_exiftool_path
|
|
from osxphotos.photosalbum import PhotosAlbumPhotoScript
|
|
from osxphotos.phototemplate import PhotoTemplate, RenderOptions
|
|
from osxphotos.utils import pluralize
|
|
from osxphotos.cli.param_types import TemplateString
|
|
|
|
from .click_rich_echo import (
|
|
rich_click_echo,
|
|
set_rich_console,
|
|
set_rich_theme,
|
|
set_rich_timestamp,
|
|
)
|
|
from .color_themes import get_theme
|
|
from .common import THEME_OPTION
|
|
from .rich_progress import rich_progress
|
|
from .verbose import get_verbose_console, verbose_print
|
|
|
|
MetaData = namedtuple("MetaData", ["title", "description", "keywords", "location"])
|
|
|
|
|
|
def echo(message, emoji=True, **kwargs):
|
|
"""Echo text with rich"""
|
|
if emoji:
|
|
if "[error]" in message:
|
|
message = f":cross_mark-emoji: {message}"
|
|
elif "[warning]" in message:
|
|
message = f":warning-emoji: {message}"
|
|
rich_click_echo(message, **kwargs)
|
|
|
|
|
|
class PhotoInfoFromFile:
|
|
"""Mock PhotoInfo class for a file to be imported
|
|
|
|
Returns None for most attributes but allows some templates like exiftool and created to work correctly"""
|
|
|
|
def __init__(self, filepath: Union[str, Path], exiftool: Optional[str] = None):
|
|
self._path = str(filepath)
|
|
self._exiftool_path = exiftool
|
|
self._uuid = str(uuid.uuid1()).upper()
|
|
|
|
@property
|
|
def uuid(self):
|
|
return self._uuid
|
|
|
|
@property
|
|
def original_filename(self):
|
|
return Path(self._path).name
|
|
|
|
@property
|
|
def date(self):
|
|
"""Use file creation date and local timezone"""
|
|
ctime = os.path.getctime(self._path)
|
|
dt = datetime.datetime.fromtimestamp(ctime)
|
|
return datetime_naive_to_local(dt)
|
|
|
|
@property
|
|
def path(self):
|
|
"""Path to photo file"""
|
|
return self._path
|
|
|
|
@property
|
|
def exiftool(self):
|
|
"""Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo.
|
|
Requires that exiftool (https://exiftool.org/) be installed
|
|
If exiftool not installed, logs warning and returns None
|
|
If photo path is missing, returns None
|
|
"""
|
|
try:
|
|
# return the memoized instance if it exists
|
|
return self._exiftool
|
|
except AttributeError:
|
|
try:
|
|
exiftool_path = self._exiftool_path or get_exiftool_path()
|
|
if self._path is not None and os.path.isfile(self._path):
|
|
exiftool = ExifToolCaching(self._path, exiftool=exiftool_path)
|
|
else:
|
|
exiftool = None
|
|
except FileNotFoundError:
|
|
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
|
exiftool = None
|
|
logging.warning(
|
|
"exiftool not in path; download and install from https://exiftool.org/"
|
|
)
|
|
|
|
self._exiftool = exiftool
|
|
return self._exiftool
|
|
|
|
def render_template(
|
|
self, template_str: str, options: Optional[RenderOptions] = None
|
|
):
|
|
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
|
|
|
Args:
|
|
template_str: a template string with fields to render
|
|
options: a RenderOptions instance
|
|
|
|
Returns:
|
|
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
|
"""
|
|
options = options or RenderOptions(caller="import")
|
|
template = PhotoTemplate(self, exiftool_path=self._exiftool_path)
|
|
return template.render(template_str, options)
|
|
|
|
def __getattr__(self, name):
|
|
"""Return None for any other non-private attribute"""
|
|
if not name.startswith("_"):
|
|
return None
|
|
raise AttributeError()
|
|
|
|
|
|
def import_photo(
|
|
filepath: Path, dup_check: bool, verbose: Callable[..., None]
|
|
) -> Tuple[Optional[Photo], Optional[str]]:
|
|
"""Import a photo and return Photo object and error string if any
|
|
|
|
Args:
|
|
filepath: path to the file to import
|
|
dup_check: enable or disable Photo's duplicate check on import
|
|
verbose: Callable"""
|
|
if imported := PhotosLibrary().import_photos(
|
|
[filepath], skip_duplicate_check=not dup_check
|
|
):
|
|
verbose(
|
|
f"Imported [filename]{filepath.name}[/] with UUID [uuid]{imported[0].uuid}[/]"
|
|
)
|
|
photo = imported[0]
|
|
return photo, None
|
|
else:
|
|
error_str = f"[error]Error importing file [filepath]{filepath}[/][/]"
|
|
echo(error_str, err=True)
|
|
return None, error_str
|
|
|
|
|
|
def render_photo_template(
|
|
filepath: Path,
|
|
relative_filepath: Path,
|
|
template: str,
|
|
exiftool_path: Optional[str],
|
|
):
|
|
"""Render template string for a photo"""
|
|
|
|
photoinfo = PhotoInfoFromFile(filepath, exiftool=exiftool_path)
|
|
options = RenderOptions(
|
|
none_str=_OSXPHOTOS_NONE_SENTINEL, filepath=relative_filepath, caller="import"
|
|
)
|
|
template_values, _ = photoinfo.render_template(template, options=options)
|
|
# filter out empty strings
|
|
template_values = [v.replace(_OSXPHOTOS_NONE_SENTINEL, "") for v in template_values]
|
|
template_values = [v for v in template_values if v]
|
|
return template_values
|
|
|
|
|
|
def add_photo_to_albums(
|
|
photo: Photo,
|
|
filepath: Path,
|
|
relative_filepath: Path,
|
|
album: Tuple[str],
|
|
split_folder: str,
|
|
exiftool_path: Path,
|
|
verbose: Callable[..., None],
|
|
):
|
|
"""Add photo to one or more albums"""
|
|
albums = []
|
|
for a in album:
|
|
albums.extend(
|
|
render_photo_template(filepath, relative_filepath, a, exiftool_path)
|
|
)
|
|
verbose(
|
|
f"Adding photo [filename]{filepath.name}[/filename] to {len(albums)} {pluralize(len(albums), 'album', 'albums')}"
|
|
)
|
|
|
|
# add photo to albums
|
|
for a in albums:
|
|
verbose(f"Adding photo [filename]{filepath.name}[/] to album [filepath]{a}[/]")
|
|
photos_album = PhotosAlbumPhotoScript(
|
|
a, verbose=verbose, split_folder=split_folder
|
|
)
|
|
photos_album.add(photo)
|
|
|
|
|
|
def clear_photo_metadata(photo: Photo, filepath: Path, verbose: Callable[..., None]):
|
|
"""Clear any metadata (title, description, keywords) associated with Photo in the Photos Library"""
|
|
verbose(f"Clearing metadata for [filename]{filepath.name}[/]")
|
|
photo.title = ""
|
|
photo.description = ""
|
|
photo.keywords = []
|
|
|
|
|
|
def clear_photo_location(photo: Photo, filepath: Path, verbose: Callable[..., None]):
|
|
"""Clear any location (latitude, longitude) associated with Photo in the Photos Library"""
|
|
verbose(f"Clearing location for [filename]{filepath.name}[/]")
|
|
photo.location = (None, None)
|
|
|
|
|
|
def metadata_from_file(filepath: Path, exiftool_path: str) -> MetaData:
|
|
"""Get metadata from file with exiftool
|
|
|
|
Returns the following metadata from EXIF/XMP/IPTC fields as a MetaData named tuple
|
|
title: str, XMP:Title, IPTC:ObjectName, QuickTime:DisplayName
|
|
description: str, XMP:Description, IPTC:Caption-Abstract, EXIF:ImageDescription, QuickTime:Description
|
|
keywords: str, XMP:Subject, XMP:TagsList, IPTC:Keywords (QuickTime:Keywords not supported)
|
|
location: Tuple[lat, lon], EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef, EXIF:GPSLongitude, QuickTime:GPSCoordinates, UserData:GPSCoordinates
|
|
"""
|
|
exiftool = ExifToolCaching(filepath, exiftool_path)
|
|
metadata = exiftool.asdict()
|
|
title = (
|
|
metadata.get("XMP:Title")
|
|
or metadata.get("IPTC:ObjectName")
|
|
or metadata.get("QuickTime:DisplayName")
|
|
)
|
|
description = (
|
|
metadata.get("XMP:Description")
|
|
or metadata.get("IPTC:Caption-Abstract")
|
|
or metadata.get("EXIF:ImageDescription")
|
|
or metadata.get("QuickTime:Description")
|
|
)
|
|
keywords = (
|
|
metadata.get("XMP:Subject")
|
|
or metadata.get("XMP:TagsList")
|
|
or metadata.get("IPTC:Keywords")
|
|
)
|
|
|
|
title = title or ""
|
|
description = description or ""
|
|
keywords = keywords or []
|
|
if not isinstance(keywords, (tuple, list)):
|
|
keywords = [keywords]
|
|
|
|
location = location_from_file(filepath, exiftool_path)
|
|
return MetaData(title, description, keywords, location)
|
|
|
|
|
|
def location_from_file(
|
|
filepath: Path, exiftool_path: str
|
|
) -> Tuple[Optional[float], Optional[float]]:
|
|
"""Get location from file with exiftool
|
|
|
|
Returns:
|
|
Tuple of lat, long or None, None if not set
|
|
|
|
Note:
|
|
Attempts to get location from the following EXIF fields:
|
|
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
|
EXIF:GPSLatitude, EXIF:GPSLongitude
|
|
QuickTime:GPSCoordinates
|
|
UserData:GPSCoordinates
|
|
"""
|
|
exiftool = ExifToolCaching(filepath, exiftool_path)
|
|
metadata = exiftool.asdict()
|
|
|
|
# photos and videos store location data differently
|
|
# for photos, location in EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef, EXIF:GPSLatitude, EXIF:GPSLongitude
|
|
# the GPSLatitudeRef and GPSLongitudeRef are needed to determine N/S, E/W respectively
|
|
# for example:
|
|
# EXIF:GPSLatitudeRef N
|
|
# EXIF:GPSLongitudeRef W
|
|
# EXIF:GPSLatitude 33.7198027777778
|
|
# EXIF:GPSLongitude 118.285491666667
|
|
# for video, location in QuickTime:GPSCoordinates or UserData:GPSCoordinates as a
|
|
# pair of positive/negative numbers thus no ref needed
|
|
# for example:
|
|
# QuickTime:GPSCoordinates 34.0533 -118.2423
|
|
|
|
latitude, longitude = None, None
|
|
try:
|
|
if latitude := metadata.get("EXIF:GPSLatitude"):
|
|
latitude = float(latitude)
|
|
latitude_ref = metadata.get("EXIF:GPSLatitudeRef")
|
|
if latitude_ref == "S":
|
|
latitude = -latitude
|
|
elif latitude_ref != "N":
|
|
latitude = None
|
|
if longitude := metadata.get("EXIF:GPSLongitude"):
|
|
longitude = float(longitude)
|
|
longitude_ref = metadata.get("EXIF:GPSLongitudeRef")
|
|
if longitude_ref == "W":
|
|
longitude = -longitude
|
|
elif longitude_ref != "E":
|
|
longitude = None
|
|
if latitude is None or longitude is None:
|
|
# maybe it's a video
|
|
if lat_lon := metadata.get("QuickTime:GPSCoordinates") or metadata.get(
|
|
"UserData:GPSCoordinates"
|
|
):
|
|
lat_lon = lat_lon.split()
|
|
if len(lat_lon) != 2:
|
|
latitude = None
|
|
longitude = None
|
|
else:
|
|
latitude = float(lat_lon[0])
|
|
longitude = float(lat_lon[1])
|
|
except ValueError:
|
|
# couldn't convert one of the numbers to float
|
|
return None, None
|
|
return latitude, longitude
|
|
|
|
|
|
def set_photo_metadata(photo: Photo, metadata: MetaData, merge_keywords: bool):
|
|
"""Set metadata (title, description, keywords) for a Photo object"""
|
|
photo.title = metadata.title
|
|
photo.description = metadata.description
|
|
keywords = metadata.keywords.copy()
|
|
if merge_keywords:
|
|
if old_keywords := photo.keywords:
|
|
keywords.extend(old_keywords)
|
|
keywords = list(set(keywords))
|
|
photo.keywords = keywords
|
|
|
|
|
|
def set_photo_metadata_from_exiftool(
|
|
photo: Photo,
|
|
filepath: Path,
|
|
exiftool_path: str,
|
|
merge_keywords: bool,
|
|
verbose: Callable[..., None],
|
|
):
|
|
"""Set photo's metadata by reading metadata form file with exiftool"""
|
|
verbose(f"Setting metadata and location from EXIF for [filename]{filepath.name}[/]")
|
|
metadata = metadata_from_file(filepath, exiftool_path)
|
|
if any([metadata.title, metadata.description, metadata.keywords]):
|
|
set_photo_metadata(photo, metadata, merge_keywords)
|
|
verbose(f"Set metadata for [filename]{filepath.name}[/]:")
|
|
verbose(
|
|
f"title='{metadata.title}', description='{metadata.description}', keywords={metadata.keywords}"
|
|
)
|
|
else:
|
|
verbose(f"No metadata to set for [filename]{filepath.name}[/]")
|
|
if metadata.location[0] is not None and metadata.location[1] is not None:
|
|
# location will be set to None, None if latitude or longitude is missing
|
|
photo.location = metadata.location
|
|
verbose(
|
|
f"Set location for [filename]{filepath.name}[/]: "
|
|
f"[num]{metadata.location[0]}[/], [num]{metadata.location[1]}[/]"
|
|
)
|
|
else:
|
|
verbose(f"No location to set for [filename]{filepath.name}[/]")
|
|
|
|
|
|
def set_photo_title(
|
|
photo: Photo,
|
|
filepath: Path,
|
|
relative_filepath: Path,
|
|
title_template: str,
|
|
exiftool_path: str,
|
|
verbose: Callable[..., None],
|
|
):
|
|
"""Set title of photo"""
|
|
title_text = render_photo_template(
|
|
filepath, relative_filepath, title_template, exiftool_path
|
|
)
|
|
if len(title_text) > 1:
|
|
echo(
|
|
f"photo can have only a single title: '{title_template}' = {title_text}",
|
|
err=True,
|
|
)
|
|
raise click.Abort()
|
|
if title_text:
|
|
verbose(
|
|
f"Setting title of photo [filename]{filepath.name}[/] to '{title_text[0]}'"
|
|
)
|
|
photo.title = title_text[0]
|
|
|
|
|
|
def set_photo_description(
|
|
photo: Photo,
|
|
filepath: Path,
|
|
relative_filepath: Path,
|
|
description_template: str,
|
|
exiftool_path: str,
|
|
verbose: Callable[..., None],
|
|
):
|
|
"""Set description of photo"""
|
|
description_text = render_photo_template(
|
|
filepath, relative_filepath, description_template, exiftool_path
|
|
)
|
|
if len(description_text) > 1:
|
|
echo(
|
|
f"photo can have only a single description: '{description_template}' = {description_text}",
|
|
err=True,
|
|
)
|
|
raise click.Abort()
|
|
if description_text:
|
|
verbose(
|
|
f"Setting description of photo [filename]{filepath.name}[/] to '{description_text[0]}'"
|
|
)
|
|
photo.description = description_text[0]
|
|
|
|
|
|
def set_photo_keywords(
|
|
photo: Photo,
|
|
filepath: Path,
|
|
relative_filepath: Path,
|
|
keyword_template: str,
|
|
exiftool_path: str,
|
|
merge: bool,
|
|
verbose: Callable[..., None],
|
|
):
|
|
"""Set keywords of photo"""
|
|
keywords = []
|
|
for keyword in keyword_template:
|
|
kw = render_photo_template(filepath, relative_filepath, keyword, exiftool_path)
|
|
keywords.extend(kw)
|
|
if keywords:
|
|
if merge:
|
|
if old_keywords := photo.keywords:
|
|
keywords.extend(old_keywords)
|
|
keywords = list(set(keywords))
|
|
verbose(f"Setting keywords of photo [filename]{filepath.name}[/] to {keywords}")
|
|
photo.keywords = keywords
|
|
|
|
|
|
def set_photo_location(
|
|
photo: Photo,
|
|
filepath: Path,
|
|
location: Tuple[float, float],
|
|
verbose: Callable[..., None],
|
|
):
|
|
"""Set location of photo"""
|
|
verbose(
|
|
f"Setting location of photo [filename]{filepath.name}[/] to {location[0]}, {location[1]}"
|
|
)
|
|
photo.location = location
|
|
|
|
|
|
def get_relative_filepath(filepath: Path, relative_to: Optional[str]) -> Path:
|
|
"""Get relative filepath of file relative to relative_to or return filepath if relative_to is None
|
|
|
|
Args:
|
|
filepath: path to file
|
|
relative_to: path to directory to which filepath is relative
|
|
|
|
Returns: relative filepath or filepath if relative_to is None
|
|
|
|
Raises: click.Abort if relative_to is not in the same path as filepath
|
|
"""
|
|
relative_filepath = filepath
|
|
|
|
# check relative_to here so we abort before import if relative_to is bad
|
|
if relative_to:
|
|
try:
|
|
relative_filepath = relative_filepath.relative_to(relative_to)
|
|
except ValueError as e:
|
|
echo(
|
|
f"--relative-to value of '{relative_to}' is not in the same path as '{relative_filepath}'",
|
|
err=True,
|
|
)
|
|
raise click.Abort() from e
|
|
|
|
return relative_filepath
|
|
|
|
|
|
def check_templates_and_exit(
|
|
files: List[str],
|
|
relative_to: Optional[Path],
|
|
title: Optional[str],
|
|
description: Optional[str],
|
|
keyword: Tuple[str],
|
|
album: Tuple[str],
|
|
split_folder: Optional[str],
|
|
exiftool_path: Optional[str],
|
|
exiftool: bool,
|
|
):
|
|
"""Renders templates against each file so user can verify correctness"""
|
|
for file in files:
|
|
file = Path(file).absolute().resolve()
|
|
relative_filepath = get_relative_filepath(file, relative_to)
|
|
echo(f"[filepath]{file}[/]:")
|
|
if exiftool:
|
|
metadata = metadata_from_file(file, exiftool_path)
|
|
echo(f"exiftool title: {metadata.title}")
|
|
echo(f"exiftool description: {metadata.description}")
|
|
echo(f"exiftool keywords: {metadata.keywords}")
|
|
echo(f"exiftool location: {metadata.location}")
|
|
if title:
|
|
rendered_title = render_photo_template(
|
|
file, relative_filepath, title, exiftool_path
|
|
)
|
|
rendered_title = rendered_title[0] if rendered_title else "None"
|
|
echo(f"title: [italic]{title}[/]: {rendered_title}")
|
|
if description:
|
|
rendered_description = render_photo_template(
|
|
file, relative_filepath, description, exiftool_path
|
|
)
|
|
rendered_description = (
|
|
rendered_description[0] if rendered_description else "None"
|
|
)
|
|
echo(f"description: [italic]{description}[/]: {rendered_description}")
|
|
if keyword:
|
|
for kw in keyword:
|
|
rendered_keywords = render_photo_template(
|
|
file, relative_filepath, kw, exiftool_path
|
|
)
|
|
rendered_keywords = rendered_keywords or "None"
|
|
echo(f"keyword: [italic]{kw}[/]: {rendered_keywords}")
|
|
if album:
|
|
for al in album:
|
|
rendered_album = render_photo_template(
|
|
file, relative_filepath, al, exiftool_path
|
|
)
|
|
rendered_album = rendered_album[0] if rendered_album else "None"
|
|
echo(f"album: [italic]{al}[/]: {rendered_album}")
|
|
sys.exit(0)
|
|
|
|
|
|
def filename_matches_patterns(filename: str, patterns: Tuple[str]) -> bool:
|
|
"""Return True if filename matches any pattern in patterns"""
|
|
return any(fnmatch.fnmatch(filename, pattern) for pattern in patterns)
|
|
|
|
|
|
def collect_files_to_import(
|
|
files: Tuple[str], walk: bool, glob: Tuple[str]
|
|
) -> List[str]:
|
|
"""Collect files to import, recursively if necessary
|
|
|
|
Args:
|
|
files: list of initial files or directories to import
|
|
walk: whether to walk directories
|
|
glob: optional glob patterns to match files
|
|
"""
|
|
files_to_import = []
|
|
for file in files:
|
|
if os.path.isfile(file):
|
|
if glob and filename_matches_patterns(os.path.basename(file), glob):
|
|
files_to_import.append(file)
|
|
elif not glob:
|
|
files_to_import.append(file)
|
|
elif os.path.isdir(file):
|
|
if walk:
|
|
for root, dirs, files in os.walk(file):
|
|
for file in files:
|
|
if glob and filename_matches_patterns(
|
|
os.path.basename(file), glob
|
|
):
|
|
files_to_import.append(os.path.join(root, file))
|
|
elif not glob:
|
|
files_to_import.append(os.path.join(root, file))
|
|
else:
|
|
continue
|
|
return files_to_import
|
|
|
|
|
|
class ImportCommand(click.Command):
|
|
"""Custom click.Command that overrides get_help() to show additional help info for import"""
|
|
|
|
def get_help(self, ctx):
|
|
help_text = super().get_help(ctx)
|
|
formatter = click.HelpFormatter(width=HELP_WIDTH)
|
|
extra_help = dedent(
|
|
"""
|
|
## Examples
|
|
|
|
Import a file into Photos:
|
|
`osxphotos import /Volumes/photos/img_1234.jpg`
|
|
|
|
Import multiple jpg files into Photos:
|
|
|
|
`osxphotos import /Volumes/photos/*.jpg`
|
|
|
|
Import files into Photos and add to album:
|
|
|
|
`osxphotos import /Volumes/photos/*.jpg --album "My Album"`
|
|
|
|
Import files into Photos and add to album named for 4-digit year of file creation date:
|
|
|
|
`oxphotos import /Volumes/photos/*.jpg --album "{created.year}"`
|
|
|
|
Import files into Photos and add to album named for month of the year in folder named
|
|
for the 4-digit year of the file creation date:
|
|
|
|
`osxphotos import /Volumes/photos/*.jpg --album "{created.year}/{created.month}" --split-folder "/"`
|
|
|
|
## Albums
|
|
|
|
The imported files may be added to one or more albums using the `--album` option.
|
|
The value passed to `--album` may be a literal string or an osxphotos template
|
|
(see Template System below). For example:
|
|
|
|
`osxphotos import /Volumes/photos/*.jpg --album "Vacation"`
|
|
|
|
adds all photos to the album "Vacation". The album will be created if it does not
|
|
already exist.
|
|
|
|
`osxphotos import /Volumes/photos/Madrid/*.jpg --album "{filepath.parent.name}"`
|
|
|
|
adds all photos to the album "Madrid" (the name of the file's parent folder).
|
|
|
|
## Folders
|
|
|
|
If you want to organize the imported photos into both folders and albums, you can
|
|
use the `--split-folder` option. For example, if your photos are organized into
|
|
folders as follows:
|
|
|
|
.
|
|
├── 2021
|
|
│ ├── Family
|
|
│ └── Travel
|
|
└── 2022
|
|
├── Family
|
|
└── Travel
|
|
|
|
You can recreate this hierarchal structure on import using
|
|
|
|
`--album "{filepath.parent}" --split-folder "/"`
|
|
|
|
In this example, `{filepath.parent}` renders to '2021/Family', '2021/Travel', etc.
|
|
and `--split-folder "/"` instructs osxphotos to split the album name into separate
|
|
parts '2021' and 'Family'.
|
|
|
|
If your photos are organized in a set of folders but you want to exclude one or more parent
|
|
folders from the list of folders and album, you can use the `--relative-to` option to specify
|
|
the parent path that all subsequent paths should be relative to. For example, if your photos
|
|
are organized into photos as follows:
|
|
|
|
/
|
|
└── Volumes
|
|
└── Photos
|
|
├── 2021
|
|
│ ├── Family
|
|
│ └── Travel
|
|
└── 2022
|
|
├── Family
|
|
└── Travel
|
|
|
|
and you want to exclude /Volumes/Photos from the folder/album path, you can do this:
|
|
|
|
`osxphotos import /Volumes/Photos/* --walk --album "{filepath}" --relative-to "/Volumes/Photos"`
|
|
|
|
Note: in Photos, only albums can contain photos and folders
|
|
may contain albums or other folders.
|
|
|
|
## Metadata
|
|
|
|
`osxphotos import` can set metadata (title, description, keywords, and location) for
|
|
imported photos/videos using several options.
|
|
|
|
If you have exiftool (https://exiftool.org/) installed, osxphotos can use
|
|
exiftool to extract metadata from the imported file and use this to update
|
|
the metadata in Photos.
|
|
|
|
The `--exiftool` option will automatically attempt to update title,
|
|
description, keywords, and location from the file's metadata:
|
|
|
|
`osxphotos import *.jpg --exiftool`
|
|
|
|
The following metadata fields are read (in priority order) and used to set
|
|
the metadata of the imported photo:
|
|
|
|
- Title: XMP:Title, IPTC:ObjectName, QuickTime:DisplayName
|
|
- Description: XMP:Description, IPTC:Caption-Abstract, EXIF:ImageDescription, QuickTime:Description
|
|
- Keywords: XMP:Subject, XMP:TagsList, IPTC:Keywords (QuickTime:Keywords not supported)
|
|
- Location: EXIF:GPSLatitude/EXIF:GPSLatitudeRef, EXIF:GPSLongitude/EXIF:GPSLongitudeRef, QuickTime:GPSCoordinates, UserData:GPSCoordinates
|
|
|
|
When importing photos, Photos itself will usually read most of these same fields
|
|
and set the metadata but when importing via AppleScript (which is how `osxphotos
|
|
import` interacts with Photos), Photos does not always reliably do this. It is
|
|
recommended you use `--exiftool` to ensure metadata gets correctly imported.
|
|
|
|
You can also use `--clear-metadata` to remove any metadata automatically set by
|
|
Photos upon import.
|
|
|
|
In addition to `--exiftool`, you can specify a template (see Template System below)
|
|
for setting title (`--title`), description (`--description`), and keywords (`--keywords`).
|
|
Location can be set using `--location`. The album(s) of the imported file can likewise
|
|
be specified with `--album`.
|
|
|
|
`--title`, `--description`, `--keyword`, and `--album` all take a literal string or an
|
|
osxphotos template string. If a template string is used, the template is rendered
|
|
using the osxphotos template language to produce the final value.
|
|
|
|
For example:
|
|
|
|
`--title "{exiftool:XMP:Title}"` sets the title of the imported file to whatever value
|
|
is in the `XMP:Title` metadata field (as read by `exiftool`).
|
|
|
|
`--keyword "Vacation"` sets the keyword for the imported file to the literal string "Vacation".
|
|
|
|
## Template System
|
|
|
|
As mentioned above, the `--title`, `--description`, `--keyword`, and `--album` options
|
|
all take an osxphotos template language template string that is further rendered to
|
|
produce the final value. The template system used by `osxphotos import` is a subset
|
|
of the template system used by `osxphotos export`. For a complete description of the
|
|
template system, see `osxphotos help export`.
|
|
|
|
Most fields in the osxphotos template system are not available to `osxphotos import` as
|
|
they are derived from data in the Photos library and the photos will obviously not be
|
|
imported yet. The following fields are available:
|
|
|
|
#### {exiftool}
|
|
- `{exiftool}`: Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (https://exiftool.org)
|
|
to extract metadata, in form GROUP:TAGNAME, from image.
|
|
E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract
|
|
keywords. See https://exiftool.org/TagNames/ for list of valid tag names.
|
|
You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`.
|
|
exiftool must be installed in the path to use this template (alternatively, you can use
|
|
`--exiftool-path` to specify the path to exiftool.)
|
|
|
|
#### {filepath}
|
|
|
|
- `{filepath}`: The full path to the file being imported.
|
|
For example, `/Volumes/photos/img_1234.jpg`.
|
|
|
|
`{filepath}` has several subfields that
|
|
allow you to access various parts of the path using the following subfield modifiers:
|
|
|
|
- `{filepath.parent}`: the parent directory
|
|
- `{filepath.name}`: the name of the file or final sub-directory
|
|
- `{filepath.stem}`: the name of the file without the extension
|
|
- `{filepath.suffix}`: the suffix of the file including the leading '.'
|
|
|
|
For example, if the field `{filepath}` is '/Shared/Backup/Photos/IMG_1234.JPG':
|
|
- `{filepath.parent}` is '/Shared/Backup/Photos'
|
|
- `{filepath.name}` is 'IMG_1234.JPG'
|
|
- `{filepath.stem}` is 'IMG_1234'
|
|
- `{filepath.suffix}` is '.JPG'
|
|
|
|
Subfields may be chained, for example, `{filepath.parent.parent}` in the above
|
|
example would be `/Shared/Backup` and `{filepath.parent.name}` would be `Photos`.
|
|
|
|
`{filepath}` may be modified using the `--relative-to` option. For example,
|
|
if the path to the imported photo is `/Volumes/Photos/Folder1/Album1/IMG_1234.jpg`
|
|
and you specify `--relative-to "/Volumes/Photos"` then `{filepath}` will be set
|
|
to `Folder1/Album1/IMG_1234.jpg`
|
|
(a subset of the path relative to the value of `--relative-to`).
|
|
|
|
#### {created}
|
|
|
|
- `{created}`: The date the file was created. `{created}` must be used with a subfield to
|
|
specify the format of the date.
|
|
|
|
- `{created.date}`: Photo's creation date in ISO format, e.g. '2020-03-22'
|
|
- `{created.year}`: 4-digit year of photo creation time
|
|
- `{created.yy}`: 2-digit year of photo creation time
|
|
- `{created.mm}`: 2-digit month of the photo creation time (zero padded)
|
|
- `{created.month}`: Month name in user's locale of the photo creation time
|
|
- `{created.mon}`: Month abbreviation in the user's locale of the photo creation time
|
|
- `{created.dd}`: 2-digit day of the month (zero padded) of photo creation time
|
|
- `{created.dow}`: Day of week in user's locale of the photo creation time
|
|
- `{created.doy}`: 3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded)
|
|
- `{created.hour}`: 2-digit hour of the photo creation time
|
|
- `{created.min}`: 2-digit minute of the photo creation time
|
|
- `{created.sec}`: 2-digit second of the photo creation time
|
|
- `{created.strftime}`: Apply strftime template to file creation date/time. Should be used in form
|
|
`{created.strftime,TEMPLATE}` where TEMPLATE is a valid strftime template, e.g.
|
|
`{created.strftime,%Y-%U}` would result in year-week number of year: '2020-23'.
|
|
If used with no template will return null value.
|
|
See https://strftime.org/ for help on strftime templates.
|
|
|
|
You may find the `--check-templates` option useful for testing templates.
|
|
When run with `--check-templates` osxphotos will not actually import anything
|
|
but will instead print out the rendered value for each `--title`, `--description`,
|
|
`--keyword`, and `--album` option. It will also print out the values extracted by
|
|
the `--exiftool` option.
|
|
"""
|
|
)
|
|
console = Console()
|
|
with console.capture() as capture:
|
|
console.print(Markdown(extra_help), width=min(HELP_WIDTH, console.width))
|
|
formatter.write(capture.get())
|
|
help_text += "\n\n" + formatter.getvalue()
|
|
return help_text
|
|
|
|
|
|
@click.command(name="import", cls=ImportCommand)
|
|
@click.option(
|
|
"--album",
|
|
"-a",
|
|
metavar="ALBUM_TEMPLATE",
|
|
multiple=True,
|
|
type=TemplateString(),
|
|
help="Import photos into album ALBUM_TEMPLATE. "
|
|
"ALBUM_TEMPLATE is an osxphotos template string. "
|
|
"Photos may be imported into more than one album by repeating --album. "
|
|
"See Template System in help for additional information.",
|
|
)
|
|
@click.option(
|
|
"--title",
|
|
"-t",
|
|
metavar="TITLE_TEMPLATE",
|
|
type=TemplateString(),
|
|
help="Set title of imported photos to TITLE_TEMPLATE. "
|
|
"TITLE_TEMPLATE is a an osxphotos template string. "
|
|
"See Template System in help for additional information.",
|
|
)
|
|
@click.option(
|
|
"--description",
|
|
"-d",
|
|
metavar="DESCRIPTION_TEMPLATE",
|
|
type=TemplateString(),
|
|
help="Set description of imported photos to DESCRIPTION_TEMPLATE. "
|
|
"DESCRIPTION_TEMPLATE is a an osxphotos template string. "
|
|
"See Template System in help for additional information.",
|
|
)
|
|
@click.option(
|
|
"--keyword",
|
|
"-k",
|
|
metavar="KEYWORD_TEMPLATE",
|
|
multiple=True,
|
|
type=TemplateString(),
|
|
help="Set keywords of imported photos to KEYWORD_TEMPLATE. "
|
|
"KEYWORD_TEMPLATE is a an osxphotos template string. "
|
|
"More than one keyword may be set by repeating --keyword. "
|
|
"See Template System in help for additional information.",
|
|
)
|
|
@click.option(
|
|
"--merge-keywords",
|
|
"-m",
|
|
is_flag=True,
|
|
help="Merge keywords created by --exiftool or --keyword "
|
|
"with any keywords already associated with the photo. "
|
|
"Without --merge-keywords, existing keywords will be overwritten.",
|
|
)
|
|
@click.option(
|
|
"--location",
|
|
"-l",
|
|
metavar="LATITUDE LONGITUDE",
|
|
nargs=2,
|
|
type=click.Tuple([click.FloatRange(-90.0, 90.0), click.FloatRange(-180.0, 180.0)]),
|
|
help="Set location of imported photo to LATITUDE LONGITUDE. "
|
|
"Latitude is a number in the range -90.0 to 90.0; "
|
|
"positive latitudes are north of the equator, negative latitudes are south of the equator. "
|
|
"Longitude is a number in the range -180.0 to 180.0; "
|
|
"positive longitudes are east of the Prime Meridian; negative longitudes are west of the Prime Meridian.",
|
|
)
|
|
@click.option(
|
|
"--clear-metadata",
|
|
"-C",
|
|
is_flag=True,
|
|
help="Clear any metadata set automatically "
|
|
"by Photos upon import. Normally, Photos will set title, description, and keywords "
|
|
"from XMP metadata in the imported file. If you specify --clear-metadata, any metadata "
|
|
"set by Photos will be cleared after import.",
|
|
)
|
|
@click.option(
|
|
"--clear-location",
|
|
"-L",
|
|
is_flag=True,
|
|
help="Clear any location data automatically imported by Photos. "
|
|
"Normally, Photos will set location of the photo to the location data found in the "
|
|
"metadata in the imported file. If you specify --clear-location, "
|
|
"this data will be cleared after import.",
|
|
)
|
|
@click.option(
|
|
"--exiftool",
|
|
"-e",
|
|
is_flag=True,
|
|
help="Use third party tool exiftool (https://exiftool.org/) to automatically "
|
|
"update metadata (title, description, keywords, location) in imported photos from "
|
|
"the imported file's metadata. "
|
|
"Note: importing keywords from video files is not currently supported.",
|
|
)
|
|
@click.option(
|
|
"--exiftool-path",
|
|
"-p",
|
|
metavar="EXIFTOOL_PATH",
|
|
type=click.Path(exists=True, dir_okay=False),
|
|
help="Optionally specify path to exiftool; if not provided, will look for exiftool in $PATH.",
|
|
)
|
|
@click.option(
|
|
"--relative-to",
|
|
"-r",
|
|
metavar="RELATIVE_TO_PATH",
|
|
type=click.Path(exists=True, file_okay=False, resolve_path=True),
|
|
help="If set, the '{filepath}' template "
|
|
"will be computed relative to RELATIVE_TO_PATH. "
|
|
"For example, if path to import is '/Volumes/photos/import/album/img_1234.jpg' "
|
|
"then '{filepath}' will be this same value. "
|
|
"If you set '--relative-to /Volumes/photos/import' "
|
|
"then '{filepath}' will be set to 'album/img_1234.jpg'",
|
|
)
|
|
@click.option("--dup-check", "-D", is_flag=True, help="Check for duplicates on import.")
|
|
@click.option(
|
|
"--split-folder",
|
|
"-f",
|
|
help="Automatically create hierarchal folders for albums as needed by splitting album name "
|
|
"into folders and album. You must specify the character used to split folders and "
|
|
"albums. For example, '--split-folder \"/\"' will split the album name 'Folder/Album' "
|
|
"into folder 'Folder' and album 'Album'. ",
|
|
)
|
|
@click.option(
|
|
"--walk", "-w", is_flag=True, help="Recursively walk through directories."
|
|
)
|
|
@click.option(
|
|
"--glob",
|
|
"-g",
|
|
metavar="GLOB",
|
|
multiple=True,
|
|
help="Only import files matching GLOB. "
|
|
"GLOB is a Unix shell-style glob pattern, for example: '--glob \"*.jpg\"'. "
|
|
"GLOB may be repeated to import multiple patterns.",
|
|
)
|
|
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
|
|
@click.option(
|
|
"--timestamp", "-T", is_flag=True, help="Add time stamp to verbose output"
|
|
)
|
|
@click.option(
|
|
"--no-progress", is_flag=True, help="Do not display progress bar during import."
|
|
)
|
|
@click.option(
|
|
"--check-templates",
|
|
is_flag=True,
|
|
help="Don't actually import anything; "
|
|
"renders template strings so you can verify they are correct.",
|
|
)
|
|
@THEME_OPTION
|
|
@click.argument("files", nargs=-1)
|
|
@click.pass_obj
|
|
@click.pass_context
|
|
def import_cli(
|
|
ctx,
|
|
cli_obj,
|
|
album,
|
|
check_templates,
|
|
clear_location,
|
|
clear_metadata,
|
|
description,
|
|
dup_check,
|
|
exiftool,
|
|
exiftool_path,
|
|
files,
|
|
glob,
|
|
keyword,
|
|
location,
|
|
merge_keywords,
|
|
no_progress,
|
|
relative_to,
|
|
split_folder,
|
|
theme,
|
|
timestamp,
|
|
title,
|
|
verbose_,
|
|
walk,
|
|
):
|
|
"""Import photos and videos into Photos."""
|
|
|
|
color_theme = get_theme(theme)
|
|
verbose = verbose_print(
|
|
verbose_, timestamp, rich=True, theme=color_theme, highlight=False
|
|
)
|
|
# set console for rich_echo to be same as for verbose_
|
|
set_rich_console(get_verbose_console())
|
|
set_rich_theme(color_theme)
|
|
set_rich_timestamp(timestamp)
|
|
|
|
if not files:
|
|
echo("Nothing to import", err=True)
|
|
return
|
|
|
|
# below needed for to make CliRunner work for testing
|
|
# cli_db = cli_obj.db if cli_obj is not None else None
|
|
# db = get_photos_db(db, cli_db)
|
|
# if not db:
|
|
# echo(get_help_msg(import_cli), err=True)
|
|
# echo("\n\nLocated the following Photos library databases: ", err=True)
|
|
# _list_libraries()
|
|
# return
|
|
|
|
relative_to = Path(relative_to) if relative_to else None
|
|
|
|
imported_count = 0
|
|
error_count = 0
|
|
files = collect_files_to_import(files, walk, glob)
|
|
if check_templates:
|
|
check_templates_and_exit(
|
|
files,
|
|
relative_to,
|
|
title,
|
|
description,
|
|
keyword,
|
|
album,
|
|
split_folder,
|
|
exiftool_path,
|
|
exiftool,
|
|
)
|
|
|
|
filecount = len(files)
|
|
with rich_progress(console=get_verbose_console(), mock=no_progress) as progress:
|
|
task = progress.add_task(
|
|
f"Importing [num]{filecount}[/] {pluralize(filecount, 'file', 'files')}",
|
|
total=filecount,
|
|
)
|
|
for filepath in files:
|
|
filepath = Path(filepath).resolve().absolute()
|
|
relative_filepath = get_relative_filepath(filepath, relative_to)
|
|
|
|
verbose(f"Importing [filepath]{filepath}[/]")
|
|
photo, error = import_photo(filepath, dup_check, verbose)
|
|
if error:
|
|
error_count += 1
|
|
continue
|
|
imported_count += 1
|
|
|
|
if clear_metadata:
|
|
clear_photo_metadata(photo, filepath, verbose)
|
|
|
|
if clear_location:
|
|
clear_photo_location(photo, filepath, verbose)
|
|
|
|
if exiftool:
|
|
set_photo_metadata_from_exiftool(
|
|
photo, filepath, exiftool_path, merge_keywords, verbose
|
|
)
|
|
|
|
if title:
|
|
set_photo_title(
|
|
photo, filepath, relative_filepath, title, exiftool_path, verbose
|
|
)
|
|
|
|
if description:
|
|
set_photo_description(
|
|
photo,
|
|
filepath,
|
|
relative_filepath,
|
|
description,
|
|
exiftool_path,
|
|
verbose,
|
|
)
|
|
|
|
if keyword:
|
|
set_photo_keywords(
|
|
photo,
|
|
filepath,
|
|
relative_filepath,
|
|
keyword,
|
|
exiftool_path,
|
|
merge_keywords,
|
|
verbose,
|
|
)
|
|
|
|
if location:
|
|
set_photo_location(photo, filepath, location, verbose)
|
|
|
|
if album:
|
|
add_photo_to_albums(
|
|
photo,
|
|
filepath,
|
|
relative_filepath,
|
|
album,
|
|
split_folder,
|
|
exiftool_path,
|
|
verbose,
|
|
)
|
|
|
|
progress.advance(task)
|
|
|
|
echo(
|
|
f"Done: imported [num]{imported_count}[/] {pluralize(imported_count, 'file', 'files')}, "
|
|
f"[num]{error_count}[/] {pluralize(error_count, 'error', 'errors')}",
|
|
emoji=False,
|
|
)
|