Added --force-update, #621

This commit is contained in:
Rhet Turnbull
2022-02-12 17:49:40 -08:00
parent ac4083bfbb
commit bfa888adc5
17 changed files with 559 additions and 343 deletions

View File

@@ -783,8 +783,15 @@ Options:
folder. folder.
--deleted-only Include only photos from the 'Recently --deleted-only Include only photos from the 'Recently
Deleted' folder. Deleted' folder.
--update Only export new or updated files. See notes --update Only export new or updated files. See also
below on export and --update. --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 --ignore-signature When used with '--update', ignores file
signature when updating files. This is useful signature when updating files. This is useful
if you have processed or edited exported if you have processed or edited exported
@@ -1725,7 +1732,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline} {lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r' {cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n' {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.9'
{osxphotos_cmd_line} The full command line used to run osxphotos {osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for 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}| |{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'| |{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'| |{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.9'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos| |{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in| |{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| |{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|

View File

@@ -1,4 +1,4 @@
# Sphinx build info version 1 # 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. # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: bf43bf49b725c31ce72a8823e4f8012b config: a320d2e66b198895ef0b12b1f5934727
tags: 645f666f9bcd5a90fca523b33c5a78b7 tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -691,7 +691,15 @@ def cli(ctx, db, json_, debug):
@click.option( @click.option(
"--update", "--update",
is_flag=True, 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( @click.option(
"--ignore-signature", "--ignore-signature",
@@ -1235,6 +1243,7 @@ def export(
timestamp, timestamp,
missing, missing,
update, update,
force_update,
ignore_signature, ignore_signature,
only_new, only_new,
dry_run, dry_run,
@@ -1398,133 +1407,134 @@ def export(
# re-set the local vars to the corresponding config value # 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 # this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
db = cfg.db add_exported_to_album = cfg.add_exported_to_album
photos_library = cfg.photos_library add_missing_to_album = cfg.add_missing_to_album
keyword = cfg.keyword add_skipped_to_album = cfg.add_skipped_to_album
person = cfg.person
album = cfg.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 album_keyword = cfg.album_keyword
keyword_template = cfg.keyword_template beta = cfg.beta
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
burst = cfg.burst burst = cfg.burst
not_burst = cfg.not_burst cleanup = cfg.cleanup
live = cfg.live convert_to_jpeg = cfg.convert_to_jpeg
not_live = cfg.not_live current_name = cfg.current_name
download_missing = cfg.download_missing db = cfg.db
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
deleted = cfg.deleted deleted = cfg.deleted
deleted_only = cfg.deleted_only deleted_only = cfg.deleted_only
use_photos_export = cfg.use_photos_export description = cfg.description
use_photokit = cfg.use_photokit description_template = cfg.description_template
report = cfg.report directory = cfg.directory
cleanup = cfg.cleanup download_missing = cfg.download_missing
add_exported_to_album = cfg.add_exported_to_album dry_run = cfg.dry_run
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
duplicate = cfg.duplicate 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_command = cfg.post_command
post_function = cfg.post_function post_function = cfg.post_function
preview = cfg.preview preview = cfg.preview
preview_suffix = cfg.preview_suffix
preview_if_missing = cfg.preview_if_missing 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 # config file might have changed verbose
VERBOSE = bool(verbose) VERBOSE = bool(verbose)
@@ -1564,8 +1574,8 @@ def export(
dependent_options = [ dependent_options = [
("missing", ("download_missing", "use_photos_export")), ("missing", ("download_missing", "use_photos_export")),
("jpeg_quality", ("convert_to_jpeg")), ("jpeg_quality", ("convert_to_jpeg")),
("ignore_signature", ("update")), ("ignore_signature", ("update", "force_update")),
("only_new", ("update")), ("only_new", ("update", "force_update")),
("exiftool_option", ("exiftool")), ("exiftool_option", ("exiftool")),
("exiftool_merge_keywords", ("exiftool", "sidecar")), ("exiftool_merge_keywords", ("exiftool", "sidecar")),
("exiftool_merge_persons", ("exiftool", "sidecar")), ("exiftool_merge_persons", ("exiftool", "sidecar")),
@@ -1905,51 +1915,52 @@ def export(
export_results = export_photo( export_results = export_photo(
photo=p, photo=p,
dest=dest, dest=dest,
verbose=verbose, album_keyword=album_keyword,
export_by_date=export_by_date, convert_to_jpeg=convert_to_jpeg,
sidecar=sidecar, description_template=description_template,
sidecar_drop_ext=sidecar_drop_ext, directory=directory,
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,
download_missing=download_missing, download_missing=download_missing,
exiftool=exiftool, dry_run=dry_run,
edited_suffix=edited_suffix,
exiftool_merge_keywords=exiftool_merge_keywords, exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons, 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, 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_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, replace_keywords=replace_keywords,
retry=retry, retry=retry,
export_dir=dest, sidecar_drop_ext=sidecar_drop_ext,
export_preview=preview, sidecar=sidecar,
preview_suffix=preview_suffix, skip_original_if_edited=skip_original_if_edited,
preview_if_missing=preview_if_missing, strip=strip,
photo_num=photo_num, touch_file=touch_file,
num_photos=num_photos, update=update,
use_photokit=use_photokit,
use_photos_export=use_photos_export,
verbose=verbose,
) )
if post_function: if post_function:
@@ -2062,7 +2073,7 @@ def export(
fp.close() fp.close()
photo_str_total = "photos" if len(photos) != 1 else "photo" photo_str_total = "photos" if len(photos) != 1 else "photo"
if update: if update or force_update:
summary = ( summary = (
f"Processed: {len(photos)} {photo_str_total}, " f"Processed: {len(photos)} {photo_str_total}, "
f"exported: {len(results.new)}, " f"exported: {len(results.new)}, "
@@ -2592,6 +2603,7 @@ def export_photo(
sidecar=None, sidecar=None,
sidecar_drop_ext=False, sidecar_drop_ext=False,
update=None, update=None,
force_update=None,
ignore_signature=None, ignore_signature=None,
export_as_hardlink=None, export_as_hardlink=None,
overwrite=None, overwrite=None,
@@ -2638,47 +2650,47 @@ def export_photo(
Args: Args:
photo: PhotoInfo object photo: PhotoInfo object
dest: destination path as string dest: destination path as string
verbose: boolean; print verbose output album_keyword: bool; if True, exports album names as keywords in metadata
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD convert_to_jpeg: bool; if True, converts non-jpeg images to jpeg
sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export description_template: str; optional template string that will be rendered for use as photo description
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
directory: template used to determine output directory directory: template used to determine output directory
filename_template: template use to determine output file download_missing: attempt download of missing iCloud photos
export_raw: boolean; if True exports raw image associate with the photo dry_run: bool; if True, doesn't actually export or update any files
export_edited: boolean; if True exports edited version of photo if there is one exiftool_merge_keywords: bool; if True, merged keywords found in file's exif data (requires exiftool)
skip_original_if_edited: boolean; if True does not export original if photo has been edited exiftool_merge_persons: bool; if True, merged persons found in file's exif data (requires exiftool)
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
exiftool_option: optional list flags (e.g. ["-m", "-F"]) to pass to 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: bool; use exiftool to write EXIF metadata directly to exported photo
exiftool_merge_persons: boolean; if True, merged persons found in file's exif data (requires exiftool) 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_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 replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error retry: retry up to retry # of times if there's an error
export_dir: top-level export directory for {export_dir} template sidecar_drop_ext: bool; if True, drops photo extension from sidecar name
export_preview: export the preview image generated by Photos sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
preview_suffix: str, template to use as suffix for preview images skip_original_if_edited: bool; if True does not export original if photo has been edited
preview_if_missing: bool, export preview if original is missing touch_file: bool; sets file's modification time to match photo date
photo_num: int, which number photo in total of num_photos is being exported update: bool, only export updated photos
num_photos: int, total number of photos that will be exported use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
verbose: bool; print verbose output
Returns: Returns:
list of path(s) of exported photo or None if photo was missing list of path(s) of exported photo or None if photo was missing
@@ -2824,6 +2836,7 @@ def export_photo(
export_raw=export_raw, export_raw=export_raw,
filename=original_filename, filename=original_filename,
fileutil=fileutil, fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature, ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext, jpeg_ext=jpeg_ext,
@@ -2936,6 +2949,7 @@ def export_photo(
export_raw=not export_original and export_raw, export_raw=not export_original and export_raw,
filename=edited_filename, filename=edited_filename,
fileutil=fileutil, fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature, ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext, jpeg_ext=jpeg_ext,
@@ -3019,6 +3033,7 @@ def export_photo_to_directory(
export_raw, export_raw,
filename, filename,
fileutil, fileutil,
force_update,
ignore_date_modified, ignore_date_modified,
ignore_signature, ignore_signature,
jpeg_ext, jpeg_ext,
@@ -3077,6 +3092,7 @@ def export_photo_to_directory(
export_as_hardlink=export_as_hardlink, export_as_hardlink=export_as_hardlink,
export_db=export_db, export_db=export_db,
fileutil=fileutil, fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature, ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext, jpeg_ext=jpeg_ext,
@@ -3179,7 +3195,7 @@ def get_filenames_from_template(
Args: Args:
photo: a PhotoInfo instance photo: a PhotoInfo instance
filename_template: a PhotoTemplate template string, may be None 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 dest_path: the path the photo will be exported to
strip: if True, strips leading/trailing white space from resulting template 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 edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
@@ -3240,9 +3256,9 @@ def get_dirnames_from_template(
Args: Args:
photo: a PhotoInstance object photo: a PhotoInstance object
directory: a PhotoTemplate template string, may be None 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 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 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 edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version

View File

@@ -18,8 +18,9 @@ from .utils import normalize_fs_path
__all__ = ["ExportDB_ABC", "ExportDBNoOp", "ExportDB", "ExportDBInMemory"] __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_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()}" 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): def set_detected_text_for_uuid(self, uuid, json_text):
pass pass
@abstractmethod
def set_metadata_for_file(self, filename, metadata):
pass
@abstractmethod
def get_metadata_for_file(self, filename):
pass
@abstractmethod @abstractmethod
def set_data( def set_data(
self, self,
@@ -114,6 +123,7 @@ class ExportDB_ABC(ABC):
edited_stat=None, edited_stat=None,
info_json=None, info_json=None,
exif_json=None, exif_json=None,
metadata=None,
): ):
pass pass
@@ -183,6 +193,12 @@ class ExportDBNoOp(ExportDB_ABC):
def set_detected_text_for_uuid(self, uuid, json_text): def set_detected_text_for_uuid(self, uuid, json_text):
pass pass
def set_metadata_for_file(self, filename, metadata):
pass
def get_metadata_for_file(self, filename):
pass
def set_data( def set_data(
self, self,
filename, filename,
@@ -193,6 +209,7 @@ class ExportDBNoOp(ExportDB_ABC):
edited_stat=None, edited_stat=None,
info_json=None, info_json=None,
exif_json=None, exif_json=None,
metadata=None,
): ):
pass pass
@@ -507,6 +524,39 @@ class ExportDB(ExportDB_ABC):
except Error as e: except Error as e:
logging.warning(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( def set_data(
self, self,
filename, filename,
@@ -517,6 +567,7 @@ class ExportDB(ExportDB_ABC):
edited_stat=None, edited_stat=None,
info_json=None, info_json=None,
exif_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""" """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)) 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 (?, ?);", "INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename_normalized, exif_json), (filename_normalized, exif_json),
) )
if metadata is not None:
c.execute(
"UPDATE files "
+ "SET metadata = ? "
+ "WHERE filepath_normalized = ?;",
(metadata, filename_normalized),
)
conn.commit() conn.commit()
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
@@ -622,7 +682,7 @@ class ExportDB(ExportDB_ABC):
conn = self._get_db_connection(dbfile) conn = self._get_db_connection(dbfile)
if not conn: if not conn:
raise Exception("Error getting connection to database {dbfile}") 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_created = True
self.was_upgraded = () self.was_upgraded = ()
else: else:
@@ -630,9 +690,7 @@ class ExportDB(ExportDB_ABC):
self.was_created = False self.was_created = False
version_info = self._get_database_version(conn) version_info = self._get_database_version(conn)
if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION: if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION:
self._create_db_tables(conn) self._create_or_migrate_db_tables(conn)
if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_FILEPATH:
self._migrate_normalized_filepath(conn)
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION) self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
else: else:
self.was_upgraded = () self.was_upgraded = ()
@@ -664,104 +722,97 @@ class ExportDB(ExportDB_ABC):
).fetchone() ).fetchone()
return (version_info[0], version_info[1]) return (version_info[0], version_info[1])
def _create_db_tables(self, conn): def _create_or_migrate_db_tables(self, conn):
"""create (if not already created) the necessary db tables for the export database """create (if not already created) the necessary db tables for the export database and apply any needed migrations
conn: sqlite3 db connection
Args:
conn: sqlite3 db connection
""" """
sql_commands = { try:
"sql_version_table": """ CREATE TABLE IF NOT EXISTS version ( version = self._get_database_version(conn)
id INTEGER PRIMARY KEY, except Exception as e:
osxphotos TEXT, version = (__version__, OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_TABLES)
exportdb TEXT
); """, # Current for version 4.3, for anything greater, do a migration after creation
"sql_about_table": """ CREATE TABLE IF NOT EXISTS about ( sql_commands = [
id INTEGER PRIMARY KEY, """ CREATE TABLE IF NOT EXISTS version (
about TEXT id INTEGER PRIMARY KEY,
);""", osxphotos TEXT,
"sql_files_table": """ CREATE TABLE IF NOT EXISTS files ( exportdb TEXT
id INTEGER PRIMARY KEY, ); """,
filepath TEXT NOT NULL, """ CREATE TABLE IF NOT EXISTS about (
filepath_normalized TEXT NOT NULL, id INTEGER PRIMARY KEY,
uuid TEXT, about TEXT
orig_mode INTEGER, );""",
orig_size INTEGER, """ CREATE TABLE IF NOT EXISTS files (
orig_mtime REAL, id INTEGER PRIMARY KEY,
exif_mode INTEGER, filepath TEXT NOT NULL,
exif_size INTEGER, filepath_normalized TEXT NOT NULL,
exif_mtime REAL uuid TEXT,
); """, orig_mode INTEGER,
"sql_files_table_migrate": """ CREATE TABLE IF NOT EXISTS files_migrate ( orig_size INTEGER,
id INTEGER PRIMARY KEY, orig_mtime REAL,
filepath TEXT NOT NULL, exif_mode INTEGER,
filepath_normalized TEXT NOT NULL, exif_size INTEGER,
uuid TEXT, exif_mtime REAL
orig_mode INTEGER, ); """,
orig_size INTEGER, """ CREATE TABLE IF NOT EXISTS runs (
orig_mtime REAL, id INTEGER PRIMARY KEY,
exif_mode INTEGER, datetime TEXT,
exif_size INTEGER, python_path TEXT,
exif_mtime REAL, script_name TEXT,
UNIQUE(filepath_normalized) args TEXT,
); """, cwd TEXT
"sql_files_migrate": """ INSERT INTO files_migrate SELECT * FROM files;""", ); """,
"sql_files_drop_tables": """ DROP TABLE files;""", """ CREATE TABLE IF NOT EXISTS info (
"sql_files_alter": """ ALTER TABLE files_migrate RENAME TO files;""", id INTEGER PRIMARY KEY,
"sql_runs_table": """ CREATE TABLE IF NOT EXISTS runs ( uuid text NOT NULL,
id INTEGER PRIMARY KEY, json_info JSON
datetime TEXT, ); """,
python_path TEXT, """ CREATE TABLE IF NOT EXISTS exifdata (
script_name TEXT, id INTEGER PRIMARY KEY,
args TEXT, filepath_normalized TEXT NOT NULL,
cwd TEXT json_exifdata JSON
); """, ); """,
"sql_info_table": """ CREATE TABLE IF NOT EXISTS info ( """ CREATE TABLE IF NOT EXISTS edited (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
uuid text NOT NULL, filepath_normalized TEXT NOT NULL,
json_info JSON mode INTEGER,
); """, size INTEGER,
"sql_exifdata_table": """ CREATE TABLE IF NOT EXISTS exifdata ( mtime REAL
id INTEGER PRIMARY KEY, ); """,
filepath_normalized TEXT NOT NULL, """ CREATE TABLE IF NOT EXISTS converted (
json_exifdata JSON id INTEGER PRIMARY KEY,
); """, filepath_normalized TEXT NOT NULL,
"sql_edited_table": """ CREATE TABLE IF NOT EXISTS edited ( mode INTEGER,
id INTEGER PRIMARY KEY, size INTEGER,
filepath_normalized TEXT NOT NULL, mtime REAL
mode INTEGER, ); """,
size INTEGER, """ CREATE TABLE IF NOT EXISTS sidecar (
mtime REAL id INTEGER PRIMARY KEY,
); """, filepath_normalized TEXT NOT NULL,
"sql_converted_table": """ CREATE TABLE IF NOT EXISTS converted ( sidecar_data TEXT,
id INTEGER PRIMARY KEY, mode INTEGER,
filepath_normalized TEXT NOT NULL, size INTEGER,
mode INTEGER, mtime REAL
size INTEGER, ); """,
mtime REAL """ CREATE TABLE IF NOT EXISTS detected_text (
); """, id INTEGER PRIMARY KEY,
"sql_sidecar_table": """ CREATE TABLE IF NOT EXISTS sidecar ( uuid TEXT NOT NULL,
id INTEGER PRIMARY KEY, text_data JSON
filepath_normalized TEXT NOT NULL, ); """,
sidecar_data TEXT, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
mode INTEGER, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
size INTEGER, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
mtime REAL """ 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);""",
"sql_detected_text_table": """ CREATE TABLE IF NOT EXISTS detected_text ( """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
id INTEGER PRIMARY KEY, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_detected_text on detected_text (uuid);""",
uuid TEXT NOT NULL, ]
text_data JSON # create the tables if needed
); """,
"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: try:
c = conn.cursor() c = conn.cursor()
for cmd in sql_commands.values(): for cmd in sql_commands:
c.execute(cmd) c.execute(cmd)
c.execute( c.execute(
"INSERT INTO version(osxphotos, exportdb) VALUES (?, ?);", "INSERT INTO version(osxphotos, exportdb) VALUES (?, ?);",
@@ -772,6 +823,19 @@ class ExportDB(ExportDB_ABC):
except Error as e: except Error as e:
logging.warning(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): def __del__(self):
"""ensure the database connection is closed""" """ensure the database connection is closed"""
try: try:
@@ -810,6 +874,28 @@ class ExportDB(ExportDB_ABC):
"""Fix all filepath_normalized columns for unicode normalization""" """Fix all filepath_normalized columns for unicode normalization"""
# Prior to database version 4.3, filepath_normalized was not normalized for unicode # Prior to database version 4.3, filepath_normalized was not normalized for unicode
c = conn.cursor() 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"]: for table in ["converted", "edited", "exifdata", "files", "sidecar"]:
old_values = c.execute( old_values = c.execute(
f"SELECT filepath_normalized, id FROM {table}" f"SELECT filepath_normalized, id FROM {table}"
@@ -848,7 +934,7 @@ class ExportDBInMemory(ExportDB):
conn = self._get_db_connection() conn = self._get_db_connection()
if not conn: if not conn:
raise Exception("Error getting connection to in-memory database") 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_created = True
self.was_upgraded = () self.was_upgraded = ()
else: else:
@@ -871,7 +957,7 @@ class ExportDBInMemory(ExportDB):
self.was_created = False self.was_created = False
_, exportdb_ver = self._get_database_version(conn) _, exportdb_ver = self._get_database_version(conn)
if exportdb_ver < OSXPHOTOS_EXPORTDB_VERSION: 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) self.was_upgraded = (exportdb_ver, OSXPHOTOS_EXPORTDB_VERSION)
else: else:
self.was_upgraded = () self.was_upgraded = ()

View File

@@ -83,6 +83,7 @@ class ExportOptions:
export_as_hardlink: (bool, default=False): if True, will hardlink files instead of copying them 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 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
fileutil: (FileUtilABC): class that conforms to FileUtilABC with various file utilities 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_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) 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 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
@@ -128,6 +129,7 @@ class ExportOptions:
export_as_hardlink: bool = False export_as_hardlink: bool = False
export_db: Optional[ExportDB_ABC] = None export_db: Optional[ExportDB_ABC] = None
fileutil: Optional[FileUtil] = None fileutil: Optional[FileUtil] = None
force_update: bool = False
ignore_date_modified: bool = False ignore_date_modified: bool = False
ignore_signature: bool = False ignore_signature: bool = False
increment: bool = True increment: bool = True
@@ -523,7 +525,7 @@ class PhotoExporter:
# the export directory # the export directory
preview_name = ( preview_name = (
preview_name preview_name
if options.overwrite or options.update if any([options.overwrite, options.update, options.force_update])
else pathlib.Path(increment_filename(preview_name)) else pathlib.Path(increment_filename(preview_name))
) )
all_results += self._export_photo( all_results += self._export_photo(
@@ -589,7 +591,7 @@ class PhotoExporter:
# if overwrite==False and #increment==False, export should fail if file exists # if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not any( if dest.exists() and not any(
[options.increment, options.update, options.overwrite] [options.increment, options.update, options.force_update, options.overwrite]
): ):
raise FileExistsError( raise FileExistsError(
f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}" f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}"
@@ -601,11 +603,13 @@ class PhotoExporter:
# e.g. exporting sidecar for file1.png and file1.jpeg # e.g. exporting sidecar for file1.png and file1.jpeg
# if file1.png exists and exporting 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 # 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)) 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 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 export_db = options.export_db
fileutil = options.fileutil fileutil = options.fileutil
# destination exists, check to see if destination is the right UUID # destination exists, check to see if destination is the right UUID
@@ -735,7 +739,7 @@ class PhotoExporter:
# export live_photo .mov file? # export live_photo .mov file?
live_photo = bool(options.live_photo and self.photo.live_photo) 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 # figure out which photo version to request
if options.edited or self.photo.shared: if options.edited or self.photo.shared:
@@ -843,7 +847,7 @@ class PhotoExporter:
# export live_photo .mov file? # export live_photo .mov file?
live_photo = bool(options.live_photo and self.photo.live_photo) 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 edited_version = options.edited or self.photo.shared
# shared photos (in shared albums) show up as not having adjustments (not edited) # 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 # but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
@@ -995,16 +999,11 @@ class PhotoExporter:
fileutil = options.fileutil fileutil = options.fileutil
export_db = options.export_db export_db = options.export_db
if options.update: # updating if options.update or options.force_update: # updating
cmp_touch, cmp_orig = False, False cmp_touch, cmp_orig = False, False
if dest_exists: if dest_exists:
# update, destination exists, but we might not need to replace it... # update, destination exists, but we might not need to replace it...
if options.ignore_signature: if options.exiftool:
cmp_orig = True
cmp_touch = fileutil.cmp(
src, dest, mtime1=int(self.photo.date.timestamp())
)
elif options.exiftool:
sig_exif = export_db.get_stat_exif_for_file(dest_str) sig_exif = export_db.get_stat_exif_for_file(dest_str)
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif) cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
if cmp_orig: if cmp_orig:
@@ -1026,10 +1025,17 @@ class PhotoExporter:
) )
cmp_touch = fileutil.cmp_file_sig(dest_str, sig_converted) cmp_touch = fileutil.cmp_file_sig(dest_str, sig_converted)
else: else:
cmp_orig = fileutil.cmp(src, dest) cmp_orig = options.ignore_signature or fileutil.cmp(src, dest)
cmp_touch = fileutil.cmp( cmp_touch = fileutil.cmp(
src, dest, mtime1=int(self.photo.date.timestamp()) 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
cmp_orig = hexdigest(
self.photo.json()
) == export_db.get_metadata_for_file(dest_str)
sig_cmp = cmp_touch if options.touch_file else cmp_orig sig_cmp = cmp_touch if options.touch_file else cmp_orig
@@ -1043,7 +1049,7 @@ class PhotoExporter:
if sig_edited != (None, None, None) if sig_edited != (None, None, None)
else False 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 ( if (options.export_as_hardlink and dest.samefile(src)) or (
not options.export_as_hardlink not options.export_as_hardlink
@@ -1086,7 +1092,9 @@ class PhotoExporter:
edited_stat = ( edited_stat = (
fileutil.file_sig(src) if options.edited else (None, None, None) 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 # need to remove the destination first
try: try:
fileutil.unlink(dest) fileutil.unlink(dest)
@@ -1129,13 +1137,15 @@ class PhotoExporter:
f"Error copying file {src} to {dest_str}: {e} ({lineno(__file__)})" f"Error copying file {src} to {dest_str}: {e} ({lineno(__file__)})"
) from e ) from e
json_info = self.photo.json()
export_db.set_data( export_db.set_data(
filename=dest_str, filename=dest_str,
uuid=self.photo.uuid, uuid=self.photo.uuid,
orig_stat=fileutil.file_sig(dest_str), orig_stat=fileutil.file_sig(dest_str),
converted_stat=converted_stat, converted_stat=converted_stat,
edited_stat=edited_stat, edited_stat=edited_stat,
info_json=self.photo.json(), info_json=json_info,
metadata=hexdigest(json_info),
) )
return ExportResults( return ExportResults(
@@ -1235,10 +1245,13 @@ class PhotoExporter:
sidecar_filename sidecar_filename
) )
write_sidecar = ( write_sidecar = (
not options.update not (options.update or options.force_update)
or (options.update and not sidecar_filename.exists())
or ( 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) and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig) or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
) )
@@ -1333,8 +1346,8 @@ class PhotoExporter:
def _should_run_exiftool(self, dest, options: ExportOptions) -> bool: def _should_run_exiftool(self, dest, options: ExportOptions) -> bool:
"""Return True if exiftool should be run to update metadata""" """Return True if exiftool should be run to update metadata"""
run_exiftool = not options.update run_exiftool = not (options.update or options.force_update)
if options.update: if options.update or options.force_update:
files_are_different = False files_are_different = False
old_data = options.export_db.get_exifdata_for_file(dest) old_data = options.export_db.get_exifdata_for_file(dest)
if old_data is not None: if old_data is not None:

View File

@@ -1728,7 +1728,8 @@ class PhotoInfo:
if isinstance(o, (datetime.date, datetime.datetime)): if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat() return o.isoformat()
return json.dumps(self.asdict(), sort_keys=True, default=default) dict_data = self.asdict()
return json.dumps(dict_data, sort_keys=True, default=default)
def __eq__(self, other): def __eq__(self, other):
"""Compare two PhotoInfo objects for equality""" """Compare two PhotoInfo objects for equality"""

View File

@@ -40,7 +40,7 @@ else:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def reset_singletons(): def reset_singletons():
""" Need to clean up any ExifTool singletons between tests """ """Need to clean up any ExifTool singletons between tests"""
_ExifToolProc.instance = None _ExifToolProc.instance = None
@@ -73,7 +73,7 @@ def pytest_collection_modifyitems(config, items):
def copy_photos_library(photos_library=TEST_LIBRARY, delay=0): 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( script = AppleScript(
""" """
tell application "Photos" tell application "Photos"
@@ -118,3 +118,9 @@ def copy_photos_library(photos_library=TEST_LIBRARY, delay=0):
@pytest.fixture @pytest.fixture
def addalbum_library(): def addalbum_library():
copy_photos_library(delay=10) 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

View File

@@ -1,10 +1,12 @@
r""" Test the command line interface (CLI) """ """ Test the command line interface (CLI) """
import os import os
import sqlite3
import tempfile import tempfile
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from conftest import copy_photos_library_to_path
import osxphotos import osxphotos
from osxphotos.exiftool import get_exiftool_path from osxphotos.exiftool import get_exiftool_path
@@ -2034,7 +2036,6 @@ def test_export_exiftool_template_change():
assert result.exit_code == 0 assert result.exit_code == 0
assert "updated EXIF data: 1" in result.output assert "updated EXIF data: 1" in result.output
# export with --update, nothing should export # export with --update, nothing should export
result = runner.invoke( result = runner.invoke(
export, export,
@@ -2054,6 +2055,7 @@ def test_export_exiftool_template_change():
assert "exported: 0" in result.output assert "exported: 0" in result.output
assert "updated EXIF data: 0" in result.output assert "updated EXIF data: 0" in result.output
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed") @pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_path(): def test_export_exiftool_path():
"""test --exiftool with --exiftool-path""" """test --exiftool with --exiftool-path"""
@@ -4809,6 +4811,75 @@ def test_export_update_basic():
) )
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
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
)
# 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 again 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
)
@pytest.mark.skipif( @pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ, "OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.", reason="Skip if not running on author's personal library.",
@@ -4931,7 +5002,7 @@ def test_export_update_exiftool():
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert ( 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 in result.output
) )

View File

@@ -7,6 +7,7 @@ import pytest
EXIF_DATA = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "EXIF:ImageDescription": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Title": "Elder Park", "EXIF:GPSLatitude": "34 deg 55' 8.01\" S", "EXIF:GPSLongitude": "138 deg 35' 48.70\" E", "Composite:GPSPosition": "34 deg 55' 8.01\" S, 138 deg 35' 48.70\" E", "EXIF:GPSLatitudeRef": "South", "EXIF:GPSLongitudeRef": "East", "EXIF:DateTimeOriginal": "2017:06:20 17:18:56", "EXIF:OffsetTimeOriginal": "+09:30", "EXIF:ModifyDate": "2020:05:18 14:42:04"}]""" 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"}}""" 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""" 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"}]""" 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"}}""" 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), (10, 11, 12),
INFO_DATA, INFO_DATA,
EXIF_DATA, EXIF_DATA,
METADATA_DATA,
) )
assert db.get_uuid_for_file(filepath2) == "BAR-FOO" assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA 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_converted_for_file(filepath2) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12) assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"]) 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 # test set_data value=None doesn't overwrite existing data
db.set_data( db.set_data(
@@ -84,6 +87,7 @@ def test_export_db():
None, None,
None, None,
None, None,
None,
) )
assert db.get_uuid_for_file(filepath2) == "BAR-FOO" assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA 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_converted_for_file(filepath2) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12) assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"]) assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"])
assert db.get_metadata_for_file(filepath2) == METADATA_DATA
# close and re-open # close and re-open
db.close() db.close()
@@ -107,6 +112,8 @@ def test_export_db():
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12) assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"]) 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 json.loads(db.get_detected_text_for_uuid("FOO-BAR")) == [["foo", 0.5]]
assert db.get_metadata_for_file(filepath2) == METADATA_DATA
# update data # update data
db.set_uuid_for_file(filepath, "FUBAR") 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)) 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_sidecar_for_file(filepath) == (None, (None, None, None))
assert db.get_previous_uuids() == [] assert db.get_previous_uuids() == []
db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]])) db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]]))
assert db.get_detected_text_for_uuid("FOO-BAR") is None 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 # test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg") filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -163,6 +171,7 @@ def test_export_db_no_op():
(10, 11, 12), (10, 11, 12),
INFO_DATA, INFO_DATA,
EXIF_DATA, EXIF_DATA,
METADATA_DATA,
) )
assert db.get_uuid_for_file(filepath2) is None assert db.get_uuid_for_file(filepath2) is None
assert db.get_info_for_uuid("BAR-FOO") 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_converted_for_file(filepath) is None
assert db.get_stat_edited_for_file(filepath) is None assert db.get_stat_edited_for_file(filepath) is None
assert db.get_previous_uuids() == [] assert db.get_previous_uuids() == []
assert db.get_metadata_for_file(filepath) is None
# update data # update data
db.set_uuid_for_file(filepath, "FUBAR") 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)) db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_previous_uuids() == ["FOO-BAR"] assert db.get_previous_uuids() == ["FOO-BAR"]
db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]])) db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]]))
db.set_metadata_for_file(filepath, METADATA_DATA)
db.close() db.close()
dbram = ExportDBInMemory(dbname, tempdir.name) 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_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
assert dbram.get_previous_uuids() == ["FOO-BAR"] assert dbram.get_previous_uuids() == ["FOO-BAR"]
assert json.loads(dbram.get_detected_text_for_uuid("FOO-BAR")) == [["foo", 0.5]] 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 # change a value
dbram.set_uuid_for_file(filepath, "FUBAR") 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_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22)) dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
dbram.set_detected_text_for_uuid("FUBAR", json.dumps([["bar", 0.5]])) 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_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2 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_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
assert dbram.get_previous_uuids() == ["FUBAR"] assert dbram.get_previous_uuids() == ["FUBAR"]
assert json.loads(dbram.get_detected_text_for_uuid("FUBAR")) == [["bar", 0.5]] assert json.loads(dbram.get_detected_text_for_uuid("FUBAR")) == [["bar", 0.5]]
assert dbram.get_metadata_for_file(filepath) == "FUBAR"
dbram.close() dbram.close()
@@ -265,6 +278,7 @@ def test_export_db_in_memory():
assert db.get_info_for_uuid("FUBAR") is None assert db.get_info_for_uuid("FUBAR") is None
assert db.get_detected_text_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(): 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_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22)) dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
dbram.set_detected_text_for_uuid("FUBAR", json.dumps([["bar", 0.5]])) 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_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2 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_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
assert dbram.get_previous_uuids() == ["FUBAR"] assert dbram.get_previous_uuids() == ["FUBAR"]
assert json.loads(dbram.get_detected_text_for_uuid("FUBAR")) == [["bar", 0.5]] assert json.loads(dbram.get_detected_text_for_uuid("FUBAR")) == [["bar", 0.5]]
assert dbram.get_metadata_for_file(filepath) == METADATA_DATA
dbram.close() dbram.close()