Compare commits

...

10 Commits

Author SHA1 Message Date
Rhet Turnbull
65d51ab129 Updated docs [skip ci] 2022-02-13 00:21:27 -08:00
Rhet Turnbull
afbda030bc beta fix for #633, fix face regions in exiftool 2022-02-13 00:14:59 -08:00
Rhet Turnbull
d111d07fb7 Updated CHANGELOG.md [skip ci] 2022-02-12 21:13:56 -08:00
Rhet Turnbull
30abdddaf3 Added --force-update, #621 2022-02-12 21:01:16 -08:00
Rhet Turnbull
a2f329b8de Updated CHANGELOG.md [skip ci] 2022-02-12 17:59:48 -08:00
Rhet Turnbull
bfa888adc5 Added --force-update, #621 2022-02-12 17:49:40 -08:00
Rhet Turnbull
ac4083bfbb Fix for #630 2022-02-12 00:23:50 -08:00
Rhet Turnbull
5fb686ac0c Refactored fix for #627 2022-02-11 23:10:09 -08:00
Rhet Turnbull
49a7b80680 Fixed cleanup for #629 2022-02-11 06:18:17 -08:00
Rhet Turnbull
cb11967eac Implement #629, sqlite performance optimizatons for export db 2022-02-10 22:36:35 -08:00
21 changed files with 867 additions and 476 deletions

View File

@@ -4,6 +4,20 @@ 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).
#### [v0.45.10](https://github.com/RhetTbull/osxphotos/compare/v0.45.9...v0.45.10)
> 12 February 2022
- Added --force-update, #621 [`30abddd`](https://github.com/RhetTbull/osxphotos/commit/30abdddaf3765f1d604984d4781b78b7806871e1)
#### [v0.45.9](https://github.com/RhetTbull/osxphotos/compare/v0.45.8...v0.45.9)
> 12 February 2022
- Added --force-update, #621 [`bfa888a`](https://github.com/RhetTbull/osxphotos/commit/bfa888adc5658a2845dcaa9b7ea360926ed4f000)
- Refactored fix for #627 [`5fb686a`](https://github.com/RhetTbull/osxphotos/commit/5fb686ac0c231932c2695fc550a0824307bd3c5f)
- Fix for #630 [`ac4083b`](https://github.com/RhetTbull/osxphotos/commit/ac4083bfbbabc8550718f0f7f8aadc635c05eb25)
#### [v0.45.8](https://github.com/RhetTbull/osxphotos/compare/v0.45.6...v0.45.8)
> 5 February 2022

View File

