Feature timewarp parse date 867 (#951)

* Working on tests for timewarp --parse-date

* Test working for --parse-date

* Refactored date utils out of timewarp.py

* Added timezone to --parse-date, updated tests

* Added cog to README
This commit is contained in:
Rhet Turnbull
2023-01-22 22:35:05 -08:00
committed by GitHub
parent 29968269ff
commit 369fa553e8
28 changed files with 1419 additions and 430 deletions

View File

@@ -1,14 +1,13 @@
""" Fix time / date / timezone for photos in Apple Photos """
import datetime
import os
from __future__ import annotations
import sys
from functools import partial
from textwrap import dedent
from typing import Callable, Optional
import click
from photoscript import Photo, PhotosLibrary
from photoscript import PhotosLibrary
from rich.console import Console
from osxphotos._constants import APP_NAME
@@ -16,10 +15,14 @@ from osxphotos.compare_exif import PhotoCompare
from osxphotos.datetime_utils import datetime_naive_to_local, datetime_to_new_tz
from osxphotos.exif_datetime_updater import ExifDateTimeUpdater
from osxphotos.exiftool import get_exiftool_path
from osxphotos.photodates import (
set_photo_date_from_filename,
update_photo_date_time,
update_photo_from_function,
update_photo_time_for_new_timezone,
)
from osxphotos.photosalbum import PhotosAlbumPhotoScript
from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater
from osxphotos.timeutils import update_datetime
from osxphotos.timezones import Timezone
from osxphotos.utils import noop, pluralize
from .click_rich_echo import (
@@ -38,6 +41,7 @@ from .param_types import (
DateOffset,
DateTimeISO8601,
FunctionCall,
StrpDateTimePattern,
TimeOffset,
TimeString,
UTCOffset,
@@ -49,110 +53,6 @@ from .verbose import get_verbose_console, verbose_print
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S%z"
def update_photo_date_time(
photo: Photo,
date,
time,
date_delta,
time_delta,
verbose_print: Callable,
):
"""Update date, time in photo"""
photo_date = photo.date
new_photo_date = update_datetime(
photo_date, date=date, time=time, date_delta=date_delta, time_delta=time_delta
)
filename = photo.filename
uuid = photo.uuid
if new_photo_date != photo_date:
photo.date = new_photo_date
verbose_print(
f"Updated date/time for photo [filename]{filename}[/filename] "
f"([uuid]{uuid}[/uuid]) from: [time]{photo_date}[/time] to [time]{new_photo_date}[/time]"
)
else:
verbose_print(
f"Skipped date/time update for photo [filename]{filename}[/filename] "
f"([uuid]{uuid}[/uuid]): nothing to do"
)
def update_photo_time_for_new_timezone(
library_path: str,
photo: Photo,
new_timezone: Timezone,
verbose_print: Callable,
):
"""Update time in photo to keep it the same time but in a new timezone
For example, photo time is 12:00+0100 and new timezone is +0200,
so adjust photo time by 1 hour so it will now be 12:00+0200 instead of
13:00+0200 as it would be with no adjustment to the time"""
old_timezone = PhotoTimeZone(library_path=library_path).get_timezone(photo)[0]
# need to move time in opposite direction of timezone offset so that
# photo time is the same time but in the new timezone
delta = old_timezone - new_timezone.offset
photo_date = photo.date
new_photo_date = update_datetime(
dt=photo_date, time_delta=datetime.timedelta(seconds=delta)
)
filename = photo.filename
uuid = photo.uuid
if photo_date != new_photo_date:
photo.date = new_photo_date
verbose_print(
f"Adjusted date/time for photo [filename]{filename}[/filename] ([uuid]{uuid}[/uuid]) to match "
f"previous time [time]{photo_date}[time] but in new timezone [tz]{new_timezone}[/tz]."
)
else:
verbose_print(
f"Skipping date/time update for photo [filename]{filename}[/filename] ([uuid]{photo.uuid}[/uuid]), "
f"already matches new timezone [tz]{new_timezone}[/tz]"
)
def update_photo_from_function(
library_path: str,
function: Callable,
verbose_print: Callable,
photo: Photo,
path: Optional[str],
):
"""Update photo from function call"""
photo_tz_sec, _, photo_tz_name = PhotoTimeZone(
library_path=library_path
).get_timezone(photo)
dt_new, tz_new = function(
photo=photo,
path=path,
tz_sec=photo_tz_sec,
tz_name=photo_tz_name,
verbose=verbose_print,
)
if dt_new != photo.date:
old_date = photo.date
photo.date = dt_new
verbose_print(
f"Updated date/time for photo [filename]{photo.filename}[/filename] "
f"([uuid]{photo.uuid}[/uuid]) from: [time]{old_date}[/time] to [time]{dt_new}[/time]"
)
else:
verbose_print(
f"Skipped date/time update for photo [filename]{photo.filename}[/filename] "
f"([uuid]{photo.uuid}[/uuid]): nothing to do"
)
if tz_new != photo_tz_sec:
tz_updater = PhotoTimeZoneUpdater(
timezone=Timezone(tz_new), verbose=verbose_print, library_path=library_path
)
tz_updater.update_photo(photo)
else:
verbose_print(
f"Skipped timezone update for photo [filename]{photo.filename}[/filename] "
f"([uuid]{photo.uuid}[/uuid]): nothing to do"
)
class TimeWarpCommand(click.Command):
"""Custom click.Command that overrides get_help() to show additional help info for export"""
@@ -223,6 +123,40 @@ if the EXIF data is missing, use the file modification date/time; show verbose o
`osxphotos timewarp --pull-exif --use-file-time --verbose`
## Parsing Dates/Times from Filenames
The --parse-date option allows you to parse dates/times from the original filename of the photo.
This is useful if you files with dates/times embedded in the filename but not in the metadata.
The argument to `--parse-date` is a pattern string that is used to parse the date/time
from the filename. The pattern string is a superset of the python `strftime/strptime`
format with the following additions:
- *: Match any number of characters
- ^: Match the beginning of the string
- $: Match the end of the string
- {n}: Match exactly n characters
- {n,}: Match at least n characters
- {n,m}: Match at least n characters and at most m characters
- In addition to `%%` for a literal `%`, the following format codes are supported:
`%^`, `%$`, `%*`, `%|`, `%{`, `%}` for `^`, `$`, `*`, `|`, `{`, `}` respectively
- |: join multiple format codes; each code is tried in order until one matches
- Unlike the standard library, the leading zero is not optional for
%d, %m, %H, %I, %M, %S, %j, %U, %W, and %V
- For optional leading zero, use %-d, %-m, %-H, %-I, %-M, %-S, %-j, %-U, %-W, and %-V
For more information on strptime format codes, see:
https://docs.python.org/3/library/datetime.html?highlight=strptime#strftime-and-strptime-format-codes
**Note**: The time zone of the parsed date/time is assumed to be the local time zone.
If the parse pattern includes a time zone, the photo's time will be converted from
the specified time zone to the local time zone. osxphotos import does not
currently support setting the time zone of imported photos.
See also `osxphotos help timewarp` for more information on the timewarp
command which can be used to change the time zone of photos after import.
"""
),
width=formatter.width,
@@ -322,6 +256,19 @@ if the EXIF data is missing, use the file modification date/time; show verbose o
"Requires the third-party exiftool utility be installed (see https://exiftool.org/). "
"See also --push-exif.",
)
@click.option(
"--parse-date",
"-M",
metavar="DATE_PATTERN",
type=StrpDateTimePattern(),
help="Parse date from filename using DATE_PATTERN and set photo's date to match. "
"If file does not match DATE_PATTERN, the date will not be changed. "
"DATE_PATTERN is a strptime-compatible pattern with extensions as pattern described below. "
"If DATE_PATTERN matches time zone information, the photo's timezone will be set to match. "
"For example, if your photos are named 'IMG_1234_2022_11_23_12_34_56.jpg' where the date/time is "
"'2022-11-23 12:34:56', you could use the pattern '%Y_%m_%d_%H_%M_%S' or "
"'IMG_*_%Y_%m_%d_%H_%M_%S' to further narrow the pattern to only match files with 'IMG_xxxx_' in the name.",
)
@click.option(
"--function",
"-F",
@@ -363,7 +310,7 @@ if the EXIF data is missing, use the file modification date/time; show verbose o
help="When used with --compare-exif, adds any photos with date/time/timezone differences "
"between Photos/EXIF to album ALBUM. If ALBUM does not exist, it will be created.",
)
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Show verbose output.")
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Show verbose output.")
@click.option(
"--library",
"-L",
@@ -419,9 +366,10 @@ def timewarp(
use_file_time,
add_to_album,
exiftool_path,
verbose,
verbose_,
library,
theme,
parse_date,
plain,
output_file,
terminal_width,
@@ -446,6 +394,7 @@ def timewarp(
timezone,
inspect,
compare_exif,
parse_date,
push_exif,
pull_exif,
function,
@@ -453,7 +402,8 @@ def timewarp(
):
raise click.UsageError(
"At least one of --date, --date-delta, --time, --time-delta, "
"--timezone, --inspect, --compare-exif, --push-exif, --pull-exif, --function "
"--timezone, --inspect, --compare-exif, --push-exif, --pull-exif, "
"--parse-date, --function "
"must be specified."
)
@@ -472,8 +422,8 @@ def timewarp(
# configure colored rich output
# TODO: this is all a little hacky, find a better way to do this
color_theme = get_theme(theme)
verbose_ = verbose_print(
verbose,
verbose = verbose_print(
verbose_,
timestamp,
rich=True,
theme=color_theme,
@@ -499,7 +449,7 @@ def timewarp(
if any([compare_exif, push_exif, pull_exif]):
exiftool_path = exiftool_path or get_exiftool_path()
verbose_(f"exiftool path: [filename]{exiftool_path}[/filename]")
verbose(f"exiftool path: [filename]{exiftool_path}[/filename]")
try:
photos = PhotosLibrary().selection
@@ -533,6 +483,7 @@ def timewarp(
push_exif,
pull_exif,
function,
parse_date,
]
)
and not force
@@ -556,13 +507,19 @@ def timewarp(
time=time,
date_delta=date_delta,
time_delta=time_delta,
verbose_print=verbose_,
verbose=verbose,
)
update_photo_time_for_new_timezone_ = partial(
update_photo_time_for_new_timezone,
library_path=library,
verbose_print=verbose_,
verbose=verbose,
)
set_photo_date_from_filename_ = partial(
set_photo_date_from_filename,
library_path=library,
verbose=verbose,
)
if function:
@@ -570,7 +527,7 @@ def timewarp(
update_photo_from_function,
library_path=library,
function=function[0],
verbose_print=verbose_,
verbose=verbose,
)
else:
update_photo_from_function_ = noop
@@ -579,14 +536,20 @@ def timewarp(
tzinfo = PhotoTimeZone(library_path=library)
if photos:
rich_echo(
"[filename]filename[/filename], [uuid]uuid[/uuid], [time]photo time (local)[/time], [time]photo time[/time], [tz]timezone offset[/tz], [tz]timezone name[/tz]"
"[filename]filename[/filename], [uuid]uuid[/uuid], "
"[time]photo time (local)[/time], "
"[time]photo time[/time], "
"[tz]timezone offset[/tz], [tz]timezone name[/tz]"
)
for photo in photos:
tz_seconds, tz_str, tz_name = tzinfo.get_timezone(photo)
photo_date_local = datetime_naive_to_local(photo.date)
photo_date_tz = datetime_to_new_tz(photo_date_local, tz_seconds)
rich_echo(
f"[filename]{photo.filename}[/filename], [uuid]{photo.uuid}[/uuid], [time]{photo_date_local.strftime(DATETIME_FORMAT)}[/time], [time]{photo_date_tz.strftime(DATETIME_FORMAT)}[/time], [tz]{tz_str}[/tz], [tz]{tz_name}[/tz]"
f"[filename]{photo.filename}[/filename], [uuid]{photo.uuid}[/uuid], "
f"[time]{photo_date_local.strftime(DATETIME_FORMAT)}[/time], "
f"[time]{photo_date_tz.strftime(DATETIME_FORMAT)}[/time], "
f"[tz]{tz_str}[/tz], [tz]{tz_name}[/tz]"
)
sys.exit(0)
@@ -596,7 +559,7 @@ def timewarp(
if photos:
photocomp = PhotoCompare(
library_path=library,
verbose=verbose_,
verbose=verbose,
exiftool_path=exiftool_path,
)
if not album:
@@ -622,12 +585,12 @@ def timewarp(
if album:
if diff_results.diff:
different_photos += 1
verbose_(
verbose(
f"Photo {filename} ({uuid}) has different date/time/timezone, adding to album '{album.name}'"
)
album.add(photo)
else:
verbose_(f"Photo {filename} ({uuid}) has same date/time/timezone")
verbose(f"Photo {filename} ({uuid}) has same date/time/timezone")
else:
rich_echo(
f"{filename}, {uuid}, "
@@ -644,14 +607,14 @@ def timewarp(
if timezone:
tz_updater = PhotoTimeZoneUpdater(
timezone, verbose=verbose_, library_path=library
timezone, verbose=verbose, library_path=library
)
if any([push_exif, pull_exif, function]):
# ExifDateTimeUpdater used to get photo path for --function
exif_updater = ExifDateTimeUpdater(
library_path=library,
verbose=verbose_,
verbose=verbose,
exiftool_path=exiftool_path,
plain=plain,
)
@@ -663,6 +626,8 @@ def timewarp(
total=num_photos,
)
for p in photos:
if parse_date:
set_photo_date_from_filename_(p, p.filename, parse_date)
if pull_exif:
exif_updater.update_photos_from_exif(
p, use_file_modify_date=use_file_time
@@ -676,7 +641,7 @@ def timewarp(
if timezone:
tz_updater.update_photo(p)
if function:
verbose_(f"Calling function [bold]{function[1]}")
verbose(f"Calling function [bold]{function[1]}")
photo_path = exif_updater.get_photo_path(p)
update_photo_from_function_(photo=p, path=photo_path)
if push_exif:

181
osxphotos/photodates.py Normal file
View File

@@ -0,0 +1,181 @@
"""Utilities for working with Photo dates in Apple Photos; used by osxphotos timewarp command"""
from __future__ import annotations
import datetime
import pathlib
from typing import Callable
import photoscript
from strpdatetime import strpdatetime
from .datetime_utils import (
datetime_has_tz,
datetime_remove_tz,
datetime_tz_to_utc,
datetime_utc_to_local,
utc_offset_seconds,
)
from .phototz import PhotoTimeZone, PhotoTimeZoneUpdater
from .timeutils import update_datetime
from .timezones import Timezone
def update_photo_date_time(
photo: photoscript.Photo,
date,
time,
date_delta,
time_delta,
verbose: Callable,
):
"""Update date, time in photo"""
photo_date = photo.date
new_photo_date = update_datetime(
photo_date, date=date, time=time, date_delta=date_delta, time_delta=time_delta
)
filename = photo.filename
uuid = photo.uuid
if new_photo_date != photo_date:
photo.date = new_photo_date
verbose(
f"Updated date/time for photo [filename]{filename}[/filename] "
f"([uuid]{uuid}[/uuid]) from: [time]{photo_date}[/time] to [time]{new_photo_date}[/time]"
)
else:
verbose(
f"Skipped date/time update for photo [filename]{filename}[/filename] "
f"([uuid]{uuid}[/uuid]): nothing to do"
)
def update_photo_from_function(
library_path: str,
function: Callable,
verbose: Callable[..., None],
photo: photoscript.Photo,
path: str | None,
):
"""Update photo from function call"""
photo_tz_sec, _, photo_tz_name = PhotoTimeZone(
library_path=library_path
).get_timezone(photo)
dt_new, tz_new = function(
photo=photo,
path=path,
tz_sec=photo_tz_sec,
tz_name=photo_tz_name,
verbose=verbose,
)
if dt_new != photo.date:
old_date = photo.date
photo.date = dt_new
verbose(
f"Updated date/time for photo [filename]{photo.filename}[/filename] "
f"([uuid]{photo.uuid}[/uuid]) from: [time]{old_date}[/time] to [time]{dt_new}[/time]"
)
else:
verbose(
f"Skipped date/time update for photo [filename]{photo.filename}[/filename] "
f"([uuid]{photo.uuid}[/uuid]): nothing to do"
)
if tz_new != photo_tz_sec:
tz_updater = PhotoTimeZoneUpdater(
timezone=Timezone(tz_new), verbose=verbose, library_path=library_path
)
tz_updater.update_photo(photo)
else:
verbose(
f"Skipped timezone update for photo [filename]{photo.filename}[/filename] "
f"([uuid]{photo.uuid}[/uuid]): nothing to do"
)
def update_photo_time_for_new_timezone(
library_path: str,
photo: photoscript.Photo,
new_timezone: Timezone,
verbose: Callable[..., None],
):
"""Update time in photo to keep it the same time but in a new timezone
For example, photo time is 12:00+0100 and new timezone is +0200,
so adjust photo time by 1 hour so it will now be 12:00+0200 instead of
13:00+0200 as it would be with no adjustment to the time"""
old_timezone = PhotoTimeZone(library_path=library_path).get_timezone(photo)[0]
# need to move time in opposite direction of timezone offset so that
# photo time is the same time but in the new timezone
delta = old_timezone - new_timezone.offset
photo_date = photo.date
new_photo_date = update_datetime(
dt=photo_date, time_delta=datetime.timedelta(seconds=delta)
)
filename = photo.filename
uuid = photo.uuid
if photo_date != new_photo_date:
photo.date = new_photo_date
verbose(
f"Adjusted date/time for photo [filename]{filename}[/] ([uuid]{uuid}[/]) to [time]{new_photo_date}[/] "
f"to match previous time [time]{photo_date}[/] but in new timezone [tz]{new_timezone}[/]."
)
else:
verbose(
f"Skipping date/time update for photo [filename]{filename}[/] ([uuid]{photo.uuid}[/]), "
f"already matches new timezone [tz]{new_timezone}[/]"
)
def set_photo_date_from_filename(
photo: photoscript.Photo,
filepath: pathlib.Path | str,
parse_date: str,
verbose: Callable[..., None],
library_path: str | None = None,
) -> datetime.datetime | None:
"""Set date of photo from filename
Args:
photo: Photo to set date
filepath: Path to photo's original file
parse_date: strptime format string to parse date from filename
verbose: verbose function to use for logging
library_path: Path to Photos library; if not provided, will attempt to determine automatically
Returns:
datetime.datetime: date set on photo or None if date could not be parsed
"""
if not isinstance(filepath, pathlib.Path):
filepath = pathlib.Path(filepath)
try:
date = strpdatetime(filepath.name, parse_date)
except ValueError:
verbose(
f"[warning]Could not parse date from filename [filename]{filepath.name}[/][/]"
)
return None
# first, set date on photo without timezone (Photos will assume local timezone)
date_no_tz = datetime_remove_tz(date) if datetime_has_tz(date) else date
verbose(
f"Setting date of photo [filename]{filepath.name}[/] to [time]{date_no_tz.strftime('%Y-%m-%d %H:%M:%S')}[/]"
)
photo.date = date_no_tz
if datetime_has_tz(date):
# if timezone, need to update timezone and also the date/time to match
photo_tz_sec, _, photo_tz_name = PhotoTimeZone(
library_path=library_path
).get_timezone(photo)
tz_new_secs = int(utc_offset_seconds(date))
if photo_tz_sec != tz_new_secs:
tz_new = Timezone(tz_new_secs)
update_photo_time_for_new_timezone(library_path, photo, tz_new, verbose)
tz_updater = PhotoTimeZoneUpdater(
timezone=tz_new,
verbose=verbose,
library_path=library_path,
)
tz_updater.update_photo(photo)
return date