Compare commits

...

11 Commits

Author SHA1 Message Date
Rhet Turnbull
235dea329c Implemented #605, refactor out export2 2022-01-29 09:38:52 -08:00
Rhet Turnbull
5afdf6fc20 Fix for #564, --preview with --download-missing 2022-01-29 08:27:43 -08:00
Rhet Turnbull
385059e973 Updated CHANGELOG.md [skip ci] 2022-01-28 23:32:46 -08:00
Rhet Turnbull
62aed02070 Updated docs [skip ci] 2022-01-28 23:20:27 -08:00
Rhet Turnbull
6843b8661d Refactored photoexporter for performance, #591 2022-01-28 23:15:02 -08:00
Rhet Turnbull
9da747ea9d Refactoring to support #591 2022-01-27 21:37:12 -08:00
Rhet Turnbull
22964afc69 Performance improvements and refactoring, #462, partial for #591 2022-01-27 06:28:12 -08:00
Rhet Turnbull
3bc53fd92b Performance improvements, partial for #591 2022-01-25 20:37:58 -08:00
Rhet Turnbull
bd31120569 Version bump 2022-01-24 06:28:58 -08:00
Rhet Turnbull
6af124e4d3 Removed exportdb requirement from PhotoTemplate 2022-01-24 06:20:34 -08:00
Rhet Turnbull
b3b1d8f193 Updated CHANGELOG.md [skip ci] 2022-01-23 22:01:54 -08:00
23 changed files with 636 additions and 513 deletions

View File