@@ -783,8 +783,15 @@ Options:
folder.
--deleted-only Include only photos from the 'Recently
Deleted' folder.
--update Only export new or updated files. See notes
below on export and --update.
--update Only export new or updated files. See also
--force-update and notes below on export and
--update.
--force-update Only export new or updated files. Unlike
--update, --force-update will re-export photos
if their metadata has changed even if this
would not otherwise trigger an export. See
also --update and notes below on export and
--update.
--ignore-signature When used with '--update', ignores file
signature when updating files. This is useful
if you have processed or edited exported
@@ -1725,7 +1732,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.45.8'
{osxphotos_version} The osxphotos version, e.g. '0.45.11'
{osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for
@@ -3629,7 +3636,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.45.8'|
|{osxphotos_version}|The osxphotos version, e.g. '0.45.11'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|

View File

@@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: bf43bf49b725c31ce72a8823e4f8012b
config: 4096293689c0c969f1ec21d5ea133ab2
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.45.8',
VERSION: '0.45.11',
LANGUAGE: 'None',
COLLAPSE_INDEX: false,
BUILDER: 'html',

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.45.8 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.45.11 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.45.8 documentation</title>
<title>Index &#8212; osxphotos 0.45.11 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.45.8 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.45.11 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos &#8212; osxphotos 0.45.8 documentation</title>
<title>osxphotos &#8212; osxphotos 0.45.11 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos package &#8212; osxphotos 0.45.8 documentation</title>
<title>osxphotos package &#8212; osxphotos 0.45.11 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.45.8 documentation</title>
<title>Search &#8212; osxphotos 0.45.11 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.45.8"
__version__ = "0.45.11"

View File

@@ -691,7 +691,15 @@ def cli(ctx, db, json_, debug):
@click.option(
"--update",
is_flag=True,
help="Only export new or updated files. See notes below on export and --update.",
help="Only export new or updated files. "
"See also --force-update and notes below on export and --update.",
)
@click.option(
"--force-update",
is_flag=True,
help="Only export new or updated files. Unlike --update, --force-update will re-export photos "
"if their metadata has changed even if this would not otherwise trigger an export. "
"See also --update and notes below on export and --update.",
)
@click.option(
"--ignore-signature",
@@ -1235,6 +1243,7 @@ def export(
timestamp,
missing,
update,
force_update,
ignore_signature,
only_new,
dry_run,
@@ -1398,133 +1407,134 @@ def export(
# re-set the local vars to the corresponding config value
# this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
db = cfg.db
photos_library = cfg.photos_library
keyword = cfg.keyword
person = cfg.person
add_exported_to_album = cfg.add_exported_to_album
add_missing_to_album = cfg.add_missing_to_album
add_skipped_to_album = cfg.add_skipped_to_album
album = cfg.album
folder = cfg.folder
name = cfg.name
uuid = cfg.uuid
uuid_from_file = cfg.uuid_from_file
title = cfg.title
no_title = cfg.no_title
description = cfg.description
no_description = cfg.no_description
uti = cfg.uti
ignore_case = cfg.ignore_case
edited = cfg.edited
external_edit = cfg.external_edit
favorite = cfg.favorite
not_favorite = cfg.not_favorite
hidden = cfg.hidden
not_hidden = cfg.not_hidden
shared = cfg.shared
not_shared = cfg.not_shared
from_date = cfg.from_date
to_date = cfg.to_date
from_time = cfg.from_time
to_time = cfg.to_time
verbose = cfg.verbose
missing = cfg.missing
update = cfg.update
ignore_signature = cfg.ignore_signature
dry_run = cfg.dry_run
export_as_hardlink = cfg.export_as_hardlink
touch_file = cfg.touch_file
overwrite = cfg.overwrite
retry = cfg.retry
export_by_date = cfg.export_by_date
skip_edited = cfg.skip_edited
skip_original_if_edited = cfg.skip_original_if_edited
skip_bursts = cfg.skip_bursts
skip_live = cfg.skip_live
skip_raw = cfg.skip_raw
skip_uuid = cfg.skip_uuid
skip_uuid_from_file = cfg.skip_uuid_from_file
person_keyword = cfg.person_keyword
album_keyword = cfg.album_keyword
keyword_template = cfg.keyword_template
replace_keywords = cfg.replace_keywords
description_template = cfg.description_template
finder_tag_template = cfg.finder_tag_template
finder_tag_keywords = cfg.finder_tag_keywords
xattr_template = cfg.xattr_template
current_name = cfg.current_name
convert_to_jpeg = cfg.convert_to_jpeg
jpeg_quality = cfg.jpeg_quality
sidecar = cfg.sidecar
sidecar_drop_ext = cfg.sidecar_drop_ext
only_photos = cfg.only_photos
only_movies = cfg.only_movies
beta = cfg.beta
burst = cfg.burst
not_burst = cfg.not_burst
live = cfg.live
not_live = cfg.not_live
download_missing = cfg.download_missing
exiftool = cfg.exiftool
exiftool_path = cfg.exiftool_path
exiftool_option = cfg.exiftool_option
exiftool_merge_keywords = cfg.exiftool_merge_keywords
exiftool_merge_persons = cfg.exiftool_merge_persons
ignore_date_modified = cfg.ignore_date_modified
portrait = cfg.portrait
not_portrait = cfg.not_portrait
screenshot = cfg.screenshot
not_screenshot = cfg.not_screenshot
slow_mo = cfg.slow_mo
not_slow_mo = cfg.not_slow_mo
time_lapse = cfg.time_lapse
not_time_lapse = cfg.not_time_lapse
hdr = cfg.hdr
not_hdr = cfg.not_hdr
selfie = cfg.selfie
not_selfie = cfg.not_selfie
panorama = cfg.panorama
not_panorama = cfg.not_panorama
has_raw = cfg.has_raw
directory = cfg.directory
filename_template = cfg.filename_template
jpeg_ext = cfg.jpeg_ext
strip = cfg.strip
edited_suffix = cfg.edited_suffix
original_suffix = cfg.original_suffix
place = cfg.place
no_place = cfg.no_place
location = cfg.location
no_location = cfg.no_location
has_comment = cfg.has_comment
no_comment = cfg.no_comment
has_likes = cfg.has_likes
no_likes = cfg.no_likes
label = cfg.label
cleanup = cfg.cleanup
convert_to_jpeg = cfg.convert_to_jpeg
current_name = cfg.current_name
db = cfg.db
deleted = cfg.deleted
deleted_only = cfg.deleted_only
use_photos_export = cfg.use_photos_export
use_photokit = cfg.use_photokit
report = cfg.report
cleanup = cfg.cleanup
add_exported_to_album = cfg.add_exported_to_album
add_skipped_to_album = cfg.add_skipped_to_album
add_missing_to_album = cfg.add_missing_to_album
exportdb = cfg.exportdb
beta = cfg.beta
only_new = cfg.only_new
in_album = cfg.in_album
not_in_album = cfg.not_in_album
min_size = cfg.min_size
max_size = cfg.max_size
regex = cfg.regex
selected = cfg.selected
exif = cfg.exif
query_eval = cfg.query_eval
query_function = cfg.query_function
description = cfg.description
description_template = cfg.description_template
directory = cfg.directory
download_missing = cfg.download_missing
dry_run = cfg.dry_run
duplicate = cfg.duplicate
edited = cfg.edited
edited_suffix = cfg.edited_suffix
exif = cfg.exif
exiftool = cfg.exiftool
exiftool_merge_keywords = cfg.exiftool_merge_keywords
exiftool_merge_persons = cfg.exiftool_merge_persons
exiftool_option = cfg.exiftool_option
exiftool_path = cfg.exiftool_path
export_as_hardlink = cfg.export_as_hardlink
export_by_date = cfg.export_by_date
exportdb = cfg.exportdb
external_edit = cfg.external_edit
favorite = cfg.favorite
filename_template = cfg.filename_template
finder_tag_keywords = cfg.finder_tag_keywords
finder_tag_template = cfg.finder_tag_template
folder = cfg.folder
force_update = cfg.force_update
from_date = cfg.from_date
from_time = cfg.from_time
has_comment = cfg.has_comment
has_likes = cfg.has_likes
has_raw = cfg.has_raw
hdr = cfg.hdr
hidden = cfg.hidden
ignore_case = cfg.ignore_case
ignore_date_modified = cfg.ignore_date_modified
ignore_signature = cfg.ignore_signature
in_album = cfg.in_album
jpeg_ext = cfg.jpeg_ext
jpeg_quality = cfg.jpeg_quality
keyword = cfg.keyword
keyword_template = cfg.keyword_template
label = cfg.label
live = cfg.live
location = cfg.location
max_size = cfg.max_size
min_size = cfg.min_size
missing = cfg.missing
name = cfg.name
no_comment = cfg.no_comment
no_description = cfg.no_description
no_likes = cfg.no_likes
no_location = cfg.no_location
no_place = cfg.no_place
no_title = cfg.no_title
not_burst = cfg.not_burst
not_favorite = cfg.not_favorite
not_hdr = cfg.not_hdr
not_hidden = cfg.not_hidden
not_in_album = cfg.not_in_album
not_live = cfg.not_live
not_panorama = cfg.not_panorama
not_portrait = cfg.not_portrait
not_screenshot = cfg.not_screenshot
not_selfie = cfg.not_selfie
not_shared = cfg.not_shared
not_slow_mo = cfg.not_slow_mo
not_time_lapse = cfg.not_time_lapse
only_movies = cfg.only_movies
only_new = cfg.only_new
only_photos = cfg.only_photos
original_suffix = cfg.original_suffix
overwrite = cfg.overwrite
panorama = cfg.panorama
person = cfg.person
person_keyword = cfg.person_keyword
photos_library = cfg.photos_library
place = cfg.place
portrait = cfg.portrait
post_command = cfg.post_command
post_function = cfg.post_function
preview = cfg.preview
preview_suffix = cfg.preview_suffix
preview_if_missing = cfg.preview_if_missing
preview_suffix = cfg.preview_suffix
query_eval = cfg.query_eval
query_function = cfg.query_function
regex = cfg.regex
replace_keywords = cfg.replace_keywords
report = cfg.report
retry = cfg.retry
screenshot = cfg.screenshot
selected = cfg.selected
selfie = cfg.selfie
shared = cfg.shared
sidecar = cfg.sidecar
sidecar_drop_ext = cfg.sidecar_drop_ext
skip_bursts = cfg.skip_bursts
skip_edited = cfg.skip_edited
skip_live = cfg.skip_live
skip_original_if_edited = cfg.skip_original_if_edited
skip_raw = cfg.skip_raw
skip_uuid = cfg.skip_uuid
skip_uuid_from_file = cfg.skip_uuid_from_file
slow_mo = cfg.slow_mo
strip = cfg.strip
time_lapse = cfg.time_lapse
title = cfg.title
to_date = cfg.to_date
to_time = cfg.to_time
touch_file = cfg.touch_file
update = cfg.update
use_photokit = cfg.use_photokit
use_photos_export = cfg.use_photos_export
uti = cfg.uti
uuid = cfg.uuid
uuid_from_file = cfg.uuid_from_file
verbose = cfg.verbose
xattr_template = cfg.xattr_template
# config file might have changed verbose
VERBOSE = bool(verbose)
@@ -1564,8 +1574,8 @@ def export(
dependent_options = [
("missing", ("download_missing", "use_photos_export")),
("jpeg_quality", ("convert_to_jpeg")),
("ignore_signature", ("update")),
("only_new", ("update")),
("ignore_signature", ("update", "force_update")),
("only_new", ("update", "force_update")),
("exiftool_option", ("exiftool")),
("exiftool_merge_keywords", ("exiftool", "sidecar")),
("exiftool_merge_persons", ("exiftool", "sidecar")),
@@ -1905,51 +1915,52 @@ def export(
export_results = export_photo(
photo=p,
dest=dest,
verbose=verbose,
export_by_date=export_by_date,
sidecar=sidecar,
sidecar_drop_ext=sidecar_drop_ext,
update=update,
ignore_signature=ignore_signature,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
export_edited=export_edited,
skip_original_if_edited=skip_original_if_edited,
original_name=original_name,
export_live=export_live,
album_keyword=album_keyword,
convert_to_jpeg=convert_to_jpeg,
description_template=description_template,
directory=directory,
download_missing=download_missing,
exiftool=exiftool,
dry_run=dry_run,
edited_suffix=edited_suffix,
exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons,
directory=directory,
filename_template=filename_template,
export_raw=export_raw,
album_keyword=album_keyword,
person_keyword=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
exiftool_option=exiftool_option,
strip=strip,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
export_by_date=export_by_date,
export_db=export_db,
export_dir=dest,
export_edited=export_edited,
export_live=export_live,
export_preview=preview,
export_raw=export_raw,
filename_template=filename_template,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
jpeg_quality=jpeg_quality,
keyword_template=keyword_template,
num_photos=num_photos,
original_name=original_name,
original_suffix=original_suffix,
overwrite=overwrite,
person_keyword=person_keyword,
photo_num=photo_num,
preview_if_missing=preview_if_missing,
preview_suffix=preview_suffix,
replace_keywords=replace_keywords,
retry=retry,
export_dir=dest,
export_preview=preview,
preview_suffix=preview_suffix,
preview_if_missing=preview_if_missing,
photo_num=photo_num,
num_photos=num_photos,
sidecar_drop_ext=sidecar_drop_ext,
sidecar=sidecar,
skip_original_if_edited=skip_original_if_edited,
strip=strip,
touch_file=touch_file,
update=update,
use_photokit=use_photokit,
use_photos_export=use_photos_export,
verbose=verbose,
)
if post_function:
@@ -2062,7 +2073,7 @@ def export(
fp.close()
photo_str_total = "photos" if len(photos) != 1 else "photo"
if update:
if update or force_update:
summary = (
f"Processed: {len(photos)} {photo_str_total}, "
f"exported: {len(results.new)}, "
@@ -2087,6 +2098,8 @@ def export(
# cleanup files and do report if needed
if cleanup:
db_file = str(pathlib.Path(export_db_path).resolve())
db_files = [db_file, db_file + "-wal", db_file + "-shm"]
all_files = (
results.exported
+ results.skipped
@@ -2105,7 +2118,7 @@ def export(
+ results.missing
# include files that have error in case they exist from previous export
+ [r[0] for r in results.error]
+ [str(pathlib.Path(export_db_path).resolve())]
+ db_files
)
click.echo(f"Cleaning up {dest}")
cleaned_files, cleaned_dirs = cleanup_files(dest, all_files, fileutil)
@@ -2590,6 +2603,7 @@ def export_photo(
sidecar=None,
sidecar_drop_ext=False,
update=None,
force_update=None,
ignore_signature=None,
export_as_hardlink=None,
overwrite=None,
@@ -2636,47 +2650,47 @@ def export_photo(
Args:
photo: PhotoInfo object
dest: destination path as string
verbose: boolean; print verbose output
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD
sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
sidecar_drop_ext: boolean; if True, drops photo extension from sidecar name
export_as_hardlink: boolean; hardlink files instead of copying them
overwrite: boolean; overwrite dest file if it already exists
original_name: boolean; use original filename instead of current filename
export_live: boolean; also export live video component if photo is a live photo
live video will have same name as photo but with .mov extension
download_missing: attempt download of missing iCloud photos
exiftool: use exiftool to write EXIF metadata directly to exported photo
album_keyword: bool; if True, exports album names as keywords in metadata
convert_to_jpeg: bool; if True, converts non-jpeg images to jpeg
description_template: str; optional template string that will be rendered for use as photo description
directory: template used to determine output directory
filename_template: template use to determine output file
export_raw: boolean; if True exports raw image associate with the photo
export_edited: boolean; if True exports edited version of photo if there is one
skip_original_if_edited: boolean; if True does not export original if photo has been edited
album_keyword: boolean; if True, exports album names as keywords in metadata
person_keyword: boolean; if True, exports person names as keywords in metadata
keyword_template: list of strings; if provided use rendered template strings as keywords
description_template: string; optional template string that will be rendered for use as photo description
export_db: export database instance compatible with ExportDB_ABC
fileutil: file util class compatible with FileUtilABC
dry_run: boolean; if True, doesn't actually export or update any files
touch_file: boolean; sets file's modification time to match photo date
use_photos_export: boolean; if True forces the use of AppleScript to export even if photo not missing
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
download_missing: attempt download of missing iCloud photos
dry_run: bool; if True, doesn't actually export or update any files
exiftool_merge_keywords: bool; if True, merged keywords found in file's exif data (requires exiftool)
exiftool_merge_persons: bool; if True, merged persons found in file's exif data (requires exiftool)
exiftool_option: optional list flags (e.g. ["-m", "-F"]) to pass to exiftool
exiftool_merge_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
exiftool_merge_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
exiftool: bool; use exiftool to write EXIF metadata directly to exported photo
export_as_hardlink: bool; hardlink files instead of copying them
export_by_date: bool; create export folder in form dest/YYYY/MM/DD
export_db: export database instance compatible with ExportDB_ABC
export_dir: top-level export directory for {export_dir} template
export_edited: bool; if True exports edited version of photo if there is one
export_live: bool; also export live video component if photo is a live photo; live video will have same name as photo but with .mov extension
export_preview: export the preview image generated by Photos
export_raw: bool; if True exports raw image associate with the photo
filename_template: template use to determine output file
fileutil: file util class compatible with FileUtilABC
force_update: bool, only export updated photos but trigger export even if only metadata has changed
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
keyword_template: list of strings; if provided use rendered template strings as keywords
num_photos: int, total number of photos that will be exported
original_name: bool; use original filename instead of current filename
overwrite: bool; overwrite dest file if it already exists
person_keyword: bool; if True, exports person names as keywords in metadata
photo_num: int, which number photo in total of num_photos is being exported
preview_if_missing: bool, export preview if original is missing
preview_suffix: str, template to use as suffix for preview images
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error
export_dir: top-level export directory for {export_dir} template
export_preview: export the preview image generated by Photos
preview_suffix: str, template to use as suffix for preview images
preview_if_missing: bool, export preview if original is missing
photo_num: int, which number photo in total of num_photos is being exported
num_photos: int, total number of photos that will be exported
sidecar_drop_ext: bool; if True, drops photo extension from sidecar name
sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
skip_original_if_edited: bool; if True does not export original if photo has been edited
touch_file: bool; sets file's modification time to match photo date
update: bool, only export updated photos
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
verbose: bool; print verbose output
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2822,6 +2836,7 @@ def export_photo(
export_raw=export_raw,
filename=original_filename,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
@@ -2934,6 +2949,7 @@ def export_photo(
export_raw=not export_original and export_raw,
filename=edited_filename,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
@@ -3017,6 +3033,7 @@ def export_photo_to_directory(
export_raw,
filename,
fileutil,
force_update,
ignore_date_modified,
ignore_signature,
jpeg_ext,
@@ -3041,53 +3058,24 @@ def export_photo_to_directory(
"""Export photo to directory dest_path"""
results = ExportResults()
# TODO: can be updated to let export do all the missing logic
if export_original:
if missing and not preview_if_missing:
space = " " if not verbose else ""
verbose_(
f"{space}Skipping missing photo {photo.original_filename} ({photo.uuid})"
)
results.missing.append(str(pathlib.Path(dest_path) / filename))
elif (
photo.intrash
and (not photo.path or (download_missing or use_photos_export))
and not preview_if_missing
):
# skip deleted files if they're missing or using use_photos_export
# as AppleScript/PhotoKit cannot export deleted photos
space = " " if not verbose else ""
verbose_(
f"{space}Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
)
results.missing.append(str(pathlib.Path(dest_path) / filename))
return results
elif not edited:
verbose_(f"Skipping original version of {photo.original_filename}")
# don't try to export photos in the trash if they're missing
photo_path = photo.path if export_original else photo.path_edited
if photo.intrash and not photo_path and not preview_if_missing:
# skip deleted files if they're missing
# as AppleScript/PhotoKit cannot export deleted photos
verbose_(
f"Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
)
results.missing.append(str(pathlib.Path(dest_path) / filename))
return results
else:
# exporting the edited version
if missing and not preview_if_missing:
space = " " if not verbose else ""
verbose_(f"{space}Skipping missing edited photo for {filename}")
results.missing.append(str(pathlib.Path(dest_path) / filename))
return results
elif (
photo.intrash
and (not photo.path_edited or (download_missing or use_photos_export))
and not preview_if_missing
):
# skip deleted files if they're missing or using use_photos_export
# as AppleScript/PhotoKit cannot export deleted photos
space = " " if not verbose else ""
verbose_(
f"{space}Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
)
results.missing.append(str(pathlib.Path(dest_path) / filename))
return results
render_options = RenderOptions(export_dir=export_dir, dest_path=dest_path)
if not export_original and not edited:
verbose_(f"Skipping original version of {photo.original_filename}")
return results
tries = 0
while tries <= retry:
tries += 1
@@ -3104,6 +3092,7 @@ def export_photo_to_directory(
export_as_hardlink=export_as_hardlink,
export_db=export_db,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
@@ -3175,7 +3164,7 @@ def export_photo_to_directory(
)
if verbose:
if update:
if update or force_update:
for new in results.new:
verbose_(f"Exported new file {new}")
for updated in results.updated:
@@ -3206,7 +3195,7 @@ def get_filenames_from_template(
Args:
photo: a PhotoInfo instance
filename_template: a PhotoTemplate template string, may be None
original_name: boolean; if True, use photo's original filename instead of current filename
original_name: bool; if True, use photo's original filename instead of current filename
dest_path: the path the photo will be exported to
strip: if True, strips leading/trailing white space from resulting template
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
@@ -3267,9 +3256,9 @@ def get_dirnames_from_template(
Args:
photo: a PhotoInstance object
directory: a PhotoTemplate template string, may be None
export_by_date: boolean; if True, creates output directories in form YYYY-MM-DD
export_by_date: bool; if True, creates output directories in form YYYY-MM-DD
dest: top-level destination directory
dry_run: boolean; if True, runs in dry-run mode and does not create output directories
dry_run: bool; if True, runs in dry-run mode and does not create output directories
strip: if True, strips leading/trailing white space from resulting template
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version

View File

@@ -18,8 +18,9 @@ from .utils import normalize_fs_path
__all__ = ["ExportDB_ABC", "ExportDBNoOp", "ExportDB", "ExportDBInMemory"]
OSXPHOTOS_EXPORTDB_VERSION = "4.3"
OSXPHOTOS_EXPORTDB_VERSION = "5.0"
OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_FILEPATH = "4.3"
OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_TABLES = "4.3"
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
@@ -103,6 +104,14 @@ class ExportDB_ABC(ABC):
def set_detected_text_for_uuid(self, uuid, json_text):
pass
@abstractmethod
def set_metadata_for_file(self, filename, metadata):
pass
@abstractmethod
def get_metadata_for_file(self, filename):
pass
@abstractmethod
def set_data(
self,
@@ -114,6 +123,7 @@ class ExportDB_ABC(ABC):
edited_stat=None,
info_json=None,
exif_json=None,
metadata=None,
):
pass
@@ -183,6 +193,12 @@ class ExportDBNoOp(ExportDB_ABC):
def set_detected_text_for_uuid(self, uuid, json_text):
pass
def set_metadata_for_file(self, filename, metadata):
pass
def get_metadata_for_file(self, filename):
pass
def set_data(
self,
filename,
@@ -193,6 +209,7 @@ class ExportDBNoOp(ExportDB_ABC):
edited_stat=None,
info_json=None,
exif_json=None,
metadata=None,
):
pass
@@ -507,6 +524,39 @@ class ExportDB(ExportDB_ABC):
except Error as e:
logging.warning(e)
def set_metadata_for_file(self, filename, metadata):
"""set metadata of filename in the database"""
filename = str(pathlib.Path(filename).relative_to(self._path))
filename_normalized = self._normalize_filepath(filename)
conn = self._conn
try:
c = conn.cursor()
c.execute(
"UPDATE files SET metadata = ? WHERE filepath_normalized = ?;",
(metadata, filename_normalized),
)
conn.commit()
except Error as e:
logging.warning(e)
def get_metadata_for_file(self, filename):
"""get metadata value for file"""
filename = self._normalize_filepath_relative(filename)
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT metadata FROM files WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
metadata = results[0] if results else None
except Error as e:
logging.warning(e)
metadata = None
return metadata
def set_data(
self,
filename,
@@ -517,6 +567,7 @@ class ExportDB(ExportDB_ABC):
edited_stat=None,
info_json=None,
exif_json=None,
metadata=None,
):
"""sets all the data for file and uuid at once; if any value is None, does not set it"""
filename = str(pathlib.Path(filename).relative_to(self._path))
@@ -570,6 +621,15 @@ class ExportDB(ExportDB_ABC):
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename_normalized, exif_json),
)
if metadata is not None:
c.execute(
"UPDATE files "
+ "SET metadata = ? "
+ "WHERE filepath_normalized = ?;",
(metadata, filename_normalized),
)
conn.commit()
except Error as e:
logging.warning(e)
@@ -622,7 +682,7 @@ class ExportDB(ExportDB_ABC):
conn = self._get_db_connection(dbfile)
if not conn:
raise Exception("Error getting connection to database {dbfile}")
self._create_db_tables(conn)
self._create_or_migrate_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
else:
@@ -630,13 +690,19 @@ class ExportDB(ExportDB_ABC):
self.was_created = False
version_info = self._get_database_version(conn)
if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION:
self._create_db_tables(conn)
if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_FILEPATH:
self._migrate_normalized_filepath(conn)
self._create_or_migrate_db_tables(conn)
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
else:
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
# turn on performance optimizations
c = conn.cursor()
c.execute("PRAGMA journal_mode=WAL;")
c.execute("PRAGMA synchronous=NORMAL;")
c.execute("PRAGMA cache_size=-100000;")
c.execute("PRAGMA temp_store=MEMORY;")
return conn
def _get_db_connection(self, dbfile):
@@ -656,104 +722,97 @@ class ExportDB(ExportDB_ABC):
).fetchone()
return (version_info[0], version_info[1])
def _create_db_tables(self, conn):
"""create (if not already created) the necessary db tables for the export database
conn: sqlite3 db connection
def _create_or_migrate_db_tables(self, conn):
"""create (if not already created) the necessary db tables for the export database and apply any needed migrations
Args:
conn: sqlite3 db connection
"""
sql_commands = {
"sql_version_table": """ CREATE TABLE IF NOT EXISTS version (
id INTEGER PRIMARY KEY,
osxphotos TEXT,
exportdb TEXT
); """,
"sql_about_table": """ CREATE TABLE IF NOT EXISTS about (
id INTEGER PRIMARY KEY,
about TEXT
);""",
"sql_files_table": """ CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY,
filepath TEXT NOT NULL,
filepath_normalized TEXT NOT NULL,
uuid TEXT,
orig_mode INTEGER,
orig_size INTEGER,
orig_mtime REAL,
exif_mode INTEGER,
exif_size INTEGER,
exif_mtime REAL
); """,
"sql_files_table_migrate": """ CREATE TABLE IF NOT EXISTS files_migrate (
id INTEGER PRIMARY KEY,
filepath TEXT NOT NULL,
filepath_normalized TEXT NOT NULL,
uuid TEXT,
orig_mode INTEGER,
orig_size INTEGER,
orig_mtime REAL,
exif_mode INTEGER,
exif_size INTEGER,
exif_mtime REAL,
UNIQUE(filepath_normalized)
); """,
"sql_files_migrate": """ INSERT INTO files_migrate SELECT * FROM files;""",
"sql_files_drop_tables": """ DROP TABLE files;""",
"sql_files_alter": """ ALTER TABLE files_migrate RENAME TO files;""",
"sql_runs_table": """ CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
datetime TEXT,
python_path TEXT,
script_name TEXT,
args TEXT,
cwd TEXT
); """,
"sql_info_table": """ CREATE TABLE IF NOT EXISTS info (
id INTEGER PRIMARY KEY,
uuid text NOT NULL,
json_info JSON
); """,
"sql_exifdata_table": """ CREATE TABLE IF NOT EXISTS exifdata (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
json_exifdata JSON
); """,
"sql_edited_table": """ CREATE TABLE IF NOT EXISTS edited (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
"sql_converted_table": """ CREATE TABLE IF NOT EXISTS converted (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
"sql_sidecar_table": """ CREATE TABLE IF NOT EXISTS sidecar (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
sidecar_data TEXT,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
"sql_detected_text_table": """ CREATE TABLE IF NOT EXISTS detected_text (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL,
text_data JSON
); """,
"sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
"sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
"sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
"sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
"sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
"sql_sidecar_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
"sql_detected_text_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_detected_text on detected_text (uuid);""",
}
try:
version = self._get_database_version(conn)
except Exception as e:
version = (__version__, OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_TABLES)
# Current for version 4.3, for anything greater, do a migration after creation
sql_commands = [
""" CREATE TABLE IF NOT EXISTS version (
id INTEGER PRIMARY KEY,
osxphotos TEXT,
exportdb TEXT
); """,
""" CREATE TABLE IF NOT EXISTS about (
id INTEGER PRIMARY KEY,
about TEXT
);""",
""" CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY,
filepath TEXT NOT NULL,
filepath_normalized TEXT NOT NULL,
uuid TEXT,
orig_mode INTEGER,
orig_size INTEGER,
orig_mtime REAL,
exif_mode INTEGER,
exif_size INTEGER,
exif_mtime REAL
); """,
""" CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
datetime TEXT,
python_path TEXT,
script_name TEXT,
args TEXT,
cwd TEXT
); """,
""" CREATE TABLE IF NOT EXISTS info (
id INTEGER PRIMARY KEY,
uuid text NOT NULL,
json_info JSON
); """,
""" CREATE TABLE IF NOT EXISTS exifdata (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
json_exifdata JSON
); """,
""" CREATE TABLE IF NOT EXISTS edited (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
""" CREATE TABLE IF NOT EXISTS converted (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
""" CREATE TABLE IF NOT EXISTS sidecar (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
sidecar_data TEXT,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
""" CREATE TABLE IF NOT EXISTS detected_text (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL,
text_data JSON
); """,
""" CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
""" CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
""" CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
""" CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
""" CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
""" CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
""" CREATE UNIQUE INDEX IF NOT EXISTS idx_detected_text on detected_text (uuid);""",
]
# create the tables if needed
try:
c = conn.cursor()
for cmd in sql_commands.values():
for cmd in sql_commands:
c.execute(cmd)
c.execute(
"INSERT INTO version(osxphotos, exportdb) VALUES (?, ?);",
@@ -764,6 +823,19 @@ class ExportDB(ExportDB_ABC):
except Error as e:
logging.warning(e)
# perform needed migrations
if version[1] < OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_FILEPATH:
self._migrate_normalized_filepath(conn)
if version[1] < OSXPHOTOS_EXPORTDB_VERSION:
try:
c = conn.cursor()
# add metadata column to files to support --force-update
c.execute("ALTER TABLE files ADD COLUMN metadata TEXT;")
conn.commit()
except Error as e:
logging.warning(e)
def __del__(self):
"""ensure the database connection is closed"""
try:
@@ -802,6 +874,28 @@ class ExportDB(ExportDB_ABC):
"""Fix all filepath_normalized columns for unicode normalization"""
# Prior to database version 4.3, filepath_normalized was not normalized for unicode
c = conn.cursor()
migration_sql = [
""" CREATE TABLE IF NOT EXISTS files_migrate (
id INTEGER PRIMARY KEY,
filepath TEXT NOT NULL,
filepath_normalized TEXT NOT NULL,
uuid TEXT,
orig_mode INTEGER,
orig_size INTEGER,
orig_mtime REAL,
exif_mode INTEGER,
exif_size INTEGER,
exif_mtime REAL,
UNIQUE(filepath_normalized)
); """,
""" INSERT INTO files_migrate SELECT * FROM files;""",
""" DROP TABLE files;""",
""" ALTER TABLE files_migrate RENAME TO files;""",
]
for sql in migration_sql:
c.execute(sql)
conn.commit()
for table in ["converted", "edited", "exifdata", "files", "sidecar"]:
old_values = c.execute(
f"SELECT filepath_normalized, id FROM {table}"
@@ -840,7 +934,7 @@ class ExportDBInMemory(ExportDB):
conn = self._get_db_connection()
if not conn:
raise Exception("Error getting connection to in-memory database")
self._create_db_tables(conn)
self._create_or_migrate_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
else:
@@ -863,7 +957,7 @@ class ExportDBInMemory(ExportDB):
self.was_created = False
_, exportdb_ver = self._get_database_version(conn)
if exportdb_ver < OSXPHOTOS_EXPORTDB_VERSION:
self._create_db_tables(conn)
self._create_or_migrate_db_tables(conn)
self.was_upgraded = (exportdb_ver, OSXPHOTOS_EXPORTDB_VERSION)
else:
self.was_upgraded = ()

View File

@@ -82,7 +82,9 @@ class ExportOptions:
exiftool: (bool, default = False): if True, will use exiftool to write metadata to export file
export_as_hardlink: (bool, default=False): if True, will hardlink files instead of copying them
export_db: (ExportDB_ABC): instance of a class that conforms to ExportDB_ABC with methods for getting/setting data related to exported files to compare update state
face_regions: (bool, default=True): if True, will export face regions
fileutil: (FileUtilABC): class that conforms to FileUtilABC with various file utilities
force_update: (bool, default=False): if True, will export photo if any metadata has changed but export otherwise would not be triggered (e.g. metadata changed but not using exiftool)
ignore_date_modified (bool): for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
ignore_signature (bool, default=False): ignore file signature when used with update (look only at filename)
increment (bool, default=True): if True, will increment file name until a non-existant name is found if overwrite=False and increment=False, export will fail if destination file already exists
@@ -127,7 +129,9 @@ class ExportOptions:
exiftool: bool = False
export_as_hardlink: bool = False
export_db: Optional[ExportDB_ABC] = None
face_regions: bool = True
fileutil: Optional[FileUtil] = None
force_update: bool = False
ignore_date_modified: bool = False
ignore_signature: bool = False
increment: bool = True
@@ -449,71 +453,95 @@ class PhotoExporter:
dest,
options=options,
)
else:
verbose(
f"Skipping missing {'edited' if options.edited else 'original'} photo {self.photo.original_filename} ({self.photo.uuid})"
)
all_results.missing.append(dest)
# copy live photo associated .mov if requested
if (
export_original
and options.live_photo
and self.photo.live_photo
and staged_files.original_live
):
if export_original and options.live_photo and self.photo.live_photo:
live_name = dest.parent / f"{dest.stem}.mov"
src_live = staged_files.original_live
all_results += self._export_photo(
src_live,
live_name,
# don't try to convert the live photo
options=dataclasses.replace(options, convert_to_jpeg=False),
)
if staged_files.original_live:
src_live = staged_files.original_live
all_results += self._export_photo(
src_live,
live_name,
# don't try to convert the live photo
options=dataclasses.replace(options, convert_to_jpeg=False),
)
else:
verbose(
f"Skipping missing live photo for {self.photo.original_filename} ({self.photo.uuid})"
)
all_results.missing.append(live_name)
if (
export_edited
and options.live_photo
and self.photo.live_photo
and staged_files.edited_live
):
if export_edited and options.live_photo and self.photo.live_photo:
live_name = dest.parent / f"{dest.stem}.mov"
src_live = staged_files.edited_live
all_results += self._export_photo(
src_live,
live_name,
# don't try to convert the live photo
options=dataclasses.replace(options, convert_to_jpeg=False),
)
if staged_files.edited_live:
src_live = staged_files.edited_live
all_results += self._export_photo(
src_live,
live_name,
# don't try to convert the live photo
options=dataclasses.replace(options, convert_to_jpeg=False),
)
else:
verbose(
f"Skipping missing edited live photo for {self.photo.original_filename} ({self.photo.uuid})"
)
all_results.missing.append(live_name)
# copy associated RAW image if requested
if options.raw_photo and self.photo.has_raw and staged_files.raw:
raw_path = pathlib.Path(staged_files.raw)
raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
all_results += self._export_photo(
raw_path,
raw_name,
options=options,
)
if options.raw_photo and self.photo.has_raw:
if staged_files.raw:
raw_path = pathlib.Path(staged_files.raw)
raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
all_results += self._export_photo(
raw_path,
raw_name,
options=options,
)
else:
# guess at most likely raw name
raw_ext = get_preferred_uti_extension(self.photo.uti_raw) or "raw"
raw_name = dest.parent / f"{dest.stem}.{raw_ext}"
all_results.missing.append(raw_name)
verbose(
f"Skipping missing raw photo for {self.photo.original_filename} ({self.photo.uuid})"
)
# copy preview image if requested
if options.preview and staged_files.preview:
# Photos keeps multiple different derivatives and path_derivatives returns list of them
# first derivative is the largest so export that one
preview_path = pathlib.Path(staged_files.preview)
preview_ext = preview_path.suffix
preview_name = (
dest.parent / f"{dest.stem}{options.preview_suffix}{preview_ext}"
)
# if original is missing, the filename won't have been incremented so
# need to check here to make sure there aren't duplicate preview files in
# the export directory
preview_name = (
preview_name
if options.overwrite or options.update
else pathlib.Path(increment_filename(preview_name))
)
all_results += self._export_photo(
preview_path,
preview_name,
options=options,
)
if options.preview:
if staged_files.preview:
# Photos keeps multiple different derivatives and path_derivatives returns list of them
# first derivative is the largest so export that one
preview_path = pathlib.Path(staged_files.preview)
preview_ext = preview_path.suffix
preview_name = (
dest.parent / f"{dest.stem}{options.preview_suffix}{preview_ext}"
)
# if original is missing, the filename won't have been incremented so
# need to check here to make sure there aren't duplicate preview files in
# the export directory
preview_name = (
preview_name
if any([options.overwrite, options.update, options.force_update])
else pathlib.Path(increment_filename(preview_name))
)
all_results += self._export_photo(
preview_path,
preview_name,
options=options,
)
else:
# don't know what actual preview suffix would be but most likely jpeg
preview_name = dest.parent / f"{dest.stem}{options.preview_suffix}.jpeg"
all_results.missing.append(preview_name)
verbose(
f"Skipping missing preview photo for {self.photo.original_filename} ({self.photo.uuid})"
)
all_results += self._write_sidecar_files(dest=dest, options=options)
@@ -565,7 +593,7 @@ class PhotoExporter:
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not any(
[options.increment, options.update, options.overwrite]
[options.increment, options.update, options.force_update, options.overwrite]
):
raise FileExistsError(
f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}"
@@ -577,11 +605,13 @@ class PhotoExporter:
# e.g. exporting sidecar for file1.png and file1.jpeg
# if file1.png exists and exporting file1.jpeg,
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
if options.increment and not options.update and not options.overwrite:
if options.increment and not any(
[options.update, options.force_update, options.overwrite]
):
return pathlib.Path(increment_filename(dest))
# if update and file exists, need to check to see if it's the write file by checking export db
if options.update and dest.exists() and src:
if (options.update or options.force_update) and dest.exists() and src:
export_db = options.export_db
fileutil = options.fileutil
# destination exists, check to see if destination is the right UUID
@@ -711,7 +741,7 @@ class PhotoExporter:
# export live_photo .mov file?
live_photo = bool(options.live_photo and self.photo.live_photo)
overwrite = options.overwrite or options.update
overwrite = any([options.overwrite, options.update, options.force_update])
# figure out which photo version to request
if options.edited or self.photo.shared:
@@ -819,7 +849,7 @@ class PhotoExporter:
# export live_photo .mov file?
live_photo = bool(options.live_photo and self.photo.live_photo)
overwrite = options.overwrite or options.update
overwrite = any([options.overwrite, options.update, options.force_update])
edited_version = options.edited or self.photo.shared
# shared photos (in shared albums) show up as not having adjustments (not edited)
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
@@ -971,18 +1001,16 @@ class PhotoExporter:
fileutil = options.fileutil
export_db = options.export_db
if options.update: # updating
if options.update or options.force_update: # updating
cmp_touch, cmp_orig = False, False
if dest_exists:
# update, destination exists, but we might not need to replace it...
if options.ignore_signature:
cmp_orig = True
cmp_touch = fileutil.cmp(
src, dest, mtime1=int(self.photo.date.timestamp())
)
elif options.exiftool:
if options.exiftool:
sig_exif = export_db.get_stat_exif_for_file(dest_str)
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
if cmp_orig:
# if signatures match also need to compare exifdata to see if metadata changed
cmp_orig = not self._should_run_exiftool(dest_str, options)
sig_exif = (
sig_exif[0],
sig_exif[1],
@@ -999,10 +1027,17 @@ class PhotoExporter:
)
cmp_touch = fileutil.cmp_file_sig(dest_str, sig_converted)
else:
cmp_orig = fileutil.cmp(src, dest)
cmp_orig = options.ignore_signature or fileutil.cmp(src, dest)
cmp_touch = fileutil.cmp(
src, dest, mtime1=int(self.photo.date.timestamp())
)
if options.force_update:
# need to also check the photo's metadata to that in the database
# and if anything changed, we need to update the file
# ony the hex digest of the metadata is stored in the database
photo_digest = hexdigest(self.photo.json())
db_digest = export_db.get_metadata_for_file(dest_str)
cmp_orig = photo_digest == db_digest
sig_cmp = cmp_touch if options.touch_file else cmp_orig
@@ -1016,7 +1051,7 @@ class PhotoExporter:
if sig_edited != (None, None, None)
else False
)
sig_cmp = sig_cmp and cmp_edited
sig_cmp = sig_cmp and (options.force_update or cmp_edited)
if (options.export_as_hardlink and dest.samefile(src)) or (
not options.export_as_hardlink
@@ -1059,7 +1094,9 @@ class PhotoExporter:
edited_stat = (
fileutil.file_sig(src) if options.edited else (None, None, None)
)
if dest_exists and (options.update or options.overwrite):
if dest_exists and any(
[options.overwrite, options.update, options.force_update]
):
# need to remove the destination first
try:
fileutil.unlink(dest)
@@ -1102,13 +1139,17 @@ class PhotoExporter:
f"Error copying file {src} to {dest_str}: {e} ({lineno(__file__)})"
) from e
json_info = self.photo.json()
# don't set the metadata digest if not force_update so that future use of force_update catches metadata change
metadata_digest = hexdigest(json_info) if options.force_update else None
export_db.set_data(
filename=dest_str,
uuid=self.photo.uuid,
orig_stat=fileutil.file_sig(dest_str),
converted_stat=converted_stat,
edited_stat=edited_stat,
info_json=self.photo.json(),
info_json=json_info,
metadata=metadata_digest,
)
return ExportResults(
@@ -1208,10 +1249,13 @@ class PhotoExporter:
sidecar_filename
)
write_sidecar = (
not options.update
or (options.update and not sidecar_filename.exists())
not (options.update or options.force_update)
or (
options.update
(options.update or options.force_update)
and not sidecar_filename.exists()
)
or (
(options.update or options.force_update)
and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
)
@@ -1279,27 +1323,7 @@ class PhotoExporter:
# determine if we need to write the exif metadata
# if we are not updating, we always write
# else, need to check the database to determine if we need to write
run_exiftool = not options.update
if options.update:
files_are_different = False
old_data = export_db.get_exifdata_for_file(dest)
if old_data is not None:
old_data = json.loads(old_data)[0]
current_data = json.loads(self._exiftool_json_sidecar(options=options))[
0
]
if old_data != current_data:
files_are_different = True
if old_data is None or files_are_different:
# didn't have old data, assume we need to write it
# or files were different
run_exiftool = True
else:
verbose(
f"Skipped up to date exiftool metadata for {pathlib.Path(dest).name}"
)
run_exiftool = self._should_run_exiftool(dest, options)
if run_exiftool:
verbose(f"Writing metadata with exiftool for {pathlib.Path(dest).name}")
if not options.dry_run:
@@ -1318,8 +1342,32 @@ class PhotoExporter:
)
exiftool_results.exif_updated.append(dest)
exiftool_results.to_touch.append(dest)
else:
verbose(
f"Skipped up to date exiftool metadata for {pathlib.Path(dest).name}"
)
return exiftool_results
def _should_run_exiftool(self, dest, options: ExportOptions) -> bool:
"""Return True if exiftool should be run to update metadata"""
run_exiftool = not (options.update or options.force_update)
if options.update or options.force_update:
files_are_different = False
old_data = options.export_db.get_exifdata_for_file(dest)
if old_data is not None:
old_data = json.loads(old_data)[0]
current_data = json.loads(self._exiftool_json_sidecar(options=options))[
0
]
if old_data != current_data:
files_are_different = True
if old_data is None or files_are_different:
# didn't have old data, assume we need to write it
# or files were different
run_exiftool = True
return run_exiftool
def _write_exif_data(self, filepath: str, options: ExportOptions):
"""write exif data to image file at filepath
@@ -1493,6 +1541,9 @@ class PhotoExporter:
person_list = sorted(list(set(person_list)))
exif["XMP:PersonInImage"] = person_list.copy()
if options.face_regions and self.photo.face_info and self.photo._db._beta:
exif.update(self._get_mwg_face_regions_exiftool())
# if self.favorite():
# exif["Rating"] = 5
@@ -1575,6 +1626,42 @@ class PhotoExporter:
return exif
def _get_mwg_face_regions_exiftool(self):
"""Return a dict with MWG face regions for use by exiftool"""
if self.photo.orientation in [5, 6, 7, 8]:
w = self.photo.height
h = self.photo.width
else:
w = self.photo.width
h = self.photo.height
exif = {}
exif["XMP:RegionAppliedToDimensionsW"] = w
exif["XMP:RegionAppliedToDimensionsH"] = h
exif["XMP:RegionAppliedToDimensionsUnit"] = "pixel"
exif["XMP:RegionName"] = []
exif["XMP:RegionType"] = []
exif["XMP:RegionAreaX"] = []
exif["XMP:RegionAreaY"] = []
exif["XMP:RegionAreaW"] = []
exif["XMP:RegionAreaH"] = []
exif["XMP:RegionAreaUnit"] = []
exif["XMP:RegionPersonDisplayName"] = []
# exif["XMP:RegionRectangle"] = []
for face in self.photo.face_info:
if not face.name:
continue
area = face.mwg_rs_area
exif["XMP:RegionName"].append(face.name)
exif["XMP:RegionType"].append("Face")
exif["XMP:RegionAreaX"].append(area.x)
exif["XMP:RegionAreaY"].append(area.y)
exif["XMP:RegionAreaW"].append(area.w)
exif["XMP:RegionAreaH"].append(area.h)
exif["XMP:RegionAreaUnit"].append("normalized")
exif["XMP:RegionPersonDisplayName"].append(face.name)
# exif["XMP:RegionRectangle"].append(f"{area.x},{area.y},{area.h},{area.w}")
return exif
def _get_exif_keywords(self):
"""returns list of keywords found in the file's exif metadata"""
keywords = []

View File

@@ -1728,7 +1728,11 @@ class PhotoInfo:
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
return json.dumps(self.asdict(), sort_keys=True, default=default)
dict_data = self.asdict()
for k, v in dict_data.items():
if v and isinstance(v, (list, tuple)) and not isinstance(v[0], dict):
dict_data[k] = sorted(v)
return json.dumps(dict_data, sort_keys=True, default=default)
def __eq__(self, other):
"""Compare two PhotoInfo objects for equality"""

View File

@@ -211,10 +211,12 @@ class SearchInfo:
"""return list of text for a specified category ID"""
if self._db_searchinfo:
content = "normalized_string" if self._normalized else "content_string"
return [
rec[content]
for rec in self._db_searchinfo
if rec["category"] == category
]
return sorted(
[
rec[content]
for rec in self._db_searchinfo
if rec["category"] == category
]
)
else:
return []

View File

@@ -103,6 +103,8 @@
% if photo.face_info:
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:AppliedToDimensions rdf:parseType="Resource">
<stDim:h>${photo.width if photo.orientation in [5, 6, 7, 8] else photo.height}</stDim:h>
<stDim:w>${photo.height if photo.orientation in [5, 6, 7, 8] else photo.width}</stDim:w>
<stDim:unit>pixel</stDim:unit>
</mwg-rs:AppliedToDimensions>
<mwg-rs:RegionList>

View File

@@ -40,7 +40,7 @@ else:
@pytest.fixture(autouse=True)
def reset_singletons():
""" Need to clean up any ExifTool singletons between tests """
"""Need to clean up any ExifTool singletons between tests"""
_ExifToolProc.instance = None
@@ -73,7 +73,7 @@ def pytest_collection_modifyitems(config, items):
def copy_photos_library(photos_library=TEST_LIBRARY, delay=0):
""" copy the test library and open Photos, returns path to copied library """
"""copy the test library and open Photos, returns path to copied library"""
script = AppleScript(
"""
tell application "Photos"
@@ -118,3 +118,9 @@ def copy_photos_library(photos_library=TEST_LIBRARY, delay=0):
@pytest.fixture
def addalbum_library():
copy_photos_library(delay=10)
def copy_photos_library_to_path(photos_library_path: str, dest_path: str) -> str:
"""Copy a photos library to a folder"""
ditto(photos_library_path, dest_path)
return dest_path

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,12 @@
r""" Test the command line interface (CLI) """
""" Test the command line interface (CLI) """
import os
import sqlite3
import tempfile
import pytest
from click.testing import CliRunner
from conftest import copy_photos_library_to_path
import osxphotos
from osxphotos.exiftool import get_exiftool_path
@@ -1971,6 +1973,89 @@ def test_export_exiftool():
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_template_change():
"""Test --exiftool when template changes with --update, #630"""
import glob
import os
import os.path
from osxphotos.cli import export
from osxphotos.exiftool import ExifTool
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_EXIFTOOL:
# export with --exiftool
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
# export with --update, should be no change
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--update",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
assert "exported: 0" in result.output
# export with --update and template change, should export
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--keyword-template",
"FOO",
"--update",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
assert "updated EXIF data: 1" in result.output
# export with --update, nothing should export
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--keyword-template",
"FOO",
"--update",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
assert "exported: 0" in result.output
assert "updated EXIF data: 0" in result.output
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_path():
"""test --exiftool with --exiftool-path"""
@@ -4721,11 +4806,96 @@ def test_export_update_basic():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
def test_export_force_update():
"""test export with --force-update"""
import glob
import os
import os.path
import osxphotos
from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# basic export
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
assert os.path.isfile(OSXPHOTOS_EXPORT_DB)
src = os.path.join(cwd, CLI_PHOTOS_DB)
dest = os.path.join(os.getcwd(), "export_force_update.photoslibrary")
photos_db_path = copy_photos_library_to_path(src, dest)
# update
result = runner.invoke(
export, [os.path.join(cwd, photos_db_path), ".", "--update"]
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
# force update must be run once to set the metadata digest info
# in practice, this means that first time user uses --force-update, most files will likely be re-exported
result = runner.invoke(
export, [os.path.join(cwd, photos_db_path), ".", "--force-update"]
)
assert result.exit_code == 0
# update a file
dbpath = os.path.join(photos_db_path, "database/Photos.sqlite")
try:
conn = sqlite3.connect(dbpath)
c = conn.cursor()
except sqlite3.Error as e:
pytest.exit(f"An error occurred opening sqlite file")
# photo is IMG_4547.jpg
c.execute(
"UPDATE ZADDITIONALASSETATTRIBUTES SET Z_OPT=9, ZTITLE='My Updated Title' WHERE Z_PK=8;"
)
conn.commit()
# run --force-update to see if updated metadata forced update
result = runner.invoke(
export, [os.path.join(cwd, photos_db_path), ".", "--force-update"]
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-1}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
# update, nothing should export
result = runner.invoke(
export, [os.path.join(cwd, photos_db_path), ".", "--update"]
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
# run --force-update, nothing should export
result = runner.invoke(
export, [os.path.join(cwd, photos_db_path), ".", "--force-update"]
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",
@@ -4838,7 +5008,7 @@ def test_export_update_exiftool():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 2, error: 1"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 1"
in result.output
)
@@ -4848,7 +5018,7 @@ def test_export_update_exiftool():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
@@ -4885,7 +5055,7 @@ def test_export_update_hardlink():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: 0, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
@@ -4924,7 +5094,7 @@ def test_export_update_hardlink_exiftool():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 2, error: 1"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 1"
in result.output
)
assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
@@ -4962,7 +5132,7 @@ def test_export_update_edits():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 1, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}, updated EXIF data: 0, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 1, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
@@ -5060,7 +5230,7 @@ def test_export_update_no_db():
# edited files will be re-exported because there won't be an edited signature
# in the database
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_EDITED_15_7}, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7}, updated EXIF data: 0, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_EDITED_15_7}, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)
assert os.path.isfile(OSXPHOTOS_EXPORT_DB)
@@ -5100,7 +5270,7 @@ def test_export_then_hardlink():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 0"
in result.output
)
assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
@@ -5125,7 +5295,7 @@ def test_export_dry_run():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 0"
in result.output
)
for filepath in CLI_EXPORT_FILENAMES_DRY_RUN:
@@ -5171,7 +5341,7 @@ def test_export_update_edits_dry_run():
)
assert result.exit_code == 0
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 1, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}, updated EXIF data: 0, missing: 2, error: 0"
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 1, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
)

View File

@@ -7,6 +7,7 @@ import pytest
EXIF_DATA = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "EXIF:ImageDescription": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Title": "Elder Park", "EXIF:GPSLatitude": "34 deg 55' 8.01\" S", "EXIF:GPSLongitude": "138 deg 35' 48.70\" E", "Composite:GPSPosition": "34 deg 55' 8.01\" S, 138 deg 35' 48.70\" E", "EXIF:GPSLatitudeRef": "South", "EXIF:GPSLongitudeRef": "East", "EXIF:DateTimeOriginal": "2017:06:20 17:18:56", "EXIF:OffsetTimeOriginal": "+09:30", "EXIF:ModifyDate": "2020:05:18 14:42:04"}]"""
INFO_DATA = """{"uuid": "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "filename": "3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "original_filename": "IMG_4547.jpg", "date": "2017-06-20T17:18:56.518000+09:30", "description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "title": "Elder Park", "keywords": [], "labels": ["Statue", "Art"], "albums": ["AlbumInFolder"], "folders": {"AlbumInFolder": ["Folder1", "SubFolder2"]}, "persons": [], "path": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/originals/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "ismissing": false, "hasadjustments": true, "external_edit": false, "favorite": false, "hidden": false, "latitude": -34.91889167000001, "longitude": 138.59686167, "path_edited": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/resources/renders/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907_1_201_a.jpeg", "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": false, "incloud": null, "date_modified": "2020-05-18T14:42:04.608664+09:30", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Elder Park, Adelaide, South Australia, Australia, River Torrens", "names": {"field0": [], "country": ["Australia"], "state_province": ["South Australia"], "sub_administrative_area": ["Adelaide"], "city": ["Adelaide", "Adelaide"], "field5": [], "additional_city_info": ["Adelaide CBD", "Tarndanya"], "ocean": [], "area_of_interest": ["Elder Park", ""], "inland_water": ["River Torrens", "River Torrens"], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": ["River Torrens", "River Torrens"]}, "country_code": "AU", "ishome": false, "address_str": "River Torrens, Adelaide SA, Australia", "address": {"street": null, "sub_locality": "Tarndanya", "city": "Adelaide", "sub_administrative_area": "Adelaide", "state_province": "SA", "postal_code": null, "country": "Australia", "iso_country_code": "AU"}}, "exif": {"flash_fired": false, "iso": 320, "metering_mode": 3, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.058823529411764705, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
SIDECAR_DATA = """FOO_BAR"""
METADATA_DATA = "FIZZ"
EXIF_DATA2 = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "XMP:Title": "St. James's Park", "XMP:TagsList": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "IPTC:Keywords": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "XMP:Subject": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "EXIF:GPSLatitude": "51 deg 30' 12.86\" N", "EXIF:GPSLongitude": "0 deg 7' 54.50\" W", "Composite:GPSPosition": "51 deg 30' 12.86\" N, 0 deg 7' 54.50\" W", "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", "EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:08 14:06:44"}]"""
INFO_DATA2 = """{"uuid": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529", "filename": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "original_filename": "IMG_8440.JPG", "date": "2019-06-11T11:42:06.711805-07:00", "description": null, "title": null, "keywords": [], "labels": ["Sky", "Cloudy", "Fence", "Land", "Outdoor", "Park", "Amusement Park", "Roller Coaster"], "albums": [], "folders": {}, "persons": [], "path": "/Volumes/MacBook Catalina - Data/Users/rhet/Pictures/Photos Library.photoslibrary/originals/F/F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "ismissing": false, "hasadjustments": false, "external_edit": false, "favorite": false, "hidden": false, "latitude": 33.81558666666667, "longitude": -117.99298, "path_edited": null, "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": true, "incloud": true, "date_modified": "2019-10-14T00:51:47.141950-07:00", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Adventure City, Stanton, California, United States", "names": {"field0": [], "country": ["United States"], "state_province": ["California"], "sub_administrative_area": ["Orange"], "city": ["Stanton", "Anaheim", "Anaheim"], "field5": [], "additional_city_info": ["West Anaheim"], "ocean": [], "area_of_interest": ["Adventure City", "Adventure City"], "inland_water": [], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": []}, "country_code": "US", "ishome": false, "address_str": "Adventure City, 1240 S Beach Blvd, Anaheim, CA 92804, United States", "address": {"street": "1240 S Beach Blvd", "sub_locality": "West Anaheim", "city": "Stanton", "sub_administrative_area": "Orange", "state_province": "CA", "postal_code": "92804", "country": "United States", "iso_country_code": "US"}}, "exif": {"flash_fired": false, "iso": 25, "metering_mode": 5, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.0004940711462450593, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
@@ -64,6 +65,7 @@ def test_export_db():
(10, 11, 12),
INFO_DATA,
EXIF_DATA,
METADATA_DATA,
)
assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA
@@ -73,6 +75,7 @@ def test_export_db():
assert db.get_stat_converted_for_file(filepath2) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"])
assert db.get_metadata_for_file(filepath2) == METADATA_DATA
# test set_data value=None doesn't overwrite existing data
db.set_data(
@@ -84,6 +87,7 @@ def test_export_db():
None,
None,
None,
None,
)
assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA
@@ -93,6 +97,7 @@ def test_export_db():
assert db.get_stat_converted_for_file(filepath2) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"])
assert db.get_metadata_for_file(filepath2) == METADATA_DATA
# close and re-open
db.close()
@@ -107,6 +112,8 @@ def test_export_db():
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"])
assert json.loads(db.get_detected_text_for_uuid("FOO-BAR")) == [["foo", 0.5]]
assert db.get_metadata_for_file(filepath2) == METADATA_DATA
# update data
db.set_uuid_for_file(filepath, "FUBAR")
@@ -148,9 +155,10 @@ def test_export_db_no_op():
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_sidecar_for_file(filepath) == (None, (None, None, None))
assert db.get_previous_uuids() == []
db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]]))
assert db.get_detected_text_for_uuid("FOO-BAR") is None
db.set_metadata_for_file(filepath, METADATA_DATA)
assert db.get_metadata_for_file(filepath) is None
# test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -163,6 +171,7 @@ def test_export_db_no_op():
(10, 11, 12),
INFO_DATA,
EXIF_DATA,
METADATA_DATA,
)
assert db.get_uuid_for_file(filepath2) is None
assert db.get_info_for_uuid("BAR-FOO") is None
@@ -172,6 +181,7 @@ def test_export_db_no_op():
assert db.get_stat_converted_for_file(filepath) is None
assert db.get_stat_edited_for_file(filepath) is None
assert db.get_previous_uuids() == []
assert db.get_metadata_for_file(filepath) is None
# update data
db.set_uuid_for_file(filepath, "FUBAR")
@@ -207,7 +217,7 @@ def test_export_db_in_memory():
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_previous_uuids() == ["FOO-BAR"]
db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]]))
db.set_metadata_for_file(filepath, METADATA_DATA)
db.close()
dbram = ExportDBInMemory(dbname, tempdir.name)
@@ -226,6 +236,7 @@ def test_export_db_in_memory():
assert dbram.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
assert dbram.get_previous_uuids() == ["FOO-BAR"]
assert json.loads(dbram.get_detected_text_for_uuid("FOO-BAR")) == [["foo", 0.5]]
assert dbram.get_metadata_for_file(filepath) == METADATA_DATA
# change a value
dbram.set_uuid_for_file(filepath, "FUBAR")
@@ -237,6 +248,7 @@ def test_export_db_in_memory():
dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
dbram.set_detected_text_for_uuid("FUBAR", json.dumps([["bar", 0.5]]))
dbram.set_metadata_for_file(filepath, "FUBAR")
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -248,6 +260,7 @@ def test_export_db_in_memory():
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
assert dbram.get_previous_uuids() == ["FUBAR"]
assert json.loads(dbram.get_detected_text_for_uuid("FUBAR")) == [["bar", 0.5]]
assert dbram.get_metadata_for_file(filepath) == "FUBAR"
dbram.close()
@@ -265,6 +278,7 @@ def test_export_db_in_memory():
assert db.get_info_for_uuid("FUBAR") is None
assert db.get_detected_text_for_uuid("FUBAR") is None
assert db.get_metadata_for_file(filepath) == METADATA_DATA
def test_export_db_in_memory_nofile():
@@ -295,6 +309,7 @@ def test_export_db_in_memory_nofile():
dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
dbram.set_detected_text_for_uuid("FUBAR", json.dumps([["bar", 0.5]]))
dbram.set_metadata_for_file(filepath, METADATA_DATA)
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -306,5 +321,6 @@ def test_export_db_in_memory_nofile():
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
assert dbram.get_previous_uuids() == ["FUBAR"]
assert json.loads(dbram.get_detected_text_for_uuid("FUBAR")) == [["bar", 0.5]]
assert dbram.get_metadata_for_file(filepath) == METADATA_DATA
dbram.close()