Compare commits
10 Commits
multiproce
...
v0.45.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d51ab129 | ||
|
|
afbda030bc | ||
|
|
d111d07fb7 | ||
|
|
30abdddaf3 | ||
|
|
a2f329b8de | ||
|
|
bfa888adc5 | ||
|
|
ac4083bfbb | ||
|
|
5fb686ac0c | ||
|
|
49a7b80680 | ||
|
|
cb11967eac |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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
|
||||
|
||||
15
README.md
15
README.md
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
2
docs/_static/documentation_options.js
vendored
2
docs/_static/documentation_options.js
vendored
@@ -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',
|
||||
|
||||
@@ -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) — osxphotos 0.45.8 documentation</title>
|
||||
<title>osxphotos command line interface (CLI) — 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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Index — osxphotos 0.45.8 documentation</title>
|
||||
<title>Index — 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>
|
||||
|
||||
@@ -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 osxphotos’s documentation! — osxphotos 0.45.8 documentation</title>
|
||||
<title>Welcome to osxphotos’s documentation! — 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>
|
||||
|
||||
@@ -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 — osxphotos 0.45.8 documentation</title>
|
||||
<title>osxphotos — 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>
|
||||
|
||||
@@ -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 — osxphotos 0.45.8 documentation</title>
|
||||
<title>osxphotos package — 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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Search — osxphotos 0.45.8 documentation</title>
|
||||
<title>Search — 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" />
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.45.8"
|
||||
__version__ = "0.45.11"
|
||||
|
||||
475
osxphotos/cli.py
475
osxphotos/cli.py
@@ -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
|
||||
|
||||
|
||||
@@ -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 = ()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user