@@ -4,6 +4,36 @@ 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.0](https://github.com/RhetTbull/osxphotos/compare/v0.44.13...v0.45.0)
> 28 January 2022
- Performance improvements and refactoring, #462, partial for #591 [`22964af`](https://github.com/RhetTbull/osxphotos/commit/22964afc6988166218413125d7a62348bb858a83)
- Refactored photoexporter for performance, #591 [`6843b86`](https://github.com/RhetTbull/osxphotos/commit/6843b8661d41d42368794c77304fc07194e7af18)
- Performance improvements, partial for #591 [`3bc53fd`](https://github.com/RhetTbull/osxphotos/commit/3bc53fd92b3222c6959e7aa12310811db41b83fe)
#### [v0.44.13](https://github.com/RhetTbull/osxphotos/compare/v0.44.12...v0.44.13)
> 24 January 2022
- Removed exportdb requirement from PhotoTemplate [`6af124e`](https://github.com/RhetTbull/osxphotos/commit/6af124e4d3a0e26c48f435452920020cd42afa1c)
- Version bump [`bd31120`](https://github.com/RhetTbull/osxphotos/commit/bd3112056920806f565be2c0c12caf4f2aff5231)
#### [v0.44.12](https://github.com/RhetTbull/osxphotos/compare/v0.44.11...v0.44.12)
> 23 January 2022
- Added query options to repl, #597 [`7855801`](https://github.com/RhetTbull/osxphotos/commit/785580115b29f5ccb895de22be1243f56dbb43dc)
- Added run command, #598 [`b4bd04c`](https://github.com/RhetTbull/osxphotos/commit/b4bd04c1461d0b427937f541403305bc979bcf4f)
- Bug fix for get_photos_library_version [`e88c6b8`](https://github.com/RhetTbull/osxphotos/commit/e88c6b8a59dfd947f6cf3c7eac9c92519ab781a3)
#### [v0.44.11](https://github.com/RhetTbull/osxphotos/compare/v0.44.10...v0.44.11)
> 23 January 2022
- creat unit test for __all__ [`#599`](https://github.com/RhetTbull/osxphotos/pull/599)
- Performance improvements, added --profile [`7486823`](https://github.com/RhetTbull/osxphotos/commit/74868238f3b1ee18feb744f137f5c14ef8e36ffc)
#### [v0.44.10](https://github.com/RhetTbull/osxphotos/compare/v0.44.9...v0.44.10)
> 22 January 2022

104
README.md
View File

@@ -38,6 +38,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
+ [Raw Photos](#raw-photos)
+ [Template System](#template-system)
+ [ExifTool](#exiftoolExifTool)
+ [PhotoExporter](#photoexporter)
+ [Text Detection](#textdetection)
+ [Utility Functions](#utility-functions)
* [Examples](#examples)
@@ -1723,7 +1724,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.44.12'
{osxphotos_version} The osxphotos version, e.g. '0.45.0'
{osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for
@@ -3627,7 +3628,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.44.12'|
|{osxphotos_version}|The osxphotos version, e.g. '0.45.0'|
|{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|
@@ -3711,6 +3712,105 @@ osxphotos.exiftool also provides an `ExifToolCaching` class which caches all met
`ExifTool()` runs `exiftool` as a subprocess using the `-stay_open True` flag to keep the process running in the background. The subprocess will be cleaned up when your main script terminates. `ExifTool()` uses a singleton pattern to ensure that only one instance of `exiftool` is created. Multiple instances of `ExifTool()` will all use the same `exiftool` subprocess.
### <a name="photoexporter">PhotoExporter</a>
[PhotoInfo.export()](#photoinfo) provides a simple method to export a photo. This method actually calls `PhotoExporter.export()` to do the export. `PhotoExporter` provides many more options to configure the export and report results and this is what the osxphotos command line export tools uses.
#### `export(dest, filename=None, options: Optional[ExportOptions]=None) -> ExportResults`
Export a photo.
Args:
- dest: must be valid destination path or exception raised
- filename: (optional): name of exported picture; if not provided, will use current filename
- options (ExportOptions): optional ExportOptions instance
Returns: ExportResults instance
*Note*: to use dry run mode, you must set options.dry_run=True and also pass in memory version of export_db, and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) in options.export_db and options.fileutil respectively.
#### `ExportOptions`
Options class for exporting photos with `export`
Attributes:
- 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
- download_missing: (bool, default=False): if True will attempt to export photo via applescript interaction with Photos if missing (see also use_photokit, use_photos_export)
- dry_run: (bool, default=False): set to True to run in "dry run" mode
- edited: (bool, default=False): if True will export the edited version of the photo otherwise exports the original version
- exiftool_flags (list of str): optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
- 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
- fileutil: (FileUtilABC): class that conforms to FileUtilABC with various file utilities
- 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
- jpeg_ext (str): if set, will use this value for extension on jpegs converted to jpeg with convert_to_jpeg; if not set, uses jpeg; do not include the leading "."
- 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 str): list of template strings that will be rendered as used as keywords
- live_photo (bool, default=False): if True, will also export the associated .mov for live photos
- location (bool): if True, include location in exported metadata
- merge_exif_keywords (bool): if True, merged keywords found in file's exif data (requires exiftool)
- merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool)
- overwrite (bool, default=False): if True will overwrite files if they already exist
- persons (bool): if True, include persons in exported metadata
- preview_suffix (str): optional string to append to end of filename for preview images
- preview (bool): if True, also exports preview image
- raw_photo (bool, default=False): if True, will also export the associated RAW photo
- render_options (RenderOptions): optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
- replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive
- sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
- sidecar: bit field (int): set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
- SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
- SIDECAR_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
- SIDECAR_XMP: if set will write an XMP sidecar with IPTC data sidecar filename will be dest/filename.xmp
- strip (bool): if True, strip whitespace from rendered templates
- timeout (int, default=120): timeout in seconds used with use_photos_export
- touch_file (bool, default=False): if True, sets file's modification time upon photo date
- update (bool, default=False): if True export will run in update mode, that is, it will not export the photo if the current version already exists in the destination
- use_albums_as_keywords (bool, default = False): if True, will include album names in keywords when exporting metadata with exiftool or sidecar
- use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar
- use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
- use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
- verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
#### `ExportResults`
`PhotoExporter().export()` returns an instance of this class.
`ExportResults` has the following properties:
- exported: list of all exported files (A single call to export could export more than one file, e.g. original file, preview, live video, raw, etc.)
- new: list of new files exported when used with update=True
- updated: list of updated files when used with update=True
- skipped: list of skipped files when used with update=True
- exif_updated: list of updated files when used with update=True and exiftool
- touched: list of files touched during export (e.g. file date/time updated with touch_file=True)
- to_touch: Reserved for internal use of export
- converted_to_jpeg: list of files converted to jpeg when convert_to_jpeg=True
- sidecar_json_written: list of JSON sidecars written
- sidecar_json_skipped: list of JSON sidecars skipped when update=True
- sidecar_exiftool_written: list of exiftool sidecars written
- sidecar_exiftool_skipped: list of exiftool sidecars skipped when update=True
- sidecar_xmp_written: list of XMP sidecars written
- sidecar_xmp_skipped: list of XMP sidecars skipped when update=True
- missing: list of missing files
- error: list of tuples containing (filename, error) if error generated during export
- exiftool_warning: list of warnings generated by exiftool during export
- exiftool_error: list of errors generated by exiftool during export
- xattr_written: list of files with extended attributes written during export
- xattr_skipped: list of files where extended attributes were skipped when update=True
- deleted_files: reserved for use by osxphotos CLI
- deleted_directories: reserved for use by osxphotos CLI
- exported_album: reserved for use by osxphotos CLI
- skipped_album: reserved for use by osxphotos CLI
- missing_album: reserved for use by osxphotos CLI
### <a name="textdetection">Text Detection</a>
The [PhotoInfo.detected_text()](#detected_text_method) and the `{detected_text}` template will perform text detection on the photos in your library. Text detection is a slow process so to avoid unnecessary re-processing of photos, osxphotos will cache the results of the text detection process as an extended attribute on the photo image file. Extended attributes do not modify the actual file. The extended attribute is named `osxphotos.metadata:detected_text` and can be viewed using the built-in [xattr](https://ss64.com/osx/xattr.html) command or my [osxmetadata](https://github.com/RhetTbull/osxmetadata) tool. If you want to remove the cached attribute, you can do so with osxmetadata as follows:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.44.12"
__version__ = "0.45.2"

View File

@@ -79,7 +79,6 @@ from .sqlgrep import sqlgrep
from .uti import get_preferred_uti_extension
from .utils import (
expand_and_validate_filepath,
list_directory,
load_function,
normalize_fs_path,
)
@@ -2032,7 +2031,6 @@ def export(
finder_tag_template=finder_tag_template,
strip=strip,
export_dir=dest,
export_db=export_db,
)
results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped)
@@ -2044,7 +2042,6 @@ def export(
xattr_template,
strip=strip,
export_dir=dest,
export_db=export_db,
)
results.xattr_written.extend(xattr_written)
results.xattr_skipped.extend(xattr_skipped)
@@ -2958,7 +2955,7 @@ def _render_suffix_template(
return ""
try:
options = RenderOptions(filename=True, export_dir=dest, exportdb=export_db)
options = RenderOptions(filename=True, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(suffix_template, options)
except ValueError as e:
raise click.BadOptionUsage(
@@ -3028,7 +3025,7 @@ def export_photo_to_directory(
"""Export photo to directory dest_path"""
results = ExportResults()
# TODO: can be updated to let export2 do all the missing logic
# 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 ""
@@ -3073,9 +3070,7 @@ def export_photo_to_directory(
results.missing.append(str(pathlib.Path(dest_path) / filename))
return results
render_options = RenderOptions(
export_dir=export_dir, dest_path=dest_path, exportdb=export_db
)
render_options = RenderOptions(export_dir=export_dir, dest_path=dest_path)
tries = 0
while tries <= retry:
@@ -3118,7 +3113,7 @@ def export_photo_to_directory(
verbose=verbose_,
)
exporter = PhotoExporter(photo)
export_results = exporter.export2(
export_results = exporter.export(
dest=dest_path, filename=filename, options=export_options
)
for warning_ in export_results.exiftool_warning:
@@ -3215,7 +3210,6 @@ def get_filenames_from_template(
edited_version=edited,
export_dir=export_dir,
dest_path=dest_path,
exportdb=export_db,
)
filenames, unmatched = photo.render_template(filename_template, options)
except ValueError as e:
@@ -3281,9 +3275,7 @@ def get_dirnames_from_template(
elif directory:
# got a directory template, render it and check results are valid
try:
options = RenderOptions(
dirname=True, edited_version=edited, exportdb=export_db
)
options = RenderOptions(dirname=True, edited_version=edited)
dirnames, unmatched = photo.render_template(directory, options)
except ValueError as e:
raise click.BadOptionUsage(
@@ -3569,7 +3561,6 @@ def write_finder_tags(
finder_tag_template=None,
strip=False,
export_dir=None,
export_db=None,
):
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
@@ -3583,7 +3574,6 @@ def write_finder_tags(
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
finder_tag_template: list of templates to evaluate for determining Finder tags
export_dir: value to use for {export_dir} template
export_db: an ExportDB object
Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
@@ -3615,7 +3605,6 @@ def write_finder_tags(
none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/",
export_dir=export_dir,
exportdb=export_db,
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
@@ -3663,7 +3652,6 @@ def write_extended_attributes(
xattr_template,
strip=False,
export_dir=None,
export_db=None,
):
"""Writes extended attributes to exported files
@@ -3671,7 +3659,6 @@ def write_extended_attributes(
photo: a PhotoInfo object
strip: xattr_template: list of tuples: (attribute name, attribute template)
export_dir: value to use for {export_dir} template
exportdb: an ExportDB object
Returns:
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
@@ -3681,10 +3668,7 @@ def write_extended_attributes(
for xattr, template_str in xattr_template:
try:
options = RenderOptions(
none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/",
export_dir=export_dir,
exportdb=export_db,
none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/", export_dir=export_dir
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
@@ -3751,9 +3735,7 @@ def run_post_command(
# some categories, like error, return a tuple of (file, error str)
if isinstance(f, tuple):
f = f[0]
render_options = RenderOptions(
export_dir=export_dir, filepath=f, exportdb=export_db
)
render_options = RenderOptions(export_dir=export_dir, filepath=f)
template = PhotoTemplate(photo, exiftool_path=exiftool_path)
command, _ = template.render(command_template, options=render_options)
command = command[0] if command else None

View File

@@ -16,7 +16,7 @@ from ._version import __version__
__all__ = ["ExportDB_ABC", "ExportDBNoOp", "ExportDB", "ExportDBInMemory"]
OSXPHOTOS_EXPORTDB_VERSION = "4.0"
OSXPHOTOS_EXPORTDB_VERSION = "4.2"
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
@@ -104,12 +104,12 @@ class ExportDB_ABC(ABC):
self,
filename,
uuid,
orig_stat,
exif_stat,
converted_stat,
edited_stat,
info_json,
exif_json,
orig_stat=None,
exif_stat=None,
converted_stat=None,
edited_stat=None,
info_json=None,
exif_json=None,
):
pass
@@ -183,12 +183,12 @@ class ExportDBNoOp(ExportDB_ABC):
self,
filename,
uuid,
orig_stat,
exif_stat,
converted_stat,
edited_stat,
info_json,
exif_json,
orig_stat=None,
exif_stat=None,
converted_stat=None,
edited_stat=None,
info_json=None,
exif_json=None,
):
pass
@@ -506,52 +506,65 @@ class ExportDB(ExportDB_ABC):
self,
filename,
uuid,
orig_stat,
exif_stat,
converted_stat,
edited_stat,
info_json,
exif_json,
orig_stat=None,
exif_stat=None,
converted_stat=None,
edited_stat=None,
info_json=None,
exif_json=None,
):
"""sets all the data for file and uuid at once"""
"""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_normalized = filename.lower()
conn = self._conn
try:
c = conn.cursor()
# update files table (if needed);
# this statement works around fact that there was no unique constraint on files.filepath_normalized
c.execute(
"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
"""INSERT OR IGNORE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);""",
(filename, filename_normalized, uuid),
)
c.execute(
"UPDATE files "
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*orig_stat, filename_normalized),
)
c.execute(
"UPDATE files "
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*exif_stat, filename_normalized),
)
c.execute(
"INSERT OR REPLACE INTO converted(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
(filename_normalized, *converted_stat),
)
c.execute(
"INSERT OR REPLACE INTO edited(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
(filename_normalized, *edited_stat),
)
c.execute(
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
(uuid, info_json),
)
c.execute(
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename_normalized, exif_json),
)
if orig_stat is not None:
c.execute(
"UPDATE files "
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*orig_stat, filename_normalized),
)
if exif_stat is not None:
c.execute(
"UPDATE files "
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*exif_stat, filename_normalized),
)
if converted_stat is not None:
c.execute(
"INSERT OR REPLACE INTO converted(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
(filename_normalized, *converted_stat),
)
if edited_stat is not None:
c.execute(
"INSERT OR REPLACE INTO edited(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
(filename_normalized, *edited_stat),
)
if info_json is not None:
c.execute(
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
(uuid, info_json),
)
if exif_json is not None:
c.execute(
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename_normalized, exif_json),
)
conn.commit()
except Error as e:
logging.warning(e)
@@ -662,6 +675,22 @@ class ExportDB(ExportDB_ABC):
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,

View File

@@ -181,7 +181,6 @@ class FileUtilMacOS(FileUtilABC):
return False
s1 = cls._sig(os.stat(f1))
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False
return s1 == s2

View File

@@ -1,7 +1,6 @@
""" PhotoExport class to export photos
"""
# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
import dataclasses
import glob
@@ -14,7 +13,7 @@ import re
import tempfile
from collections import namedtuple # pylint: disable=syntax-error
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Callable, List, Optional
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
import photoscript
from mako.template import Template
@@ -72,7 +71,7 @@ class ExportError(Exception):
@dataclass
class ExportOptions:
"""Options class for exporting photos with export2
"""Options class for exporting photos with export
Attributes:
convert_to_jpeg (bool): if True, converts non-jpeg images to jpeg
@@ -212,7 +211,7 @@ class StagedFiles:
class ExportResults:
"""Results class which holds export results for export2"""
"""Results class which holds export results for export"""
def __init__(
self,
@@ -222,6 +221,7 @@ class ExportResults:
skipped=None,
exif_updated=None,
touched=None,
to_touch=None,
converted_to_jpeg=None,
sidecar_json_written=None,
sidecar_json_skipped=None,
@@ -247,6 +247,7 @@ class ExportResults:
self.skipped = skipped or []
self.exif_updated = exif_updated or []
self.touched = touched or []
self.to_touch = to_touch or []
self.converted_to_jpeg = converted_to_jpeg or []
self.sidecar_json_written = sidecar_json_written or []
self.sidecar_json_skipped = sidecar_json_skipped or []
@@ -298,6 +299,7 @@ class ExportResults:
self.skipped += other.skipped
self.exif_updated += other.exif_updated
self.touched += other.touched
self.to_touch += other.to_touch
self.converted_to_jpeg += other.converted_to_jpeg
self.sidecar_json_written += other.sidecar_json_written
self.sidecar_json_skipped += other.sidecar_json_skipped
@@ -326,6 +328,7 @@ class ExportResults:
+ f",skipped={self.skipped}"
+ f",exif_updated={self.exif_updated}"
+ f",touched={self.touched}"
+ f",to_touch={self.to_touch}"
+ f",converted_to_jpeg={self.converted_to_jpeg}"
+ f",sidecar_json_written={self.sidecar_json_written}"
+ f",sidecar_json_skipped={self.sidecar_json_skipped}"
@@ -357,136 +360,15 @@ class PhotoExporter:
prefix=f"osxphotos_photo_exporter_{self.photo.uuid}_"
)
self._temp_dir_path = pathlib.Path(self._temp_dir.name)
self.fileutil = FileUtil
def export(
self,
dest,
filename=None,
edited=False,
live_photo=False,
raw_photo=False,
export_as_hardlink=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_exiftool=False,
sidecar_xmp=False,
download_missing=False,
use_photos_export=False,
use_photokit=True,
timeout=120,
exiftool=False,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
render_options: Optional[RenderOptions] = None,
):
"""export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they already exist
increment: (boolean, 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
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
sidecar_xmp: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via AppleScript or PhotoKit interaction with Photos
download_missing: (boolean, default=False); if True will attempt to export photo via AppleScript or PhotoKit interaction with Photos if missing
use_photokit: (boolean, default=True); if True will attempt to export photo via photokit instead of AppleScript when used with use_photos_export or download_missing
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer
Returns: list of photos exported
"""
# Implementation note: calls export2 to actually do the work
sidecar = 0
if sidecar_json:
sidecar |= SIDECAR_JSON
if sidecar_exiftool:
sidecar |= SIDECAR_EXIFTOOL
if sidecar_xmp:
sidecar |= SIDECAR_XMP
if not filename:
if not edited:
filename = self.photo.original_filename
else:
original_name = pathlib.Path(self.photo.original_filename)
if self.photo.path_edited:
ext = pathlib.Path(self.photo.path_edited).suffix
else:
uti = (
self.photo.uti_edited
if edited and self.photo.uti_edited
else self.photo.uti
)
ext = get_preferred_uti_extension(uti)
ext = "." + ext
filename = original_name.stem + "_edited" + ext
options = ExportOptions(
description_template=description_template,
download_missing=download_missing,
edited=edited,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
increment=increment,
keyword_template=keyword_template,
live_photo=live_photo,
overwrite=overwrite,
raw_photo=raw_photo,
render_options=render_options,
sidecar=sidecar,
timeout=timeout,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
use_photokit=use_photokit,
use_photos_export=use_photos_export,
)
results = self.export2(
dest,
filename=filename,
options=options,
)
return results.exported
def export2(
self,
dest,
filename=None,
options: Optional[ExportOptions] = None,
):
"""export photo, like export but with update and dry_run options
) -> ExportResults:
"""export photo
Args:
dest: must be valid destination path or exception raised
@@ -520,11 +402,10 @@ class PhotoExporter:
# when called from export(), won't get an export_db, so use no-op version
options.export_db = options.export_db or ExportDBNoOp()
export_db = options.export_db
# ensure there's a FileUtil class to use
options.fileutil = options.fileutil or FileUtil
fileutil = options.fileutil
self.fileutil = options.fileutil
self._render_options = options.render_options or RenderOptions()
@@ -551,87 +432,24 @@ class PhotoExporter:
dest = pathlib.Path(dest) / filename
# Is there something to convert with convert_to_jpeg?
if options.convert_to_jpeg and self.photo.isphoto:
something_to_convert = False
ext = "." + options.jpeg_ext if options.jpeg_ext else ".jpeg"
if export_original and self.photo.uti_original != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
something_to_convert = True
dest = dest.parent / f"{dest.stem}{ext}"
if export_edited and self.photo.uti != "public.jpeg":
# in Big Sur+, edited HEICs are HEIC
something_to_convert = True
dest = dest.parent / f"{dest.stem}{ext}"
convert_to_jpeg = something_to_convert
else:
convert_to_jpeg = False
options = dataclasses.replace(options, convert_to_jpeg=convert_to_jpeg)
dest, options = self._should_convert_to_jpeg(dest, options)
dest, _ = self._validate_dest_path(
dest,
increment=options.increment,
update=options.update,
overwrite=options.overwrite,
)
dest = pathlib.Path(dest)
# stage files for export by finding path in local library or downloading from iCloud as appropriate
staged_files = self._stage_photos_for_export(options)
src = staged_files.edited if options.edited else staged_files.original
# get the right destination path depending on options.update, etc.
dest = self._get_dest_path(src, dest, options)
self._render_options.filepath = str(dest)
all_results = ExportResults()
staged_files = self._stage_photos_for_export(options)
src = staged_files.edited if options.edited else staged_files.original
if src:
# found source now try to find right destination
if options.update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
dest_uuid = self.photo.uuid
export_db.set_data(
filename=dest,
uuid=self.photo.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.photo.json(),
exif_json=None,
)
if dest_uuid != self.photo.uuid:
# not the right file, find the right one
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
dest_files = glob.glob(glob_str)
for file_ in dest_files:
dest_uuid = export_db.get_uuid_for_file(file_)
if dest_uuid == self.photo.uuid:
dest = pathlib.Path(file_)
break
elif dest_uuid is None and fileutil.cmp(src, file_):
# files match, update the UUID
dest = pathlib.Path(file_)
export_db.set_data(
filename=dest,
uuid=self.photo.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.photo.json(),
exif_json=None,
)
break
else:
# increment the destination file
dest = pathlib.Path(increment_filename(dest))
# export the dest file
results = self._export_photo(
all_results += self._export_photo(
src,
dest,
options=options,
)
all_results += results
# copy live photo associated .mov if requested
if (
@@ -642,13 +460,12 @@ class PhotoExporter:
):
live_name = dest.parent / f"{dest.stem}.mov"
src_live = staged_files.original_live
results = self._export_photo(
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),
)
all_results += results
if (
export_edited
@@ -658,26 +475,23 @@ class PhotoExporter:
):
live_name = dest.parent / f"{dest.stem}.mov"
src_live = staged_files.edited_live
results = self._export_photo(
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),
)
all_results += results
# 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}"
if raw_path is not None:
results = self._export_photo(
raw_path,
raw_name,
options=options,
)
all_results += results
all_results += self._export_photo(
raw_path,
raw_name,
options=options,
)
# copy preview image if requested
if options.preview and staged_files.preview:
@@ -696,44 +510,35 @@ class PhotoExporter:
if options.overwrite or options.update
else pathlib.Path(increment_filename(preview_name))
)
if preview_path is not None:
results = self._export_photo(
preview_path,
preview_name,
options=options,
)
all_results += results
results = self._write_sidecar_files(dest=dest, options=options)
all_results += results
# if exiftool, write the metadata
if options.exiftool:
exif_files = (
all_results.new + all_results.updated + all_results.skipped
if options.update
else all_results.exported
all_results += self._export_photo(
preview_path,
preview_name,
options=options,
)
for exported_file in exif_files:
results = self._write_exif_metadata_to_files(
exported_file=exported_file, options=options
)
all_results += results
all_results += self._write_sidecar_files(dest=dest, options=options)
if options.touch_file:
for exif_file in all_results.exif_updated:
verbose(f"Updating file modification time for {exif_file}")
all_results.touched.append(exif_file)
ts = int(self.photo.date.timestamp())
fileutil.utime(exif_file, (ts, ts))
all_results.touched = list(set(all_results.touched))
all_results += self._touch_files(all_results, options)
return all_results
def _touch_files(
self, results: ExportResults, options: ExportOptions
) -> ExportResults:
"""touch file date/time to match photo creation date/time"""
fileutil = options.fileutil
touch_files = set(results.to_touch)
touch_results = ExportResults()
for touch_file in touch_files:
ts = int(self.photo.date.timestamp())
fileutil.utime(touch_file, (ts, ts))
touch_results.touched.append(touch_file)
return touch_results
def _get_edited_filename(self, original_filename):
"""Return the filename for the exported edited photo
(used when filename isn't provided in call to export2)"""
(used when filename isn't provided in call to export)"""
# need to get the right extension for edited file
original_filename = pathlib.Path(original_filename)
if self.photo.path_edited:
@@ -745,34 +550,78 @@ class PhotoExporter:
edited_filename = original_filename.stem + "_edited" + ext
return edited_filename
def _validate_dest_path(self, dest, increment, update, overwrite, count=0):
"""If destination exists, add (1), (2), and so on to filename to get a valid destination
def _get_dest_path(
self, src: str, dest: pathlib.Path, options: ExportOptions
) -> pathlib.Path:
"""If destination exists find match in ExportDB, on disk, or add (1), (2), and so on to filename to get a valid destination
Args:
dest (str): Destination path
increment (bool): Whether to increment the filename if it already exists
update (bool): Whether running in update mode
overwrite (bool): Whether running in overwrite mode
count: optional counter to start from (if 0, start from 1)
src (str): source file path
dest (str): destination path
options (ExportOptions): Export options
Returns:
new dest path (pathlib.Path), increment count (int)
new dest path (pathlib.Path)
"""
# check to see if file exists and if so, add (1), (2), etc until we find one that works
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not any(
[options.increment, options.update, options.overwrite]
):
raise FileExistsError(
f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}"
)
# if not update or overwrite, check to see if file exists and if so, add (1), (2), etc
# until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# 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 increment and not update and not overwrite:
dest, count = increment_filename_with_count(dest, count=count)
dest = pathlib.Path(dest)
if options.increment and not options.update and not options.overwrite:
return pathlib.Path(increment_filename(dest))
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and all([not x for x in [increment, update, overwrite]]):
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
return dest, count
# 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:
export_db = options.export_db
fileutil = options.fileutil
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
dest_uuid = self.photo.uuid
export_db.set_data(
filename=dest,
uuid=self.photo.uuid,
orig_stat=fileutil.file_sig(dest),
info_json=self.photo.json(),
)
if dest_uuid != self.photo.uuid:
# not the right file, find the right one
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
# TODO: use the normalized code in utils
dest_files = glob.glob(glob_str)
for file_ in dest_files:
dest_uuid = export_db.get_uuid_for_file(file_)
if dest_uuid == self.photo.uuid:
dest = pathlib.Path(file_)
break
elif dest_uuid is None and fileutil.cmp(src, file_):
# files match, update the UUID
dest = pathlib.Path(file_)
export_db.set_data(
filename=dest,
uuid=self.photo.uuid,
orig_stat=fileutil.file_sig(dest),
info_json=self.photo.json(),
)
break
else:
# increment the destination file
dest = pathlib.Path(increment_filename(dest))
# either dest was updated in the if clause above or not updated at all
return dest
def _stage_photos_for_export(self, options: ExportOptions) -> StagedFiles:
"""Stages photos for export
@@ -810,12 +659,26 @@ class PhotoExporter:
if options.live_photo and self.photo.live_photo:
staged.edited_live = self.photo.path_edited_live_photo
if options.exiftool and not options.dry_run and not options.export_as_hardlink:
# copy files to temp dir for exiftool to process before export
# not needed for download_missing or use_photokit as those files already staged to temp dir
for file_type in [
"raw",
"preview",
"original",
"original_live",
"edited",
"edited_live",
]:
staged_file = getattr(staged, file_type)
if staged_file:
setattr(staged, file_type, self._copy_to_temp_file(staged_file))
# download any missing files
if options.download_missing:
live_photo = staged.edited_live if options.edited else staged.original_live
missing_options = ExportOptions(
edited=options.edited,
# TODO: missing previews are not generated/downloaded
preview=options.preview and not staged.preview,
raw_photo=options.raw_photo and not staged.raw,
live_photo=options.live_photo and not live_photo,
@@ -931,6 +794,9 @@ class PhotoExporter:
except Exception as e:
results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
if options.preview and self.photo.path_derivatives:
results.preview = self.photo.path_derivatives[0]
return results
def _stage_photo_for_export_with_applescript(
@@ -1010,13 +876,48 @@ class PhotoExporter:
if results_attr:
setattr(results, results_attr, exported_file)
if options.preview and self.photo.path_derivatives:
results.preview = self.photo.path_derivatives[0]
return results
def _should_convert_to_jpeg(
self, dest: pathlib.Path, options: ExportOptions
) -> Tuple[pathlib.Path, ExportOptions]:
"""Determine if a file really should be converted to jpeg or not
and return the new destination and ExportOptions instance with the convert_to_jpeg flag set appropriately
"""
if not (options.convert_to_jpeg and self.photo.isphoto):
# nothing to convert
return dest, dataclasses.replace(options, convert_to_jpeg=False)
convert_to_jpeg = False
ext = "." + options.jpeg_ext if options.jpeg_ext else ".jpeg"
if not options.edited and self.photo.uti_original != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
convert_to_jpeg = True
dest = dest.parent / f"{dest.stem}{ext}"
elif options.edited and self.photo.uti != "public.jpeg":
# in Big Sur+, edited HEICs are HEIC
convert_to_jpeg = True
dest = dest.parent / f"{dest.stem}{ext}"
return dest, dataclasses.replace(options, convert_to_jpeg=convert_to_jpeg)
def _is_temp_file(self, filepath: str) -> bool:
"""Returns True if file is in the PhotosExporter temp directory otherwise False"""
filepath = pathlib.Path(filepath)
return filepath.parent == self._temp_dir_path
def _copy_to_temp_file(self, filepath: str) -> str:
"""Copies filepath to a temp file preserving access and modification times"""
filepath = pathlib.Path(filepath)
dest = self._temp_dir_path / filepath.name
dest = increment_filename(dest)
self.fileutil.copy(filepath, dest)
stat = os.stat(filepath)
self.fileutil.utime(dest, (stat.st_atime, stat.st_mtime))
return str(dest)
def _export_photo(
self,
src,
@@ -1027,7 +928,9 @@ class PhotoExporter:
Does the actual copy or hardlink taking the appropriate
action depending on update, overwrite, export_as_hardlink
Assumes destination is the right destination (e.g. UUID matches)
sets UUID and JSON info for exported file using set_uuid_for_file, set_info_for_uuid
Sets UUID and JSON info for exported file using set_uuid_for_file, set_info_for_uuid
Expects that src is a temporary file (as set by _stage_photos_for_export) and
may modify the src (e.g. for convert_to_jpeg or exiftool)
Args:
src (str): src path
@@ -1052,9 +955,12 @@ class PhotoExporter:
exported_files = []
update_updated_files = []
update_new_files = []
update_skipped_files = []
update_skipped_files = [] # skip files that are already up to date
touched_files = []
converted_to_jpeg_files = []
exif_results = ExportResults()
converted_stat = None
edited_stat = None
dest_str = str(dest)
dest_exists = dest.exists()
@@ -1144,8 +1050,9 @@ class PhotoExporter:
sig = (sig[0], sig[1], int(self.photo.date.timestamp()))
if not fileutil.cmp_file_sig(src, sig):
touched_files.append(dest_str)
if not update_skipped_files:
converted_stat = (None, None, None)
# have file to export
edited_stat = (
fileutil.file_sig(src) if options.edited else (None, None, None)
)
@@ -1164,14 +1071,27 @@ class PhotoExporter:
raise ExportError(
f"Error hardlinking {src} to {dest}: {e} ({lineno(__file__)})"
) from e
elif options.convert_to_jpeg:
# use convert_to_jpeg to export the file
fileutil.convert_to_jpeg(
src, dest_str, compression_quality=options.jpeg_quality
)
converted_stat = fileutil.file_sig(dest_str)
converted_to_jpeg_files.append(dest_str)
else:
if options.convert_to_jpeg:
# use convert_to_jpeg to export the file
# convert to a temp file before copying
tmp_file = increment_filename(
self._temp_dir_path
/ f"{pathlib.Path(src).stem}_converted_to_jpeg.jpeg"
)
fileutil.convert_to_jpeg(
src, tmp_file, compression_quality=options.jpeg_quality
)
src = tmp_file
converted_stat = fileutil.file_sig(tmp_file)
converted_to_jpeg_files.append(dest_str)
if options.exiftool:
# if exiftool, write the metadata
exif_results = self._write_exif_metadata_to_file(
src, dest, options=options
)
try:
fileutil.copy(src, dest_str)
except Exception as e:
@@ -1179,28 +1099,26 @@ class PhotoExporter:
f"Error copying file {src} to {dest_str}: {e} ({lineno(__file__)})"
) from e
export_db.set_data(
filename=dest_str,
uuid=self.photo.uuid,
orig_stat=fileutil.file_sig(dest_str),
exif_stat=(None, None, None),
converted_stat=converted_stat,
edited_stat=edited_stat,
info_json=self.photo.json(),
exif_json=None,
)
if touched_files:
ts = int(self.photo.date.timestamp())
fileutil.utime(dest, (ts, ts))
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(),
)
return ExportResults(
converted_to_jpeg=converted_to_jpeg_files,
error=exif_results.error,
exif_updated=exif_results.exif_updated,
exiftool_error=exif_results.exiftool_error,
exiftool_warning=exif_results.exiftool_warning,
exported=exported_files + update_new_files + update_updated_files,
new=update_new_files,
updated=update_updated_files,
skipped=update_skipped_files,
touched=touched_files,
converted_to_jpeg=converted_to_jpeg_files,
to_touch=touched_files,
updated=update_updated_files,
)
def _write_sidecar_files(
@@ -1318,21 +1236,34 @@ class PhotoExporter:
sidecar_xmp_skipped=sidecar_xmp_files_skipped,
)
def _write_exif_metadata_to_files(
def _write_exif_metadata_to_file(
self,
exported_file: str,
src,
dest,
options: ExportOptions,
) -> ExportResults:
"""Write exif metadata to files using exiftool."""
"""Write exif metadata to file using exiftool
Note: this method modifies src so src must be a copy of the original file;
it also does not write to dest (dest is the intended destination for purposes of
referencing the export database. This allows the exiftool update to be done on the
local machine prior to being copied to the export destination which may be on a
network drive or other slower external storage."""
export_db = options.export_db
fileutil = options.fileutil
verbose = options.verbose or self._verbose
results = ExportResults()
exiftool_results = ExportResults()
# 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
current_data = "foo"
if options.update:
files_are_different = False
old_data = export_db.get_exifdata_for_file(exported_file)
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))[
@@ -1344,44 +1275,31 @@ class PhotoExporter:
if old_data is None or files_are_different:
# didn't have old data, assume we need to write it
# or files were different
verbose(f"Writing metadata with exiftool for {exported_file}")
if not options.dry_run:
warning_, error_ = self._write_exif_data(
exported_file, options=options
)
if warning_:
results.exiftool_warning.append((exported_file, warning_))
if error_:
results.exiftool_error.append((exported_file, error_))
results.error.append((exported_file, error_))
export_db.set_exifdata_for_file(
exported_file, self._exiftool_json_sidecar(options=options)
)
export_db.set_stat_exif_for_file(
exported_file, fileutil.file_sig(exported_file)
)
results.exif_updated.append(exported_file)
run_exiftool = True
else:
verbose(f"Skipped up to date exiftool metadata for {exported_file}")
else:
verbose(f"Writing metadata with exiftool for {exported_file}")
if not options.dry_run:
warning_, error_ = self._write_exif_data(exported_file, options=options)
if warning_:
results.exiftool_warning.append((exported_file, warning_))
if error_:
results.exiftool_error.append((exported_file, error_))
results.error.append((exported_file, error_))
verbose(
f"Skipped up to date exiftool metadata for {pathlib.Path(dest).name}"
)
export_db.set_exifdata_for_file(
exported_file, self._exiftool_json_sidecar(options=options)
if run_exiftool:
verbose(f"Writing metadata with exiftool for {pathlib.Path(dest).name}")
if not options.dry_run:
warning_, error_ = self._write_exif_data(src, options=options)
if warning_:
exiftool_results.exiftool_warning.append((dest, warning_))
if error_:
exiftool_results.exiftool_error.append((dest, error_))
exiftool_results.error.append((dest, error_))
export_db.set_data(
dest,
uuid=self.photo.uuid,
exif_stat=fileutil.file_sig(src),
exif_json=self._exiftool_json_sidecar(options=options),
)
export_db.set_stat_exif_for_file(
exported_file, fileutil.file_sig(exported_file)
)
results.exif_updated.append(exported_file)
return results
exiftool_results.exif_updated.append(dest)
exiftool_results.to_touch.append(dest)
return exiftool_results
def _write_exif_data(self, filepath: str, options: ExportOptions):
"""write exif data to image file at filepath

View File

@@ -35,6 +35,9 @@ from ._constants import (
BURST_KEY,
BURST_NOT_SELECTED,
BURST_SELECTED,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
TEXT_DETECTION_CONFIDENCE_THRESHOLD,
)
from .adjustmentsinfo import AdjustmentsInfo
@@ -43,7 +46,7 @@ from .exifinfo import ExifInfo
from .exiftool import ExifToolCaching, get_exiftool_path
from .momentinfo import MomentInfo
from .personinfo import FaceInfo, PersonInfo
from .photoexporter import PhotoExporter
from .photoexporter import ExportOptions, PhotoExporter
from .phototemplate import PhotoTemplate, RenderOptions
from .placeinfo import PlaceInfo4, PlaceInfo5
from .query_builder import get_query
@@ -1490,28 +1493,48 @@ class PhotoInfo:
"""
exporter = PhotoExporter(self)
return exporter.export(
dest=dest,
filename=filename,
sidecar = 0
if sidecar_json:
sidecar |= SIDECAR_JSON
if sidecar_exiftool:
sidecar |= SIDECAR_EXIFTOOL
if sidecar_xmp:
sidecar |= SIDECAR_XMP
if not filename:
if not edited:
filename = self.original_filename
else:
original_name = pathlib.Path(self.original_filename)
if self.path_edited:
ext = pathlib.Path(self.path_edited).suffix
else:
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
ext = "." + ext
filename = original_name.stem + "_edited" + ext
options = ExportOptions(
description_template=description_template,
edited=edited,
live_photo=live_photo,
raw_photo=raw_photo,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
increment=increment,
sidecar_json=sidecar_json,
sidecar_exiftool=sidecar_exiftool,
sidecar_xmp=sidecar_xmp,
use_photos_export=use_photos_export,
timeout=timeout,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
increment=increment,
keyword_template=keyword_template,
live_photo=live_photo,
overwrite=overwrite,
raw_photo=raw_photo,
render_options=render_options,
sidecar=sidecar,
timeout=timeout,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
render_options=render_options,
use_photos_export=use_photos_export,
)
results = exporter.export(dest, filename=filename, options=options)
return results.exported
def _get_album_uuids(self, project=False):
"""Return list of album UUIDs this photo is found in

View File

@@ -17,7 +17,6 @@ from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD
from ._version import __version__
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifToolCaching
from .export_db import ExportDB_ABC, ExportDBInMemory
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .text_detection import detect_text
from .utils import expand_and_validate_filepath, load_function
@@ -300,7 +299,6 @@ class RenderOptions:
dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
quote: quote path templates for execution in the shell
exportdb: ExportDB object
"""
none_str: str = "_"
@@ -315,7 +313,6 @@ class RenderOptions:
dest_path: Optional[str] = None
filepath: Optional[str] = None
quote: bool = False
exportdb: Optional[ExportDB_ABC] = None
class PhotoTemplateParser:
@@ -384,9 +381,6 @@ class PhotoTemplate:
self.filepath = options.filepath
self.quote = options.quote
self.dest_path = options.dest_path
self.exportdb = options.exportdb or ExportDBInMemory(
None, self.export_dir or "."
)
def render(
self,
@@ -420,7 +414,6 @@ class PhotoTemplate:
self.filepath = options.filepath
self.quote = options.quote
self.dest_path = options.dest_path
self.exportdb = options.exportdb or self.exportdb
try:
model = self.parser.parse(template)
@@ -1216,7 +1209,7 @@ class PhotoTemplate:
else:
values = list(obj)
elif field == "detected_text":
values = _get_detected_text(self.photo, self.exportdb, confidence=subfield)
values = _get_detected_text(self.photo, confidence=subfield)
else:
raise ValueError(f"Unhandled template value: {field}")
@@ -1459,7 +1452,7 @@ def _get_album_by_path(photo, folder_album_path):
return None
def _get_detected_text(photo, exportdb, confidence=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
def _get_detected_text(photo, confidence=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
"""Returns the detected text for a photo
{detected_text} uses this instead of PhotoInfo.detected_text() to cache the text for all confidence values
"""
@@ -1475,5 +1468,4 @@ def _get_detected_text(photo, exportdb, confidence=TEXT_DETECTION_CONFIDENCE_THR
# _detected_text caches the text detection results in an extended attribute
# so the first time this gets called is slow but repeated accesses are fast
detected_text = photo._detected_text()
exportdb.set_detected_text_for_uuid(photo.uuid, json.dumps(detected_text))
return [text for text, conf in detected_text if conf >= confidence]

View File

@@ -20,7 +20,7 @@ from typing import Callable, List, Union
import CoreFoundation
import objc
from Foundation import NSFileManager, NSString
from Foundation import NSFileManager, NSPredicate, NSString
from ._constants import UNICODE_FORMAT
@@ -265,7 +265,7 @@ def list_photo_libraries():
# On older MacOS versions, mdfind appears to ignore some libraries
# glob to find libraries in ~/Pictures then mdfind to find all the others
# TODO: make this more robust
lib_list = glob.glob(f"{str(pathlib.Path.home())}/Pictures/*.photoslibrary")
lib_list = glob.glob(f"{pathlib.Path.home()}/Pictures/*.photoslibrary")
# On older OS, may not get all libraries so make sure we get the last one
last_lib = get_last_library_path()
@@ -284,35 +284,32 @@ def list_photo_libraries():
def normalize_fs_path(path: str) -> str:
"""Normalize filesystem paths with unicode in them"""
with objc.autorelease_pool():
normalized_path = NSString.fileSystemRepresentation(path)
return normalized_path.decode("utf8")
# macOS HFS+ uses NFD, APFS doesn't normalize but stick with NFD
# ref: https://eclecticlight.co/2021/05/08/explainer-unicode-normalization-and-apfs/
return unicodedata.normalize("NFD", path)
def findfiles(pattern, path_):
"""Returns list of filenames from path_ matched by pattern
def findfiles(pattern, path):
"""Returns list of filenames from path matched by pattern
shell pattern. Matching is case-insensitive.
If 'path_' is invalid/doesn't exist, returns []."""
if not os.path.isdir(path_):
if not os.path.isdir(path):
return []
# See: https://gist.github.com/techtonik/5694830
# paths need to be normalized for unicode as filesystem returns unicode in NFD form
pattern = normalize_fs_path(pattern)
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
files = list_directory(path_)
files = os.listdir(path)
return [name for name in files if rule.match(name)]
def list_directory(directory_path: str) -> List[str]:
"""List directory contents using NSFileManager"""
"""[[NSFileManager defaultManager] contentsOfDirectoryAtPath:@"directoryName" error:nil]"""
with objc.autorelease_pool():
manager = NSFileManager.defaultManager()
contents, error = manager.contentsOfDirectoryAtPath_error_(directory_path, None)
if error:
raise OSError(f"Error listing directory {directory_path}: {error}")
return [str(path) for path in contents]
def list_directory_startswith(directory_path: str, startswith: str) -> List[str]:
"""List directory contents and return list of files starting with startswith; returns [] if directory doesn't exist"""
if not os.path.isdir(directory_path):
return []
startswith = normalize_fs_path(startswith)
files = [normalize_fs_path(f) for f in os.listdir(directory_path)]
return [f for f in files if f.startswith(startswith)]
def _open_sql_file(dbname):
@@ -353,44 +350,16 @@ def _db_is_locked(dbname):
return locked
# OSXPHOTOS_XATTR_UUID = "com.osxphotos.uuid"
# def get_uuid_for_file(filepath):
# """ returns UUID associated with an exported file
# filepath: path to exported photo
# """
# attr = xattr.xattr(filepath)
# try:
# uuid_bytes = attr[OSXPHOTOS_XATTR_UUID]
# uuid_str = uuid_bytes.decode('utf-8')
# except KeyError:
# uuid_str = None
# return uuid_str
# def set_uuid_for_file(filepath, uuid):
# """ sets the UUID associated with an exported file
# filepath: path to exported photo
# uuid: uuid string for photo
# """
# if not os.path.exists(filepath):
# raise FileNotFoundError(f"Missing file: {filepath}")
# attr = xattr.xattr(filepath)
# uuid_bytes = bytes(uuid, 'utf-8')
# attr.set(OSXPHOTOS_XATTR_UUID, uuid_bytes)
def normalize_unicode(value):
"""normalize unicode data"""
if value is not None:
if isinstance(value, (tuple, list)):
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
elif isinstance(value, str):
return unicodedata.normalize(UNICODE_FORMAT, value)
else:
return value
else:
if value is None:
return None
if isinstance(value, (tuple, list)):
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
elif isinstance(value, str):
return unicodedata.normalize(UNICODE_FORMAT, value)
else:
return value
def increment_filename_with_count(
@@ -411,7 +380,7 @@ def increment_filename_with_count(
Note: This obviously is subject to race condition so using with caution.
"""
dest = filepath if isinstance(filepath, pathlib.Path) else pathlib.Path(filepath)
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = list_directory_startswith(str(dest.parent), dest.stem)
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = f"{dest.stem} ({count})" if count else dest.stem
dest_new = normalize_fs_path(dest_new)

View File

@@ -4293,7 +4293,7 @@ def test_export_deleted_only_2():
def test_export_error(monkeypatch):
"""Test that export catches errors thrown by export2"""
"""Test that export catches errors thrown by export"""
# Note: I often comment out the try/except block in cli.py::export_photo_with_template when
# debugging to see exactly where the error is
# this test verifies I've re-enabled that code
@@ -4307,7 +4307,7 @@ def test_export_error(monkeypatch):
def throw_error(*args, **kwargs):
raise ValueError("Argh!")
monkeypatch.setattr(osxphotos.PhotoExporter, "export2", throw_error)
monkeypatch.setattr(osxphotos.PhotoExporter, "export", throw_error)
with runner.isolated_filesystem():
result = runner.invoke(
export,
@@ -6871,6 +6871,77 @@ def test_export_download_missing_file_exists():
assert "skipped: 1" in result.output
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",
)
def test_export_download_missing_preview():
"""test --download-missing --preview, #564"""
import glob
import os
import os.path
import pathlib
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_RHET),
".",
"-V",
"--uuid",
UUID_DOWNLOAD_MISSING,
"--download-missing",
"--use-photos-export",
"--use-photokit",
"--preview",
],
)
assert result.exit_code == 0
assert "exported: 2" in result.output
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",
)
def test_export_download_missing_preview_applesccript():
"""test --download-missing --preview and applescript download, #564"""
import glob
import os
import os.path
import pathlib
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_RHET),
".",
"-V",
"--uuid",
UUID_DOWNLOAD_MISSING,
"--download-missing",
"--use-photos-export",
"--preview",
],
)
assert result.exit_code == 0
assert "exported: 2" in result.output
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",

View File

@@ -40,7 +40,7 @@ def test_export_convert_raw_to_jpeg(photosdb):
photos = photosdb.photos(uuid=[UUID_DICT["raw"]])
export_options = ExportOptions(convert_to_jpeg=True)
results = PhotoExporter(photos[0]).export2(dest, options=export_options)
results = PhotoExporter(photos[0]).export(dest, options=export_options)
got_dest = pathlib.Path(results.exported[0])
assert got_dest.is_file()
@@ -58,7 +58,7 @@ def test_export_convert_heic_to_jpeg(photosdb):
photos = photosdb.photos(uuid=[UUID_DICT["heic"]])
export_options = ExportOptions(convert_to_jpeg=True)
results = PhotoExporter(photos[0]).export2(dest, options=export_options)
results = PhotoExporter(photos[0]).export(dest, options=export_options)
got_dest = pathlib.Path(results.exported[0])
assert got_dest.is_file()
@@ -86,7 +86,7 @@ def test_export_convert_live_heic_to_jpeg():
photo = photosdb.get_photo(UUID_LIVE_HEIC)
export_options = ExportOptions(convert_to_jpeg=True, live_photo=True)
results = PhotoExporter(photo).export2(dest, options=export_options)
results = PhotoExporter(photo).export(dest, options=export_options)
for name in NAMES_LIVE_HEIC:
assert f"{tempdir.name}/{name}" in results.exported

View File

@@ -74,6 +74,26 @@ 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"])
# test set_data value=None doesn't overwrite existing data
db.set_data(
filepath2,
"BAR-FOO",
None,
None,
None,
None,
None,
None,
)
assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA
assert db.get_exifdata_for_file(filepath2) == EXIF_DATA
assert db.get_stat_orig_for_file(filepath2) == (1, 2, 3)
assert db.get_stat_exif_for_file(filepath2) == (4, 5, 6)
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"])
# close and re-open
db.close()
db = ExportDB(dbname, tempdir.name)

View File

@@ -93,7 +93,7 @@ def test_exportresults_iadd():
def test_all_files():
""" test ExportResults.all_files() """
"""test ExportResults.all_files()"""
results = ExportResults()
for x in EXPORT_RESULT_ATTRIBUTES:
setattr(results, x, [f"{x}1"])
@@ -106,13 +106,3 @@ def test_all_files():
assert sorted(
results.all_files() + results.deleted_files + results.deleted_directories
) == sorted([f"{x}1" for x in EXPORT_RESULT_ATTRIBUTES])
def test_str():
""" test ExportResults.__str__ """
results = ExportResults()
assert (
str(results)
== "ExportResults(exported=[],new=[],updated=[],skipped=[],exif_updated=[],touched=[],converted_to_jpeg=[],sidecar_json_written=[],sidecar_json_skipped=[],sidecar_exiftool_written=[],sidecar_exiftool_skipped=[],sidecar_xmp_written=[],sidecar_xmp_skipped=[],missing=[],error=[],exiftool_warning=[],exiftool_error=[],deleted_files=[],deleted_directories=[],exported_album=[],skipped_album=[],missing_album=[])"
)

View File

@@ -41,7 +41,7 @@ def test_sidecar_xmp(photosdb):
dest = tempdir.name
photo = photosdb.get_photo(uuid)
export_options = ExportOptions(sidecar=SIDECAR_XMP)
PhotoExporter(photo).export2(
PhotoExporter(photo).export(
dest, photo.original_filename, options=export_options
)
filepath = str(pathlib.Path(dest) / photo.original_filename)