Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05e7be14e | ||
|
|
e27c40c772 | ||
|
|
e752f3c7a7 | ||
|
|
6f4cab6721 | ||
|
|
2d899ef045 | ||
|
|
4f17c8fb23 | ||
|
|
173a0fce28 | ||
|
|
b04ea8174d | ||
|
|
e40ecc45ad | ||
|
|
277b1614b9 | ||
|
|
88099de688 | ||
|
|
7d81b94c16 | ||
|
|
d627cfc4fa | ||
|
|
bf208bbe4b | ||
|
|
79ba6f813f | ||
|
|
141c0244e4 | ||
|
|
7e0276beb7 | ||
|
|
1bf11b0414 | ||
|
|
c23f3fc5e4 | ||
|
|
016297d2ff | ||
|
|
aa64283b55 | ||
|
|
3973c27238 | ||
|
|
2e32d62237 | ||
|
|
d497b94ad5 | ||
|
|
8c09ae82a4 | ||
|
|
632169f277 | ||
|
|
675371f0d7 | ||
|
|
7e2d09bf12 | ||
|
|
28c681aa96 | ||
|
|
5d39aa92df | ||
|
|
b4dbad5e74 | ||
|
|
b1b099257f | ||
|
|
63e8410841 | ||
|
|
2e1c91cd67 | ||
|
|
391b0a577b | ||
|
|
1d26ac9630 | ||
|
|
03b4f59549 | ||
|
|
9aa3ac3640 | ||
|
|
6339e3c70e | ||
|
|
4cc3220287 | ||
|
|
f32c4f4acd | ||
|
|
aba2ce0923 | ||
|
|
c209ceae2e | ||
|
|
94ac2bd04e | ||
|
|
d1b1d20bcf | ||
|
|
fb723fb8b7 | ||
|
|
fc7c61b11b | ||
|
|
a73db3a1bb | ||
|
|
d2dcbaaec4 | ||
|
|
08147e91d9 | ||
|
|
d034605784 |
@@ -222,6 +222,25 @@
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mkirkland4874",
|
||||
"name": "mkirkland4874",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36466711?v=4",
|
||||
"profile": "https://github.com/mkirkland4874",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"example"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jcommisso07",
|
||||
"name": "Joseph Commisso",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3111054?v=4",
|
||||
"profile": "https://github.com/jcommisso07",
|
||||
"contributions": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -4,6 +4,97 @@ 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.42.64](https://github.com/RhetTbull/osxphotos/compare/v0.42.63...v0.42.64)
|
||||
|
||||
> 18 July 2021
|
||||
|
||||
- Pass dest_path to template function via RenderOptions, enable implementation of #496 [`2d899ef`](https://github.com/RhetTbull/osxphotos/commit/2d899ef0453c0800ff9b9d374b2b7db0948688fe)
|
||||
|
||||
#### [v0.42.63](https://github.com/RhetTbull/osxphotos/compare/v0.42.62...v0.42.63)
|
||||
|
||||
> 18 July 2021
|
||||
|
||||
- Added album_sort_order example [`b04ea81`](https://github.com/RhetTbull/osxphotos/commit/b04ea8174d049d9f3783aac6bbc397ed71584965)
|
||||
- Updated README.md [skip ci] [`88099de`](https://github.com/RhetTbull/osxphotos/commit/88099de688bcb6a1ddcad6c340833f1627aff268)
|
||||
- Added RenderOptions to {function} template, #496 [`173a0fc`](https://github.com/RhetTbull/osxphotos/commit/173a0fce28e91177dec114d0dba001adfb76834a)
|
||||
|
||||
#### [v0.42.62](https://github.com/RhetTbull/osxphotos/compare/v0.42.61...v0.42.62)
|
||||
|
||||
> 16 July 2021
|
||||
|
||||
- Upgraded osxmetadata to add new extended attributes [`7d81b94`](https://github.com/RhetTbull/osxphotos/commit/7d81b94c16623d11312aaf1b0c47fb580d01bc66)
|
||||
- Updated tutorial with --regex example [skip ci] [`bf208bb`](https://github.com/RhetTbull/osxphotos/commit/bf208bbe4b965a2d39fc1836335b7b65f402af30)
|
||||
- Update README.md [`d627cfc`](https://github.com/RhetTbull/osxphotos/commit/d627cfc4fa22497769babc3d686393c6043d1f37)
|
||||
|
||||
#### [v0.42.61](https://github.com/RhetTbull/osxphotos/compare/v0.42.60...v0.42.61)
|
||||
|
||||
> 7 July 2021
|
||||
|
||||
- Added --selected, closes #489 [`#489`](https://github.com/RhetTbull/osxphotos/issues/489)
|
||||
|
||||
#### [v0.42.60](https://github.com/RhetTbull/osxphotos/compare/v0.42.59...v0.42.60)
|
||||
|
||||
> 6 July 2021
|
||||
|
||||
- docs: add mkirkland4874 as a contributor for example [`#492`](https://github.com/RhetTbull/osxphotos/pull/492)
|
||||
- Updated README.md [skip ci], closes #488 [`#488`](https://github.com/RhetTbull/osxphotos/issues/488)
|
||||
- Added example for {function} template [`016297d`](https://github.com/RhetTbull/osxphotos/commit/016297d2ffcf2e8db0d659ccfe7411ecff3dd41b)
|
||||
- Fixed cleanup to delete empty folders, #491 [`1bf11b0`](https://github.com/RhetTbull/osxphotos/commit/1bf11b0414a7fcf785c792b98f6231821bdad4d4)
|
||||
|
||||
#### [v0.42.59](https://github.com/RhetTbull/osxphotos/compare/v0.42.58...v0.42.59)
|
||||
|
||||
> 4 July 2021
|
||||
|
||||
- Re-enabled try/except in cli export [`d497b94`](https://github.com/RhetTbull/osxphotos/commit/d497b94ad506bf6cf044bbabe7fcbf4ab9d5b9e7)
|
||||
- Added test for try/except block in cli export [`2e32d62`](https://github.com/RhetTbull/osxphotos/commit/2e32d62237f59b16a9be422104347d6a1332865c)
|
||||
|
||||
#### [v0.42.58](https://github.com/RhetTbull/osxphotos/compare/v0.42.57...v0.42.58)
|
||||
|
||||
> 4 July 2021
|
||||
|
||||
- Added --preview-if-missing, #446 [`632169f`](https://github.com/RhetTbull/osxphotos/commit/632169f2774558ef8487eb7fb9323aecbadedd88)
|
||||
|
||||
#### [v0.42.57](https://github.com/RhetTbull/osxphotos/compare/v0.42.54...v0.42.57)
|
||||
|
||||
> 4 July 2021
|
||||
|
||||
- Refactored export2, #485, #486 [`28c681a`](https://github.com/RhetTbull/osxphotos/commit/28c681aa96874588bc59335b2a0db3b8be6eabaa)
|
||||
- Added --preview, #470 [`7e2d09b`](https://github.com/RhetTbull/osxphotos/commit/7e2d09bf123428c09a669d8d581e1a35e374273d)
|
||||
- Fixed path_derivatives to always return jpeg if photo is a photo [`b4dbad5`](https://github.com/RhetTbull/osxphotos/commit/b4dbad5e7451447480699105fb62b157dce8195d)
|
||||
|
||||
#### [v0.42.54](https://github.com/RhetTbull/osxphotos/compare/v0.42.52...v0.42.54)
|
||||
|
||||
> 2 July 2021
|
||||
|
||||
- Removed _applescript, #461 [`1d26ac9`](https://github.com/RhetTbull/osxphotos/commit/1d26ac9630dd0a414c01cc4f89a080e4efd7fd97)
|
||||
- Removed _applescript, #461 [`03b4f59`](https://github.com/RhetTbull/osxphotos/commit/03b4f59549de54da91c36feba613d69f9e86e47b)
|
||||
- Added get_selected() to REPL [`2e1c91c`](https://github.com/RhetTbull/osxphotos/commit/2e1c91cd672eefe84063933437e5d691f5ad1db1)
|
||||
|
||||
#### [v0.42.52](https://github.com/RhetTbull/osxphotos/compare/v0.42.51...v0.42.52)
|
||||
|
||||
> 2 July 2021
|
||||
|
||||
- docs: add jcommisso07 as a contributor for data [`#483`](https://github.com/RhetTbull/osxphotos/pull/483)
|
||||
- docs: add mkirkland4874 as a contributor for bug [`#482`](https://github.com/RhetTbull/osxphotos/pull/482)
|
||||
- Fix for path_raw when file is reference, #480 [`4cc3220`](https://github.com/RhetTbull/osxphotos/commit/4cc322028790b3beefce42af5e35c23976b1a35a)
|
||||
- Updated README.md [skip ci] [`6339e3c`](https://github.com/RhetTbull/osxphotos/commit/6339e3c70ee174394af356710de4bf9442bad9fc)
|
||||
|
||||
#### [v0.42.51](https://github.com/RhetTbull/osxphotos/compare/v0.42.46...v0.42.51)
|
||||
|
||||
> 30 June 2021
|
||||
|
||||
- Alpha support for Monterey/macOS 12 [`08147e9`](https://github.com/RhetTbull/osxphotos/commit/08147e91d92013c9cd179187a447f81bc08de3af)
|
||||
- Refactored UTI utils to get ready for Monterey [`d034605`](https://github.com/RhetTbull/osxphotos/commit/d0346057843aae3a72a79695819df31385db596f)
|
||||
- Updated photokit code to work with raw+jpeg, #478 [`a73db3a`](https://github.com/RhetTbull/osxphotos/commit/a73db3a1bbc2a320d68dcf7f31f1074bc23a242a)
|
||||
|
||||
#### [v0.42.46](https://github.com/RhetTbull/osxphotos/compare/v0.42.45...v0.42.46)
|
||||
|
||||
> 23 June 2021
|
||||
|
||||
- Bug fix for template functions #477 [`4931758`](https://github.com/RhetTbull/osxphotos/commit/49317582c4582e291463d368425513b09a799058)
|
||||
- Updated README.md [skip ci] [`64fd852`](https://github.com/RhetTbull/osxphotos/commit/64fd85253508b51c3f945f4c8ff02585f1b90aab)
|
||||
- Fixed deprecation warning [`3fbfc55`](https://github.com/RhetTbull/osxphotos/commit/3fbfc55e84756844070f4080ce415ba77d5c7665)
|
||||
|
||||
#### [v0.42.45](https://github.com/RhetTbull/osxphotos/compare/v0.42.44...v0.42.45)
|
||||
|
||||
> 20 June 2021
|
||||
|
||||
2
build.sh
2
build.sh
@@ -3,7 +3,7 @@
|
||||
# script to help build osxphotos release
|
||||
# this is unique to my own dev setup
|
||||
|
||||
activate osxphotos
|
||||
source venv/bin/activate
|
||||
rm -rf dist; rm -rf build
|
||||
python3 utils/update_readme.py
|
||||
(cd docsrc && make github && make pdf)
|
||||
|
||||
129
examples/album_sort_order.py
Normal file
129
examples/album_sort_order.py
Normal file
@@ -0,0 +1,129 @@
|
||||
""" Example function for use with osxphotos export --post-function option showing how to record album sort order """
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Optional
|
||||
|
||||
from osxphotos import ExportResults, PhotoInfo
|
||||
from osxphotos.albuminfo import AlbumInfo
|
||||
from osxphotos.path_utils import sanitize_dirname
|
||||
from osxphotos.phototemplate import RenderOptions
|
||||
|
||||
|
||||
def _get_album_sort_order(album: AlbumInfo, photo: PhotoInfo) -> Optional[int]:
|
||||
"""Get the sort order of photo in album
|
||||
|
||||
Returns: sort order as int or None if photo not found in album
|
||||
"""
|
||||
# get the album sort order from the album_info
|
||||
sort_order = 0 # change this to 1 if you want counting to start at 1
|
||||
for album_photo in album.photos:
|
||||
if album_photo.uuid == photo.uuid:
|
||||
# found the photo we're processing
|
||||
break
|
||||
sort_order += 1
|
||||
else:
|
||||
# didn't find the photo, so skip this file
|
||||
return None
|
||||
return sort_order
|
||||
|
||||
|
||||
def album_sequence(photo: PhotoInfo, options: RenderOptions, **kwargs) -> str:
|
||||
"""Call this with {function} template to get album sequence (sort order) when exporting with {folder_album} template
|
||||
|
||||
For example, calling this template function like the following prepends sequence#_ to each exported file if the file is in an album:
|
||||
|
||||
osxphotos export /path/to/export -V --directory "{folder_album}" --filename "{album?{function:examples/album_sort_order.py::album_sequence}_,}{original_name}"
|
||||
|
||||
The sequence will start at 0. To change the sequence to start at a different offset (e.g. 1), set the environment variable OSXPHOTOS_ALBUM_SEQUENCE_START=1 (or whatever offset you want)
|
||||
"""
|
||||
dest_path = options.dest_path
|
||||
if not dest_path:
|
||||
return ""
|
||||
|
||||
album_info = None
|
||||
for album in photo.album_info:
|
||||
# following code is how {folder_album} builds the folder path
|
||||
folder = "/".join(sanitize_dirname(f) for f in album.folder_names)
|
||||
folder += "/" + sanitize_dirname(album.title)
|
||||
if dest_path.endswith(folder):
|
||||
album_info = album
|
||||
break
|
||||
else:
|
||||
# didn't find the album, so skip this file
|
||||
return ""
|
||||
start_index = int(os.getenv("OSXPHOTOS_ALBUM_SEQUENCE_START", 0))
|
||||
return str(album_info.photo_index(photo) + start_index)
|
||||
|
||||
|
||||
def album_sort_order(
|
||||
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
|
||||
):
|
||||
"""Call this with osxphotos export /path/to/export --post-function post_function.py::post_function
|
||||
This will get called immediately after the photo has been exported
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo instance for the photo that's just been exported
|
||||
results: ExportResults instance with information about the files associated with the exported photo
|
||||
verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed)
|
||||
**kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions
|
||||
|
||||
Notes:
|
||||
Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag
|
||||
Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color
|
||||
Will not be called if --dry-run flag is enabled
|
||||
Will be called immediately after export and before any --post-command commands are executed
|
||||
"""
|
||||
|
||||
# ExportResults has the following properties
|
||||
# fields with filenames contain the full path to the file
|
||||
# exported: list of all files exported
|
||||
# new: list of all new files exported (--update)
|
||||
# updated: list of all files updated (--update)
|
||||
# skipped: list of all files skipped (--update)
|
||||
# exif_updated: list of all files that were updated with --exiftool
|
||||
# touched: list of all files that had date updated with --touch-file
|
||||
# converted_to_jpeg: list of files converted to jpeg with --convert-to-jpeg
|
||||
# sidecar_json_written: list of all JSON sidecar files written
|
||||
# sidecar_json_skipped: list of all JSON sidecar files skipped (--update)
|
||||
# sidecar_exiftool_written: list of all exiftool sidecar files written
|
||||
# sidecar_exiftool_skipped: list of all exiftool sidecar files skipped (--update)
|
||||
# sidecar_xmp_written: list of all XMP sidecar files written
|
||||
# sidecar_xmp_skipped: list of all XMP sidecar files skipped (--update)
|
||||
# missing: list of all missing files
|
||||
# error: list tuples of (filename, error) for any errors generated during export
|
||||
# exiftool_warning: list of tuples of (filename, warning) for any warnings generated by exiftool with --exiftool
|
||||
# exiftool_error: list of tuples of (filename, error) for any errors generated by exiftool with --exiftool
|
||||
# xattr_written: list of files that had extended attributes written
|
||||
# xattr_skipped: list of files that where extended attributes were skipped (--update)
|
||||
# deleted_files: list of deleted files
|
||||
# deleted_directories: list of deleted directories
|
||||
# exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album
|
||||
# skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album
|
||||
# missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album
|
||||
|
||||
for filepath in results.exported:
|
||||
# do your processing here
|
||||
filepath = pathlib.Path(filepath)
|
||||
album_dir = filepath.parent.name
|
||||
if album_dir not in photo.albums:
|
||||
return
|
||||
|
||||
# get the first album that matches this name of which the photo is a member
|
||||
album_info = None
|
||||
for album in photo.album_info:
|
||||
if album.title == album_dir:
|
||||
album_info = album
|
||||
break
|
||||
else:
|
||||
# didn't find the album, so skip this file
|
||||
return
|
||||
|
||||
sort_order = _get_album_sort_order(album_info, photo)
|
||||
if sort_order is None:
|
||||
# didn't find the photo, so skip this file
|
||||
return
|
||||
|
||||
verbose(f"Sort order for {filepath} in album {album_dir} is {sort_order}")
|
||||
with open(str(filepath) + "_sort_order.txt", "w") as f:
|
||||
f.write(str(sort_order))
|
||||
173
examples/export_template.py
Normal file
173
examples/export_template.py
Normal file
@@ -0,0 +1,173 @@
|
||||
""" Example showing how to use a custom function for osxphotos {function} template
|
||||
to export photos in a folder structure similar to Photos' own structure
|
||||
|
||||
Use: osxphotos export /path/to/export --directory "{function:/path/to/export_template.py::photos_folders}"
|
||||
|
||||
This will likely export multiple copies of each photo. If using APFS file system, this should be
|
||||
a non-issue as osxphotos will use copy-on-write so each exported photo doesn't take up additional space
|
||||
unless you edit the photo.
|
||||
|
||||
Thank-you @mkirkland4874 for the inspiration for this example!
|
||||
|
||||
This will produce output similar to this:
|
||||
|
||||
Library
|
||||
- Photos
|
||||
-- {created.year}
|
||||
---- {created.mm}
|
||||
------ {created.dd}
|
||||
- Favorites
|
||||
- Hidden
|
||||
- Recently Deleted
|
||||
- People
|
||||
- Places
|
||||
- Imports
|
||||
Media Types
|
||||
- Videos
|
||||
- Selfies
|
||||
- Portrait
|
||||
- Panoramas
|
||||
- Time-lapse
|
||||
- Slow-mo
|
||||
- Bursts
|
||||
- Screenshots
|
||||
My Albums
|
||||
-- Album 1
|
||||
-- Album 2
|
||||
-- Folder 1
|
||||
---- Album 3
|
||||
Shared Albums
|
||||
-- Shared Album 1
|
||||
-- Shared Album 2
|
||||
"""
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.datetime_formatter import DateTimeFormatter
|
||||
from osxphotos.path_utils import sanitize_dirname
|
||||
from osxphotos.phototemplate import RenderOptions
|
||||
|
||||
|
||||
def place_folder(photo: osxphotos.PhotoInfo) -> str:
|
||||
"""Return places as folder in format Country/State/City/etc."""
|
||||
if not photo.place:
|
||||
return ""
|
||||
|
||||
places = []
|
||||
if photo.place.names.country:
|
||||
places.append(photo.place.names.country[0])
|
||||
|
||||
if photo.place.names.state_province:
|
||||
places.append(photo.place.names.state_province[0])
|
||||
|
||||
if photo.place.names.sub_administrative_area:
|
||||
places.append(photo.place.names.sub_administrative_area[0])
|
||||
|
||||
if photo.place.names.additional_city_info:
|
||||
places.append(photo.place.names.additional_city_info[0])
|
||||
|
||||
if photo.place.names.area_of_interest:
|
||||
places.append(photo.place.names.area_of_interest[0])
|
||||
|
||||
if places:
|
||||
return "Library/Places/" + "/".join(sanitize_dirname(place) for place in places)
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def photos_folders(photo: osxphotos.PhotoInfo, options: osxphotos.phototemplate.RenderOptions, **kwargs) -> Union[List, str]:
|
||||
"""template function for use with --directory to export photos in a folder structure similar to Photos
|
||||
|
||||
Args:
|
||||
photo: osxphotos.PhotoInfo object
|
||||
options: RenderOptions instance
|
||||
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
|
||||
|
||||
Returns: list of directories for each photo
|
||||
|
||||
"""
|
||||
|
||||
rendered_date, _ = photo.render_template("{created.year}/{created.mm}/{created.dd}")
|
||||
date_path = rendered_date[0]
|
||||
|
||||
def add_date_path(path):
|
||||
"""add date path (year/mm/dd)"""
|
||||
return f"{path}/{date_path}"
|
||||
|
||||
# Library
|
||||
|
||||
directories = []
|
||||
if not photo.hidden and not photo.intrash and not photo.shared:
|
||||
# set directories to [Library/Photos/year/mm/dd]
|
||||
# render_template returns a tuple of [rendered value(s)], [unmatched]
|
||||
# here, we can ignore the unmatched value, assigned to _, as we know template will match
|
||||
directories, _ = photo.render_template(
|
||||
"Library/Photos/{created.year}/{created.mm}/{created.dd}"
|
||||
)
|
||||
|
||||
if photo.favorite:
|
||||
directories.append(add_date_path("Library/Favorites"))
|
||||
if photo.hidden:
|
||||
directories.append(add_date_path("Library/Hidden"))
|
||||
if photo.intrash:
|
||||
directories.append(add_date_path("Library/Recently Deleted"))
|
||||
|
||||
directories.extend(
|
||||
[
|
||||
add_date_path(f"Library/People/{person}")
|
||||
for person in photo.persons
|
||||
if person != _UNKNOWN_PERSON
|
||||
]
|
||||
)
|
||||
|
||||
if photo.place:
|
||||
directories.append(add_date_path(place_folder(photo)))
|
||||
|
||||
if photo.import_info:
|
||||
dt = DateTimeFormatter(photo.import_info.creation_date)
|
||||
directories.append(f"Library/Imports/{dt.year}/{dt.mm}/{dt.dd}")
|
||||
|
||||
# Media Types
|
||||
|
||||
if photo.ismovie:
|
||||
directories.append(add_date_path("Media Types/Videos"))
|
||||
if photo.selfie:
|
||||
directories.append(add_date_path("Media Types/Selfies"))
|
||||
if photo.live_photo:
|
||||
directories.append(add_date_path("Media Types/Live Photos"))
|
||||
if photo.portrait:
|
||||
directories.append(add_date_path("Media Types/Portrait"))
|
||||
if photo.panorama:
|
||||
directories.append(add_date_path("Media Types/Panoramas"))
|
||||
if photo.time_lapse:
|
||||
directories.append(add_date_path("Media Types/Time-lapse"))
|
||||
if photo.slow_mo:
|
||||
directories.append(add_date_path("Media Types/Slo-mo"))
|
||||
if photo.burst:
|
||||
directories.append(add_date_path("Media Types/Bursts"))
|
||||
if photo.screenshot:
|
||||
directories.append(add_date_path("Media Types/Screenshots"))
|
||||
|
||||
# Albums
|
||||
|
||||
# render the folders and albums in folder/subfolder/album format
|
||||
# the __NO_ALBUM__ is used as a sentinel to strip out photos not in an album
|
||||
# use RenderOptions.dirname to force the rendered folder_album value to be sanitized as a valid path
|
||||
# use RenderOptions.none_str to specify custom value for any photo that doesn't belong to an album so
|
||||
# those can be filtered out; if not specified, none_str is "_"
|
||||
folder_albums, _ = photo.render_template(
|
||||
"{folder_album}", RenderOptions(dirname=True, none_str="__NO_ALBUM__")
|
||||
)
|
||||
|
||||
root_directory = "Shared Albums/" if photo.shared else "My Albums/"
|
||||
directories.extend(
|
||||
[
|
||||
root_directory + folder_album
|
||||
for folder_album in folder_albums
|
||||
if folder_album != "__NO_ALBUM__"
|
||||
]
|
||||
)
|
||||
|
||||
return directories
|
||||
@@ -1,3 +1,4 @@
|
||||
from ._constants import AlbumSortOrder
|
||||
from ._version import __version__
|
||||
from .exiftool import ExifTool
|
||||
from .photoinfo import ExportResults, PhotoInfo
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
|
||||
|
||||
import sys
|
||||
|
||||
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
|
||||
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
|
||||
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
|
||||
|
||||
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
|
||||
from . import kae
|
||||
|
||||
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
class AppleScript:
|
||||
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
_codecs = Codecs()
|
||||
|
||||
def __init__(self, source=None, path=None):
|
||||
"""
|
||||
source : str | None -- AppleScript source code
|
||||
path : str | None -- full path to .scpt/.applescript file
|
||||
|
||||
Notes:
|
||||
|
||||
- Either the path or the source argument must be provided.
|
||||
|
||||
- If the script cannot be read/compiled, a ScriptError is raised.
|
||||
"""
|
||||
if path:
|
||||
url = NSURL.fileURLWithPath_(path)
|
||||
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
elif source:
|
||||
self._script = NSAppleScript.alloc().initWithSource_(source)
|
||||
else:
|
||||
raise ValueError("Missing source or path argument.")
|
||||
if not self._script.isCompiled():
|
||||
errorinfo = self._script.compileAndReturnError_(None)[1]
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
s = self.source
|
||||
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
|
||||
|
||||
##
|
||||
|
||||
def _newevent(self, suite, code, args):
|
||||
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
|
||||
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
|
||||
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
|
||||
return evt
|
||||
|
||||
def _unpackresult(self, result, errorinfo):
|
||||
if not result:
|
||||
raise ScriptError(errorinfo)
|
||||
return self._codecs.unpack(result)
|
||||
|
||||
##
|
||||
|
||||
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
|
||||
|
||||
def run(self, *args):
|
||||
""" Run the script, optionally passing arguments to its run handler.
|
||||
|
||||
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The run handler must be explicitly declared in order to pass arguments.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
if args:
|
||||
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
else:
|
||||
return self._unpackresult(*self._script.executeAndReturnError_(None))
|
||||
|
||||
def call(self, name, *args):
|
||||
""" Call the specified user-defined handler.
|
||||
|
||||
name : str -- the handler's name (case-sensitive)
|
||||
args : anything -- arguments to pass to script, if any; see documentation for supported types
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
|
||||
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
|
||||
fourcharcode(kae.keyASSubroutineName))
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class ScriptError(Exception):
|
||||
""" Indicates an AppleScript compilation/execution error. """
|
||||
|
||||
def __init__(self, errorinfo):
|
||||
self._errorinfo = dict(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
return 'ScriptError({})'.format(self._errorinfo)
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
""" str -- the error message """
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
|
||||
if not msg:
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
|
||||
return msg
|
||||
|
||||
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
|
||||
doc="int | None -- the error number, if given")
|
||||
|
||||
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
|
||||
doc="str | None -- the name of the application that reported the error, where relevant")
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
|
||||
range = self._errorinfo.get(NSAppleScriptErrorRange)
|
||||
if range:
|
||||
start = range.rangeValue().location
|
||||
end = start + range.rangeValue().length
|
||||
return (start, end)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
msg = self.message
|
||||
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
|
||||
if v is not None:
|
||||
msg += s.format(v)
|
||||
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
kMissingValue = AEType(kae.cMissingValue) # convenience constant
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
|
||||
|
||||
import datetime, struct, sys
|
||||
|
||||
from Foundation import NSAppleEventDescriptor, NSURL
|
||||
|
||||
from . import kae
|
||||
|
||||
|
||||
__all__ = ['Codecs', 'AEType', 'AEEnum']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
def fourcharcode(code):
|
||||
""" Convert four-char code for use in NSAppleEventDescriptor methods.
|
||||
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
Result : int -- OSType, e.g. 1970567284
|
||||
"""
|
||||
return struct.unpack('>I', code)[0]
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class Codecs:
|
||||
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
|
||||
|
||||
kMacEpoch = datetime.datetime(1904, 1, 1)
|
||||
kUSRF = fourcharcode(kae.keyASUserRecordFields)
|
||||
|
||||
def __init__(self):
|
||||
# Clients may add/remove/replace encoder and decoder items:
|
||||
self.encoders = {
|
||||
NSAppleEventDescriptor.class__(): self.packdesc,
|
||||
type(None): self.packnone,
|
||||
bool: self.packbool,
|
||||
int: self.packint,
|
||||
float: self.packfloat,
|
||||
bytes: self.packbytes,
|
||||
str: self.packstr,
|
||||
list: self.packlist,
|
||||
tuple: self.packlist,
|
||||
dict: self.packdict,
|
||||
datetime.datetime: self.packdatetime,
|
||||
AEType: self.packtype,
|
||||
AEEnum: self.packenum,
|
||||
}
|
||||
if sys.version_info.major < 3: # 2.7 compatibility
|
||||
self.encoders[unicode] = self.packstr
|
||||
|
||||
self.decoders = {fourcharcode(k): v for k, v in {
|
||||
kae.typeNull: self.unpacknull,
|
||||
kae.typeBoolean: self.unpackboolean,
|
||||
kae.typeFalse: self.unpackboolean,
|
||||
kae.typeTrue: self.unpackboolean,
|
||||
kae.typeSInt32: self.unpacksint32,
|
||||
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
|
||||
kae.typeUTF8Text: self.unpackunicodetext,
|
||||
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
|
||||
kae.typeUnicodeText: self.unpackunicodetext,
|
||||
kae.typeLongDateTime: self.unpacklongdatetime,
|
||||
kae.typeAEList: self.unpackaelist,
|
||||
kae.typeAERecord: self.unpackaerecord,
|
||||
kae.typeAlias: self.unpackfile,
|
||||
kae.typeFSS: self.unpackfile,
|
||||
kae.typeFSRef: self.unpackfile,
|
||||
kae.typeFileURL: self.unpackfile,
|
||||
kae.typeType: self.unpacktype,
|
||||
kae.typeEnumeration: self.unpackenumeration,
|
||||
}.items()}
|
||||
|
||||
def pack(self, data):
|
||||
"""Pack Python data.
|
||||
data : anything -- a Python value
|
||||
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
|
||||
"""
|
||||
try:
|
||||
return self.encoders[data.__class__](data) # quick lookup by type/class
|
||||
except (KeyError, AttributeError) as e:
|
||||
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
|
||||
if isinstance(data, type):
|
||||
return encoder(data)
|
||||
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
|
||||
|
||||
def unpack(self, desc):
|
||||
"""Unpack an Apple event descriptor.
|
||||
desc : NSAppleEventDescriptor
|
||||
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
|
||||
"""
|
||||
decoder = self.decoders.get(desc.descriptorType())
|
||||
# unpack known type
|
||||
if decoder:
|
||||
return decoder(desc)
|
||||
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
|
||||
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
|
||||
if rec:
|
||||
rec = self.unpackaerecord(rec)
|
||||
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
|
||||
return rec
|
||||
# return as-is
|
||||
return desc
|
||||
|
||||
##
|
||||
|
||||
def _packbytes(self, desctype, data):
|
||||
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
|
||||
fourcharcode(desctype), data, len(data))
|
||||
|
||||
def packdesc(self, val):
|
||||
return val
|
||||
|
||||
def packnone(self, val):
|
||||
return NSAppleEventDescriptor.nullDescriptor()
|
||||
|
||||
def packbool(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
|
||||
|
||||
def packint(self, val):
|
||||
if (-2**31) <= val < (2**31):
|
||||
return NSAppleEventDescriptor.descriptorWithInt32_(val)
|
||||
else:
|
||||
return self.pack(float(val))
|
||||
|
||||
def packfloat(self, val):
|
||||
return self._packbytes(kae.typeFloat, struct.pack('d', val))
|
||||
|
||||
def packbytes(self, val):
|
||||
return self._packbytes(kae.typeData, val)
|
||||
|
||||
def packstr(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithString_(val)
|
||||
|
||||
def packdatetime(self, val):
|
||||
delta = val - self.kMacEpoch
|
||||
sec = delta.days * 3600 * 24 + delta.seconds
|
||||
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
|
||||
|
||||
def packlist(self, val):
|
||||
lst = NSAppleEventDescriptor.listDescriptor()
|
||||
for item in val:
|
||||
lst.insertDescriptor_atIndex_(self.pack(item), 0)
|
||||
return lst
|
||||
|
||||
def packdict(self, val):
|
||||
record = NSAppleEventDescriptor.recordDescriptor()
|
||||
usrf = desctype = None
|
||||
for key, value in val.items():
|
||||
if isinstance(key, AEType):
|
||||
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
|
||||
desctype = value
|
||||
else:
|
||||
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
|
||||
else:
|
||||
if not usrf:
|
||||
usrf = NSAppleEventDescriptor.listDescriptor()
|
||||
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
|
||||
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
|
||||
if usrf:
|
||||
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
|
||||
if desctype:
|
||||
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
|
||||
if newrecord:
|
||||
record = newrecord
|
||||
else: # coercion failed for some reason, so pack as normal key-value pair
|
||||
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
|
||||
return record
|
||||
|
||||
def packtype(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
|
||||
|
||||
def packenum(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
|
||||
|
||||
#######
|
||||
|
||||
def unpacknull(self, desc):
|
||||
return None
|
||||
|
||||
def unpackboolean(self, desc):
|
||||
return desc.booleanValue()
|
||||
|
||||
def unpacksint32(self, desc):
|
||||
return desc.int32Value()
|
||||
|
||||
def unpackfloat64(self, desc):
|
||||
return struct.unpack('d', bytes(desc.data()))[0]
|
||||
|
||||
def unpackunicodetext(self, desc):
|
||||
return desc.stringValue()
|
||||
|
||||
def unpacklongdatetime(self, desc):
|
||||
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
|
||||
|
||||
def unpackaelist(self, desc):
|
||||
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
|
||||
|
||||
def unpackaerecord(self, desc):
|
||||
dct = {}
|
||||
for i in range(desc.numberOfItems()):
|
||||
key = desc.keywordForDescriptorAtIndex_(i + 1)
|
||||
value = desc.descriptorForKeyword_(key)
|
||||
if key == self.kUSRF:
|
||||
lst = self.unpackaelist(value)
|
||||
for i in range(0, len(lst), 2):
|
||||
dct[lst[i]] = lst[i+1]
|
||||
else:
|
||||
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
|
||||
return dct
|
||||
|
||||
def unpacktype(self, desc):
|
||||
return AEType(struct.pack('>I', desc.typeCodeValue()))
|
||||
|
||||
def unpackenumeration(self, desc):
|
||||
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
|
||||
|
||||
def unpackfile(self, desc):
|
||||
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
|
||||
return NSURL.URLWithString_(url).path()
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class AETypeBase:
|
||||
""" Base class for AEType and AEEnum.
|
||||
|
||||
Notes:
|
||||
|
||||
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
|
||||
"""
|
||||
|
||||
def __init__(self, code):
|
||||
"""
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
"""
|
||||
if not isinstance(code, bytes):
|
||||
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
|
||||
elif len(code) != 4:
|
||||
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
|
||||
self._code = code
|
||||
|
||||
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._code)
|
||||
|
||||
def __eq__(self, val):
|
||||
return val.__class__ == self.__class__ and val.code == self._code
|
||||
|
||||
def __ne__(self, val):
|
||||
return not self == val
|
||||
|
||||
def __repr__(self):
|
||||
return "{}({!r})".format(self.__class__.__name__, self._code)
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class AEType(AETypeBase):
|
||||
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
|
||||
|
||||
|
||||
class AEEnum(AETypeBase):
|
||||
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ Constants used by osxphotos
|
||||
|
||||
import os.path
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
OSXPHOTOS_URL = "https://github.com/RhetTbull/osxphotos"
|
||||
|
||||
@@ -34,11 +35,12 @@ _PHOTOS_3_VERSION = "3301"
|
||||
|
||||
# versions 5.0 and later have a different database structure
|
||||
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
|
||||
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.6
|
||||
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.7 (also Big Sur and Monterey which switch to model version)
|
||||
|
||||
# Ranges for model version by Photos version
|
||||
_PHOTOS_5_MODEL_VERSION = [13000, 13999]
|
||||
_PHOTOS_6_MODEL_VERSION = [14000, 14999]
|
||||
_PHOTOS_7_MODEL_VERSION = [15000, 15999] # Monterey developer preview is 15134
|
||||
|
||||
# some table names differ between Photos 5 and Photos 6
|
||||
_DB_TABLE_NAMES = {
|
||||
@@ -49,6 +51,10 @@ _DB_TABLE_NAMES = {
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_34ASSETS",
|
||||
"IMPORT_FOK": "ZGENERICASSET.Z_FOK_IMPORTSESSION",
|
||||
"DEPTH_STATE": "ZGENERICASSET.ZDEPTHSTATES",
|
||||
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
|
||||
"ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
|
||||
"ASSET_ALBUM_TABLE": "Z_26ASSETS",
|
||||
"HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
|
||||
},
|
||||
6: {
|
||||
"ASSET": "ZASSET",
|
||||
@@ -57,6 +63,22 @@ _DB_TABLE_NAMES = {
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_3ASSETS",
|
||||
"IMPORT_FOK": "null",
|
||||
"DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
|
||||
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
|
||||
"ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
|
||||
"ASSET_ALBUM_TABLE": "Z_26ASSETS",
|
||||
"HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
|
||||
},
|
||||
7: {
|
||||
"ASSET": "ZASSET",
|
||||
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_38KEYWORDS",
|
||||
"ALBUM_JOIN": "Z_27ASSETS.Z_3ASSETS",
|
||||
"ALBUM_SORT_ORDER": "Z_27ASSETS.Z_FOK_3ASSETS",
|
||||
"IMPORT_FOK": "null",
|
||||
"DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
|
||||
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZCOMPACTUTI",
|
||||
"ASSET_ALBUM_JOIN": "Z_27ASSETS.Z_27ALBUMS",
|
||||
"ASSET_ALBUM_TABLE": "Z_27ASSETS",
|
||||
"HDR_TYPE": "ZHDRTYPE",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -189,6 +211,9 @@ DEFAULT_EDITED_SUFFIX = "_edited"
|
||||
# Default suffix to add to original images
|
||||
DEFAULT_ORIGINAL_SUFFIX = ""
|
||||
|
||||
# Default suffix to add to preview images
|
||||
DEFAULT_PREVIEW_SUFFIX = "_preview"
|
||||
|
||||
# Colors for print CLI messages
|
||||
CLI_COLOR_ERROR = "red"
|
||||
CLI_COLOR_WARNING = "yellow"
|
||||
@@ -203,10 +228,17 @@ EXTENDED_ATTRIBUTE_NAMES = [
|
||||
"authors",
|
||||
"comment",
|
||||
"copyright",
|
||||
"creator",
|
||||
"description",
|
||||
"findercomment",
|
||||
"headline",
|
||||
"keywords",
|
||||
"participants",
|
||||
"projects",
|
||||
"rating",
|
||||
"subject",
|
||||
"title",
|
||||
"version",
|
||||
]
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
|
||||
|
||||
@@ -242,3 +274,11 @@ POST_COMMAND_CATEGORIES = {
|
||||
# "deleted_files": "When used with '--cleanup', all files deleted during the export",
|
||||
# "deleted_directories": "When used with '--cleanup', all directories deleted during the export",
|
||||
}
|
||||
|
||||
class AlbumSortOrder(Enum):
|
||||
"""Album Sort Order"""
|
||||
UNKNOWN = 0
|
||||
MANUAL = 1
|
||||
NEWEST_FIRST = 2
|
||||
OLDEST_FIRST = 3
|
||||
TITLE = 5
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.46"
|
||||
__version__ = "0.42.65"
|
||||
|
||||
@@ -19,21 +19,22 @@ from ._constants import (
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_FOLDER_KIND,
|
||||
TIME_DELTA,
|
||||
AlbumSortOrder,
|
||||
)
|
||||
from .datetime_utils import get_local_tz
|
||||
|
||||
|
||||
def sort_list_by_keys(values, sort_keys):
|
||||
""" Sorts list values by a second list sort_keys
|
||||
"""Sorts list values by a second list sort_keys
|
||||
e.g. given ["a","c","b"], [1, 3, 2], returns ["a", "b", "c"]
|
||||
|
||||
Args:
|
||||
values: a list of values to be sorted
|
||||
sort_keys: a list of keys to sort values by
|
||||
|
||||
|
||||
Returns:
|
||||
list of values, sorted by sort_keys
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: raised if len(values) != len(sort_keys)
|
||||
"""
|
||||
@@ -63,12 +64,12 @@ class AlbumInfoBaseClass:
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of album """
|
||||
"""return uuid of album"""
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def creation_date(self):
|
||||
""" return creation date of album """
|
||||
"""return creation date of album"""
|
||||
try:
|
||||
return self._creation_date
|
||||
except AttributeError:
|
||||
@@ -90,8 +91,8 @@ class AlbumInfoBaseClass:
|
||||
|
||||
@property
|
||||
def start_date(self):
|
||||
""" For Albums, return start date (earliest image) of album or None for albums with no images
|
||||
For Import Sessions, return start date of import session (when import began) """
|
||||
"""For Albums, return start date (earliest image) of album or None for albums with no images
|
||||
For Import Sessions, return start date of import session (when import began)"""
|
||||
try:
|
||||
return self._start_date
|
||||
except AttributeError:
|
||||
@@ -109,8 +110,8 @@ class AlbumInfoBaseClass:
|
||||
|
||||
@property
|
||||
def end_date(self):
|
||||
""" For Albums, return end date (most recent image) of album or None for albums with no images
|
||||
For Import Sessions, return end date of import sessions (when import was completed) """
|
||||
"""For Albums, return end date (most recent image) of album or None for albums with no images
|
||||
For Import Sessions, return end date of import sessions (when import was completed)"""
|
||||
try:
|
||||
return self._end_date
|
||||
except AttributeError:
|
||||
@@ -131,7 +132,7 @@ class AlbumInfoBaseClass:
|
||||
return []
|
||||
|
||||
def __len__(self):
|
||||
""" return number of photos contained in album """
|
||||
"""return number of photos contained in album"""
|
||||
return len(self.photos)
|
||||
|
||||
|
||||
@@ -144,29 +145,39 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of album """
|
||||
"""return title / name of album"""
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in album sorted in same sort order as Photos """
|
||||
"""return list of photos contained in album sorted in same sort order as Photos"""
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
if self.uuid in self._db._dbalbums_album:
|
||||
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
|
||||
sorted_uuid = sort_list_by_keys(uuid, sort_order)
|
||||
self._photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
sort_order = self.sort_order
|
||||
if sort_order == AlbumSortOrder.NEWEST_FIRST:
|
||||
self._photos = sorted(photos, key=lambda p: p.date, reverse=True)
|
||||
elif sort_order == AlbumSortOrder.OLDEST_FIRST:
|
||||
self._photos = sorted(photos, key=lambda p: p.date)
|
||||
elif sort_order == AlbumSortOrder.TITLE:
|
||||
self._photos = sorted(photos, key=lambda p: p.title or "")
|
||||
else:
|
||||
# assume AlbumSortOrder.MANUAL
|
||||
self._photos = photos
|
||||
else:
|
||||
self._photos = []
|
||||
return self._photos
|
||||
|
||||
@property
|
||||
def folder_names(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
"""return hierarchical list of folders the album is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders"""
|
||||
|
||||
try:
|
||||
return self._folder_names
|
||||
@@ -176,10 +187,10 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
|
||||
@property
|
||||
def folder_list(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
as list of FolderInfo objects in form
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
"""return hierarchical list of folders the album is contained in
|
||||
as list of FolderInfo objects in form
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders"""
|
||||
|
||||
try:
|
||||
return self._folders
|
||||
@@ -189,7 +200,7 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
|
||||
"""returns FolderInfo object for parent folder or None if no parent (e.g. top-level album)"""
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
@@ -209,11 +220,44 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
)
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def sort_order(self):
|
||||
"""return sort order of album"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return AlbumSortOrder.MANUAL
|
||||
|
||||
details = self._db._dbalbum_details[self._uuid]
|
||||
if details["customsortkey"] == 1:
|
||||
if details["customsortascending"] == 0:
|
||||
return AlbumSortOrder.NEWEST_FIRST
|
||||
elif details["customsortascending"] == 1:
|
||||
return AlbumSortOrder.OLDEST_FIRST
|
||||
else:
|
||||
return AlbumSortOrder.UNKNOWN
|
||||
elif details["customsortkey"] == 5:
|
||||
return AlbumSortOrder.TITLE
|
||||
elif details["customsortkey"] == 0:
|
||||
return AlbumSortOrder.MANUAL
|
||||
else:
|
||||
return AlbumSortOrder.UNKNOWN
|
||||
|
||||
def photo_index(self, photo):
|
||||
"""return index of photo in album (based on album sort order)"""
|
||||
index = 0
|
||||
for p in self.photos:
|
||||
if p.uuid == photo.uuid:
|
||||
return index
|
||||
index += 1
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Photo with uuid {photo.uuid} does not appear to be in this album"
|
||||
)
|
||||
|
||||
|
||||
class ImportInfo(AlbumInfoBaseClass):
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in import session """
|
||||
"""return list of photos contained in import session"""
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
@@ -231,7 +275,7 @@ class ImportInfo(AlbumInfoBaseClass):
|
||||
|
||||
class FolderInfo:
|
||||
"""
|
||||
Info about a specific folder, contains all the details about the folder
|
||||
Info about a specific folder, contains all the details about the folder
|
||||
including folders, albums, etc
|
||||
"""
|
||||
|
||||
@@ -247,17 +291,17 @@ class FolderInfo:
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of folder"""
|
||||
"""return title / name of folder"""
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of folder """
|
||||
"""return uuid of folder"""
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def album_info(self):
|
||||
""" return list of albums (as AlbumInfo objects) contained in the folder """
|
||||
"""return list of albums (as AlbumInfo objects) contained in the folder"""
|
||||
try:
|
||||
return self._albums
|
||||
except AttributeError:
|
||||
@@ -282,7 +326,7 @@ class FolderInfo:
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent or None if no parent (e.g. top-level folder) """
|
||||
"""returns FolderInfo object for parent or None if no parent (e.g. top-level folder)"""
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
@@ -304,7 +348,7 @@ class FolderInfo:
|
||||
|
||||
@property
|
||||
def subfolders(self):
|
||||
""" return list of folders (as FolderInfo objects) contained in the folder """
|
||||
"""return list of folders (as FolderInfo objects) contained in the folder"""
|
||||
try:
|
||||
return self._folders
|
||||
except AttributeError:
|
||||
@@ -328,5 +372,5 @@ class FolderInfo:
|
||||
return self._folders
|
||||
|
||||
def __len__(self):
|
||||
""" returns count of folders + albums contained in the folder """
|
||||
"""returns count of folders + albums contained in the folder"""
|
||||
return len(self.subfolders) + len(self.album_info)
|
||||
|
||||
768
osxphotos/cli.py
768
osxphotos/cli.py
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,6 @@ from typing import Optional
|
||||
import photoscript
|
||||
from mako.template import Template
|
||||
|
||||
# from .._applescript import AppleScript
|
||||
from .._constants import (
|
||||
_MAX_IPTC_KEYWORD_LEN,
|
||||
_OSXPHOTOS_NONE_SENTINEL,
|
||||
@@ -38,6 +37,7 @@ from .._constants import (
|
||||
_UNKNOWN_PERSON,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
_XMP_TEMPLATE_NAME_BETA,
|
||||
DEFAULT_PREVIEW_SUFFIX,
|
||||
LIVE_VIDEO_EXTENSIONS,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
@@ -55,7 +55,8 @@ from ..photokit import (
|
||||
PhotoLibrary,
|
||||
)
|
||||
from ..phototemplate import RenderOptions
|
||||
from ..utils import findfiles, get_preferred_uti_extension, lineno, noop
|
||||
from ..uti import get_preferred_uti_extension
|
||||
from ..utils import findfiles, lineno, noop
|
||||
|
||||
# retry if use_photos_export fails the first time (which sometimes it does)
|
||||
MAX_PHOTOSCRIPT_RETRIES = 3
|
||||
@@ -379,7 +380,7 @@ def rename_jpeg_files(files, jpeg_ext, fileutil):
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
*filename,
|
||||
filename=None,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
raw_photo=False,
|
||||
@@ -410,12 +411,12 @@ def export(
|
||||
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
|
||||
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 associted .mov for live photos
|
||||
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
|
||||
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 alreay exist
|
||||
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
|
||||
@@ -449,10 +450,25 @@ def export(
|
||||
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
|
||||
|
||||
results = self.export2(
|
||||
dest,
|
||||
*filename,
|
||||
original=not edited,
|
||||
original_filename=filename,
|
||||
edited=edited,
|
||||
edited_filename=filename,
|
||||
live_photo=live_photo,
|
||||
raw_photo=raw_photo,
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
@@ -466,7 +482,7 @@ def export(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
render_options = render_options,
|
||||
render_options=render_options,
|
||||
)
|
||||
|
||||
return results.exported
|
||||
@@ -475,8 +491,10 @@ def export(
|
||||
def export2(
|
||||
self,
|
||||
dest,
|
||||
*filename,
|
||||
original=True,
|
||||
original_filename=None,
|
||||
edited=False,
|
||||
edited_filename=None,
|
||||
live_photo=False,
|
||||
raw_photo=False,
|
||||
export_as_hardlink=False,
|
||||
@@ -509,7 +527,9 @@ def export2(
|
||||
persons=True,
|
||||
location=True,
|
||||
replace_keywords=False,
|
||||
render_options: Optional[RenderOptions] = None
|
||||
preview=False,
|
||||
preview_suffix=DEFAULT_PREVIEW_SUFFIX,
|
||||
render_options: Optional[RenderOptions] = None,
|
||||
):
|
||||
"""export photo, like export but with update and dry_run options
|
||||
dest: must be valid destination path or exception raised
|
||||
@@ -521,8 +541,8 @@ def export2(
|
||||
in which case export will use the extension provided by Photos upon export.
|
||||
e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
original: (boolean, default=True); if True, will export the original version of the photo
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
(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
|
||||
@@ -565,6 +585,8 @@ def export2(
|
||||
persons: if True, include persons in exported metadata
|
||||
location: if True, include location in exported metadata
|
||||
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
|
||||
preview: if True, also exports preview image
|
||||
preview_suffix: optional string to append to end of filename for preview images
|
||||
render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
|
||||
|
||||
Returns: ExportResults class
|
||||
@@ -609,209 +631,275 @@ def export2(
|
||||
|
||||
self._render_options = render_options or RenderOptions()
|
||||
|
||||
# suffix to add to edited files
|
||||
# e.g. name will be filename_edited.jpg
|
||||
edited_identifier = "_edited"
|
||||
|
||||
# check edited and raise exception trying to export edited version of
|
||||
# photo that hasn't been edited
|
||||
export_original = original
|
||||
export_edited = edited
|
||||
if edited and not self.hasadjustments:
|
||||
raise ValueError(
|
||||
"Photo does not have adjustments, cannot export edited version"
|
||||
)
|
||||
|
||||
# check arguments and get destination path and filename (if provided)
|
||||
if filename and len(filename) > 2:
|
||||
raise TypeError(
|
||||
"Too many positional arguments. Should be at most two: destination, filename."
|
||||
)
|
||||
|
||||
# verify destination is a valid path
|
||||
if dest is None:
|
||||
raise ValueError("Destination must not be None")
|
||||
raise ValueError("dest must not be None")
|
||||
elif not dry_run and not os.path.isdir(dest):
|
||||
raise FileNotFoundError("Invalid path passed to export")
|
||||
|
||||
if filename and len(filename) == 1:
|
||||
# if filename passed, use it
|
||||
fname = filename[0]
|
||||
else:
|
||||
# no filename provided so use the default
|
||||
# if edited file requested, use filename but add _edited
|
||||
# need to use file extension from edited file as Photos saves a jpeg once edited
|
||||
if edited and not use_photos_export:
|
||||
# verify we have a valid path_edited and use that to get filename
|
||||
if not self.path_edited:
|
||||
raise FileNotFoundError(
|
||||
"edited=True but path_edited is none; hasadjustments: "
|
||||
f" {self.hasadjustments}"
|
||||
)
|
||||
edited_name = pathlib.Path(self.path_edited).name
|
||||
edited_suffix = pathlib.Path(edited_name).suffix
|
||||
fname = (
|
||||
pathlib.Path(self.original_filename).stem
|
||||
+ edited_identifier
|
||||
+ edited_suffix
|
||||
)
|
||||
original_filename = original_filename or self.original_filename
|
||||
dest_original = pathlib.Path(dest) / original_filename
|
||||
|
||||
if not edited_filename:
|
||||
if not edited:
|
||||
edited_filename = self.original_filename
|
||||
else:
|
||||
fname = self.original_filename
|
||||
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
|
||||
edited_filename = original_name.stem + "_edited" + ext
|
||||
dest_edited = pathlib.Path(dest) / edited_filename
|
||||
|
||||
uti = self.uti if edited else self.uti_original
|
||||
if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
|
||||
# not a jpeg but will convert to jpeg upon export so fix file extension
|
||||
fname_new = pathlib.Path(fname)
|
||||
if convert_to_jpeg and self.isphoto:
|
||||
something_to_convert = False
|
||||
ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
|
||||
fname = str(fname_new.parent / f"{fname_new.stem}{ext}")
|
||||
if export_original and self.uti_original != "public.jpeg":
|
||||
# not a jpeg but will convert to jpeg upon export so fix file extension
|
||||
something_to_convert = True
|
||||
dest_original = dest_original.parent / f"{dest_original.stem}{ext}"
|
||||
if export_edited and self.uti != "public.jpeg":
|
||||
# in Big Sur+, edited HEICs are HEIC
|
||||
something_to_convert = True
|
||||
dest_edited = dest_edited.parent / f"{dest_edited.stem}{ext}"
|
||||
convert_to_jpeg = something_to_convert
|
||||
else:
|
||||
# nothing to convert
|
||||
convert_to_jpeg = False
|
||||
|
||||
# check destination path
|
||||
dest = pathlib.Path(dest)
|
||||
fname = pathlib.Path(fname)
|
||||
dest = dest / fname
|
||||
|
||||
# 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
|
||||
count = 0
|
||||
if not update and increment and not overwrite:
|
||||
count = 1
|
||||
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
|
||||
dest_files = findfiles(f"{dest_original.stem}*", str(dest_original.parent))
|
||||
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
dest_new = dest_original.stem
|
||||
while dest_new.lower() in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
dest_new = f"{dest_original.stem} ({count})"
|
||||
dest_original = dest_original.parent / f"{dest_new}{dest_original.suffix}"
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest.exists() and not update and not overwrite and not increment:
|
||||
if (
|
||||
dest_original.exists()
|
||||
and export_original
|
||||
and not update
|
||||
and not overwrite
|
||||
and not increment
|
||||
):
|
||||
raise FileExistsError(
|
||||
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
|
||||
f"destination exists ({dest_original}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
self._render_options.filepath = str(dest)
|
||||
if export_edited:
|
||||
if not update and increment and not overwrite:
|
||||
dest_files = findfiles(f"{dest_edited.stem}*", str(dest_edited.parent))
|
||||
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
|
||||
dest_new = dest_edited.stem
|
||||
if count:
|
||||
# incremented above when checking original destination
|
||||
dest_new = f"{dest_new} ({count})"
|
||||
while dest_new.lower() in dest_files:
|
||||
count += 1
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
dest_edited = dest_edited.parent / f"{dest_new}{dest_edited.suffix}"
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest_edited.exists() and not update and not overwrite and not increment:
|
||||
raise FileExistsError(
|
||||
f"destination exists ({dest_edited}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
self._render_options.filepath = (
|
||||
str(dest_original) if export_original else str(dest_edited)
|
||||
)
|
||||
all_results = ExportResults()
|
||||
if not use_photos_export:
|
||||
|
||||
if use_photos_export:
|
||||
# TODO: collapse these into a single call (refactor _export_photo_with_photos_export)
|
||||
if original:
|
||||
self._export_photo_with_photos_export(
|
||||
dest_original,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
use_photokit=use_photokit,
|
||||
dry_run=dry_run,
|
||||
timeout=timeout,
|
||||
jpeg_ext=jpeg_ext,
|
||||
touch_file=touch_file,
|
||||
update=update,
|
||||
overwrite=overwrite,
|
||||
live_photo=live_photo,
|
||||
edited=False,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
)
|
||||
if edited:
|
||||
self._export_photo_with_photos_export(
|
||||
dest_edited,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
use_photokit=use_photokit,
|
||||
dry_run=dry_run,
|
||||
timeout=timeout,
|
||||
jpeg_ext=jpeg_ext,
|
||||
touch_file=touch_file,
|
||||
update=update,
|
||||
overwrite=overwrite,
|
||||
live_photo=live_photo,
|
||||
edited=True,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
)
|
||||
else:
|
||||
# find the source file on disk and export
|
||||
# get path to source file and verify it's not None and is valid file
|
||||
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
|
||||
if edited:
|
||||
if self.path_edited is not None:
|
||||
src = self.path_edited
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Cannot export edited photo if path_edited is None"
|
||||
)
|
||||
else:
|
||||
if self.path is not None:
|
||||
src = self.path
|
||||
else:
|
||||
raise FileNotFoundError("Cannot export photo if path is None")
|
||||
export_src_dest = []
|
||||
if edited and self.path_edited is not None:
|
||||
export_src_dest.append((self.path_edited, dest_edited))
|
||||
elif not edited and self.path is not None:
|
||||
export_src_dest.append((self.path, dest_original))
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError(f"{src} does not appear to exist")
|
||||
for src, dest in export_src_dest:
|
||||
if not pathlib.Path(src).is_file():
|
||||
raise FileNotFoundError(f"{src} does not appear to exist")
|
||||
|
||||
# found source now try to find right destination
|
||||
if 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.uuid
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.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.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
if dest_uuid != self.uuid:
|
||||
# not the right file, find the right one
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
|
||||
dest_files = glob.glob(glob_str)
|
||||
found_match = False
|
||||
for file_ in dest_files:
|
||||
dest_uuid = export_db.get_uuid_for_file(file_)
|
||||
if dest_uuid == self.uuid:
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
break
|
||||
elif dest_uuid is None and fileutil.cmp(src, file_):
|
||||
# files match, update the UUID
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.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.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
# increment the destination file
|
||||
# found source now try to find right destination
|
||||
if 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.uuid
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.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.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
if dest_uuid != self.uuid:
|
||||
# not the right file, find the right one
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem}*")
|
||||
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
|
||||
dest_files = glob.glob(glob_str)
|
||||
dest_files = [pathlib.Path(f).stem for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
found_match = False
|
||||
for file_ in dest_files:
|
||||
dest_uuid = export_db.get_uuid_for_file(file_)
|
||||
if dest_uuid == self.uuid:
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
break
|
||||
elif dest_uuid is None and fileutil.cmp(src, file_):
|
||||
# files match, update the UUID
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.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.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
break
|
||||
|
||||
# export the dest file
|
||||
results = self._export_photo(
|
||||
src,
|
||||
dest,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
convert_to_jpeg,
|
||||
fileutil=fileutil,
|
||||
edited=edited,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
if not found_match:
|
||||
# increment the destination file
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem}*")
|
||||
dest_files = glob.glob(glob_str)
|
||||
dest_files = [pathlib.Path(f).stem for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
|
||||
# export the dest file
|
||||
results = self._export_photo(
|
||||
src,
|
||||
dest,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
convert_to_jpeg,
|
||||
fileutil=fileutil,
|
||||
edited=edited,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
dest = dest_original if export_original else dest_edited
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
if export_original and live_photo and self.live_photo and self.path_live_photo:
|
||||
live_name = dest.parent / f"{dest.stem}.mov"
|
||||
src_live = self.path_live_photo
|
||||
results = self._export_photo(
|
||||
src_live,
|
||||
live_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
False,
|
||||
fileutil=fileutil,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
if src_live is not None:
|
||||
results = self._export_photo(
|
||||
src_live,
|
||||
live_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
False,
|
||||
fileutil=fileutil,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
if (
|
||||
export_edited
|
||||
and live_photo
|
||||
and self.live_photo
|
||||
and self.path_edited_live_photo
|
||||
):
|
||||
live_name = dest.parent / f"{dest_edited.stem}.mov"
|
||||
src_live = self.path_edited_live_photo
|
||||
results = self._export_photo(
|
||||
src_live,
|
||||
live_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
False,
|
||||
fileutil=fileutil,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
# copy associated RAW image if requested
|
||||
if raw_photo and self.has_raw:
|
||||
if raw_photo and self.has_raw and self.path_raw:
|
||||
raw_path = pathlib.Path(self.path_raw)
|
||||
raw_ext = raw_path.suffix
|
||||
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
|
||||
@@ -831,26 +919,30 @@ def export2(
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
else:
|
||||
self._export_photo_with_photos_export(
|
||||
dest,
|
||||
filename,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
use_photokit=use_photokit,
|
||||
dry_run=dry_run,
|
||||
timeout=timeout,
|
||||
jpeg_ext=jpeg_ext,
|
||||
touch_file=touch_file,
|
||||
update=update,
|
||||
overwrite=overwrite,
|
||||
live_photo=live_photo,
|
||||
edited=edited,
|
||||
edited_identifier=edited_identifier,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
)
|
||||
|
||||
# copy preview image if requested
|
||||
if preview and self.path_derivatives:
|
||||
# Photos keeps multiple different derivatives and path_derivatives returns list of them
|
||||
# first derivative is the largest so export that one
|
||||
preview_path = pathlib.Path(self.path_derivatives[0])
|
||||
preview_ext = preview_path.suffix
|
||||
preview_name = dest.parent / f"{dest.stem}{preview_suffix}{preview_ext}"
|
||||
if preview_path is not None:
|
||||
results = self._export_photo(
|
||||
preview_path,
|
||||
preview_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
convert_to_jpeg,
|
||||
fileutil=fileutil,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
# export metadata
|
||||
sidecars = []
|
||||
@@ -861,6 +953,7 @@ def export2(
|
||||
sidecar_xmp_files_skipped = []
|
||||
sidecar_xmp_files_written = []
|
||||
|
||||
dest = dest_original if export_original else dest_edited
|
||||
dest_suffix = "" if sidecar_drop_ext else dest.suffix
|
||||
if sidecar & SIDECAR_JSON:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
|
||||
@@ -1111,7 +1204,6 @@ def export2(
|
||||
def _export_photo_with_photos_export(
|
||||
self,
|
||||
dest,
|
||||
filename,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
@@ -1124,7 +1216,6 @@ def _export_photo_with_photos_export(
|
||||
overwrite=None,
|
||||
live_photo=None,
|
||||
edited=None,
|
||||
edited_identifier=None,
|
||||
convert_to_jpeg=None,
|
||||
jpeg_quality=1.0,
|
||||
):
|
||||
@@ -1137,15 +1228,10 @@ def _export_photo_with_photos_export(
|
||||
# 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
|
||||
# so tell Photos to export the current version in this case
|
||||
if filename:
|
||||
# use filename stem provided
|
||||
filestem = dest.stem
|
||||
else:
|
||||
# didn't get passed a filename, add _edited
|
||||
filestem = f"{dest.stem}{edited_identifier}"
|
||||
uti = self.uti_edited if edited and self.uti_edited else self.uti
|
||||
ext = get_preferred_uti_extension(uti)
|
||||
dest = dest.parent / f"{filestem}{ext}"
|
||||
# didn't get passed a filename, add _edited
|
||||
uti = self.uti_edited if edited and self.uti_edited else self.uti
|
||||
ext = get_preferred_uti_extension(uti)
|
||||
dest = dest.parent / f"{dest.stem}.{ext}"
|
||||
|
||||
if use_photokit:
|
||||
photolib = PhotoLibrary()
|
||||
@@ -1190,7 +1276,7 @@ def _export_photo_with_photos_export(
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
filestem=dest.stem,
|
||||
original=False,
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
@@ -1204,7 +1290,6 @@ def _export_photo_with_photos_export(
|
||||
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
|
||||
else:
|
||||
# export original version and not edited
|
||||
filestem = dest.stem
|
||||
if use_photokit:
|
||||
photolib = PhotoLibrary()
|
||||
photo = None
|
||||
@@ -1241,7 +1326,7 @@ def _export_photo_with_photos_export(
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
filestem=dest.stem,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=live_photo,
|
||||
@@ -1608,7 +1693,9 @@ def _exiftool_dict(
|
||||
)
|
||||
|
||||
if description_template is not None:
|
||||
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, expand_inplace=True, inplace_sep=", "
|
||||
)
|
||||
rendered = self.render_template(description_template, options)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
exif["EXIF:ImageDescription"] = description
|
||||
@@ -1647,7 +1734,9 @@ def _exiftool_dict(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
for template_str in keyword_template:
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
@@ -1925,7 +2014,9 @@ def _xmp_sidecar(
|
||||
extension = extension.suffix[1:] if extension.suffix else None
|
||||
|
||||
if description_template is not None:
|
||||
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, expand_inplace=True, inplace_sep=", "
|
||||
)
|
||||
rendered = self.render_template(description_template, options)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
else:
|
||||
@@ -1958,7 +2049,9 @@ def _xmp_sidecar(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
for template_str in keyword_template:
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
|
||||
@@ -36,7 +36,8 @@ from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate, RenderOptions
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
||||
from ..uti import get_preferred_uti_extension, get_uti_for_extension
|
||||
from ..utils import _debug, _get_resource_loc, findfiles
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
@@ -148,41 +149,11 @@ class PhotoInfo:
|
||||
except AttributeError:
|
||||
self._path = None
|
||||
photopath = None
|
||||
# TODO: should path try to return path even if ismissing?
|
||||
if self._info["isMissing"] == 1:
|
||||
return photopath # path would be meaningless until downloaded
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self._info["has_raw"]:
|
||||
# return the path to JPEG even if RAW is original
|
||||
vol = (
|
||||
self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
|
||||
if self._info["raw_pair_info"]["volumeId"] is not None
|
||||
else None
|
||||
)
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path,
|
||||
self._info["raw_pair_info"]["imagePath"],
|
||||
)
|
||||
else:
|
||||
vol = self._info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["imagePath"]
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
return self._path_4()
|
||||
|
||||
if self._info["shared"]:
|
||||
# shared photo
|
||||
@@ -212,6 +183,37 @@ class PhotoInfo:
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
def _path_4(self):
|
||||
"""return path for photo on Photos <= version 4"""
|
||||
if self._info["has_raw"]:
|
||||
# return the path to JPEG even if RAW is original
|
||||
vol = (
|
||||
self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
|
||||
if self._info["raw_pair_info"]["volumeId"] is not None
|
||||
else None
|
||||
)
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path,
|
||||
self._info["raw_pair_info"]["imagePath"],
|
||||
)
|
||||
else:
|
||||
vol = self._info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["imagePath"]
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_edited(self):
|
||||
"""absolute path on disk of the edited picture"""
|
||||
@@ -251,14 +253,10 @@ class PhotoInfo:
|
||||
filename = None
|
||||
if self._info["type"] == _PHOTO_TYPE:
|
||||
# it's a photo
|
||||
if self._db._photos_ver == 5:
|
||||
filename = f"{self._uuid}_1_201_a.jpeg"
|
||||
if self._db._photos_ver != 5 and self.uti == "public.heic":
|
||||
filename = f"{self._uuid}_1_201_a.heic"
|
||||
else:
|
||||
# could be a heic or a jpeg
|
||||
if self.uti == "public.heic":
|
||||
filename = f"{self._uuid}_1_201_a.heic"
|
||||
else:
|
||||
filename = f"{self._uuid}_1_201_a.jpeg"
|
||||
filename = f"{self._uuid}_1_201_a.jpeg"
|
||||
elif self._info["type"] == _MOVIE_TYPE:
|
||||
# it's a movie
|
||||
filename = f"{self._uuid}_2_0_a.mov"
|
||||
@@ -344,6 +342,37 @@ class PhotoInfo:
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_edited_live_photo(self):
|
||||
"""return path to edited version of live photo movie; only valid for Photos 5+"""
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._path_edited_live_photo
|
||||
except AttributeError:
|
||||
self._path_edited_live_photo = self._path_edited_5_live_photo()
|
||||
return self._path_edited_live_photo
|
||||
|
||||
def _path_edited_5_live_photo(self):
|
||||
"""return path_edited_live_photo for Photos >= 5"""
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
raise RuntimeError("Wrong database format!")
|
||||
|
||||
if self.live_photo and self._info["hasAdjustments"]:
|
||||
library = self._db._library_path
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
filename = f"{self._uuid}_2_100_a.mov"
|
||||
photopath = os.path.join(
|
||||
library, "resources", "renders", directory, filename
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_raw(self):
|
||||
"""absolute path of associated RAW image or None if there is not one"""
|
||||
@@ -373,47 +402,59 @@ class PhotoInfo:
|
||||
# return photopath
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
vol = self._info["raw_info"]["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
return self._path_raw_4()
|
||||
|
||||
if not self.isreference:
|
||||
filestem = pathlib.Path(self._info["filename"]).stem
|
||||
raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
|
||||
# raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
|
||||
|
||||
if self._info["directory"].startswith("/"):
|
||||
filepath = self._info["directory"]
|
||||
else:
|
||||
filepath = os.path.join(self._db._masters_path, self._info["directory"])
|
||||
|
||||
glob_str = f"{filestem}*.{raw_ext}"
|
||||
# raw files have same name as original but with _4.raw_ext appended
|
||||
# I believe the _4 maps to PHAssetResourceTypeAlternatePhoto = 4
|
||||
# see: https://developer.apple.com/documentation/photokit/phassetresourcetype/phassetresourcetypealternatephoto?language=objc
|
||||
glob_str = f"{filestem}_4*"
|
||||
raw_file = findfiles(glob_str, filepath)
|
||||
if len(raw_file) != 1:
|
||||
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
|
||||
# that are missing do not always trigger is_missing = True as happens
|
||||
# in earlier version so it's possible for this check to fail, if so, return None
|
||||
logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
|
||||
if not raw_file:
|
||||
photopath = None
|
||||
else:
|
||||
photopath = os.path.join(filepath, raw_file[0])
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
photopath = pathlib.Path(filepath) / raw_file[0]
|
||||
photopath = str(photopath) if photopath.is_file() else None
|
||||
else:
|
||||
# is a reference
|
||||
try:
|
||||
photopath = (
|
||||
pathlib.Path("/Volumes")
|
||||
/ self._info["raw_volume"]
|
||||
/ self._info["raw_relative_path"]
|
||||
)
|
||||
photopath = str(photopath) if photopath.is_file() else None
|
||||
except KeyError:
|
||||
# don't have the path details
|
||||
photopath = None
|
||||
|
||||
return photopath
|
||||
|
||||
def _path_raw_4(self):
|
||||
"""Return path_raw for Photos <= version 4"""
|
||||
vol = self._info["raw_info"]["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""long / extended description of picture"""
|
||||
@@ -661,13 +702,23 @@ class PhotoInfo:
|
||||
"""Returns Uniform Type Identifier (UTI) for the original image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
||||
return self._info["raw_pair_info"]["UTI"]
|
||||
elif self.shared:
|
||||
# TODO: need reliable way to get original UTI for shared
|
||||
return self.uti
|
||||
else:
|
||||
return self._info["UTI_original"]
|
||||
try:
|
||||
return self._uti_original
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
||||
self._uti_original = self._info["raw_pair_info"]["UTI"]
|
||||
elif self.shared:
|
||||
# TODO: need reliable way to get original UTI for shared
|
||||
self._uti_original = self.uti
|
||||
elif self._db._photos_ver >= 7:
|
||||
# Monterey+
|
||||
self._uti_original = get_uti_for_extension(
|
||||
pathlib.Path(self.original_filename).suffix
|
||||
)
|
||||
else:
|
||||
self._uti_original = self._info["UTI_original"]
|
||||
|
||||
return self._uti_original
|
||||
|
||||
@property
|
||||
def uti_edited(self):
|
||||
@@ -686,7 +737,14 @@ class PhotoInfo:
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""
|
||||
return self._info["UTI_raw"]
|
||||
if self._db._photos_ver < 7:
|
||||
return self._info["UTI_raw"]
|
||||
|
||||
rawpath = self.path_raw
|
||||
if rawpath:
|
||||
return get_uti_for_extension(pathlib.Path(rawpath).suffix)
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
@@ -825,20 +883,35 @@ class PhotoInfo:
|
||||
@property
|
||||
def path_derivatives(self):
|
||||
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return self._path_derivatives_4()
|
||||
try:
|
||||
return self._path_derivatives
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._path_derivatives = self._path_derivatives_4()
|
||||
return self._path_derivatives
|
||||
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
derivative_path = (
|
||||
pathlib.Path(self._db._library_path)
|
||||
/ "resources"
|
||||
/ "derivatives"
|
||||
/ directory
|
||||
)
|
||||
files = derivative_path.glob(f"{self.uuid}*.*")
|
||||
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
|
||||
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
|
||||
return [str(filename) for filename in files if filename.suffix != ".THM"]
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
derivative_path = (
|
||||
pathlib.Path(self._db._library_path)
|
||||
/ "resources"
|
||||
/ "derivatives"
|
||||
/ directory
|
||||
)
|
||||
files = derivative_path.glob(f"{self.uuid}*.*")
|
||||
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
|
||||
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
|
||||
derivatives = [
|
||||
str(filename) for filename in files if filename.suffix != ".THM"
|
||||
]
|
||||
if (
|
||||
self.isphoto
|
||||
and len(derivatives) > 1
|
||||
and derivatives[0].endswith(".mov")
|
||||
):
|
||||
derivatives[1], derivatives[0] = derivatives[0], derivatives[1]
|
||||
|
||||
self._path_derivatives = derivatives
|
||||
return self._path_derivatives
|
||||
|
||||
def _path_derivatives_4(self):
|
||||
"""Return paths to all derivative (preview) files for Photos <= 4"""
|
||||
|
||||
@@ -34,7 +34,8 @@ from Foundation import NSNotificationCenter, NSObject
|
||||
from PyObjCTools import AppHelper
|
||||
|
||||
from .fileutil import FileUtil
|
||||
from .utils import _get_os_version, get_preferred_uti_extension, increment_filename
|
||||
from .uti import get_preferred_uti_extension
|
||||
from .utils import _get_os_version, increment_filename
|
||||
|
||||
# NOTE: This requires user have granted access to the terminal (e.g. Terminal.app or iTerm)
|
||||
# to access Photos. This should happen automatically the first time it's called. I've
|
||||
@@ -64,7 +65,7 @@ MIN_SLEEP = 0.015
|
||||
|
||||
### utility functions
|
||||
def NSURL_to_path(url):
|
||||
""" Convert URL string as represented by NSURL to a path string """
|
||||
"""Convert URL string as represented by NSURL to a path string"""
|
||||
nsurl = Foundation.NSURL.alloc().initWithString_(
|
||||
Foundation.NSString.alloc().initWithString_(str(url))
|
||||
)
|
||||
@@ -74,7 +75,7 @@ def NSURL_to_path(url):
|
||||
|
||||
|
||||
def path_to_NSURL(path):
|
||||
""" Convert path string to NSURL """
|
||||
"""Convert path string to NSURL"""
|
||||
pathstr = Foundation.NSString.alloc().initWithString_(str(path))
|
||||
url = Foundation.NSURL.fileURLWithPath_(pathstr)
|
||||
pathstr.dealloc()
|
||||
@@ -82,10 +83,10 @@ def path_to_NSURL(path):
|
||||
|
||||
|
||||
def check_photokit_authorization():
|
||||
""" Check authorization to use user's Photos Library
|
||||
"""Check authorization to use user's Photos Library
|
||||
|
||||
Returns:
|
||||
True if user has authorized access to the Photos library, otherwise False
|
||||
True if user has authorized access to the Photos library, otherwise False
|
||||
"""
|
||||
|
||||
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
|
||||
@@ -93,7 +94,7 @@ def check_photokit_authorization():
|
||||
|
||||
|
||||
def request_photokit_authorization():
|
||||
""" Request authorization to user's Photos Library
|
||||
"""Request authorization to user's Photos Library
|
||||
|
||||
Returns:
|
||||
authorization status
|
||||
@@ -135,39 +136,39 @@ def request_photokit_authorization():
|
||||
|
||||
### exceptions
|
||||
class PhotoKitError(Exception):
|
||||
"""Base class for exceptions in this module. """
|
||||
"""Base class for exceptions in this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PhotoKitFetchFailed(PhotoKitError):
|
||||
"""Exception raised for errors in the input. """
|
||||
"""Exception raised for errors in the input."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PhotoKitAuthError(PhotoKitError):
|
||||
"""Exception raised if unable to authorize use of PhotoKit. """
|
||||
"""Exception raised if unable to authorize use of PhotoKit."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PhotoKitExportError(PhotoKitError):
|
||||
"""Exception raised if unable to export asset. """
|
||||
"""Exception raised if unable to export asset."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PhotoKitMediaTypeError(PhotoKitError):
|
||||
""" Exception raised if an unknown mediaType() is encountered """
|
||||
"""Exception raised if an unknown mediaType() is encountered"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
### helper classes
|
||||
class ImageData:
|
||||
""" Simple class to hold the data passed to the handler for
|
||||
requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
"""Simple class to hold the data passed to the handler for
|
||||
requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -181,8 +182,7 @@ class ImageData:
|
||||
|
||||
|
||||
class AVAssetData:
|
||||
""" Simple class to hold the data passed to the handler for
|
||||
"""
|
||||
"""Simple class to hold the data passed to the handler for"""
|
||||
|
||||
def __init__(self):
|
||||
self.asset = None
|
||||
@@ -192,7 +192,7 @@ class AVAssetData:
|
||||
|
||||
|
||||
class PHAssetResourceData:
|
||||
""" Simple class to hold data from
|
||||
"""Simple class to hold data from
|
||||
requestDataForAssetResource:options:dataReceivedHandler:completionHandler:
|
||||
"""
|
||||
|
||||
@@ -211,8 +211,8 @@ class PHAssetResourceData:
|
||||
|
||||
|
||||
class PhotoKitNotificationDelegate(NSObject):
|
||||
""" Handles notifications from NotificationCenter;
|
||||
used with asynchronous PhotoKit requests to stop event loop when complete
|
||||
"""Handles notifications from NotificationCenter;
|
||||
used with asynchronous PhotoKit requests to stop event loop when complete
|
||||
"""
|
||||
|
||||
def liveNotification_(self, note):
|
||||
@@ -226,11 +226,11 @@ class PhotoKitNotificationDelegate(NSObject):
|
||||
|
||||
### main class implementation
|
||||
class PhotoAsset:
|
||||
""" PhotoKit PHAsset representation """
|
||||
"""PhotoKit PHAsset representation"""
|
||||
|
||||
def __init__(self, manager, phasset):
|
||||
""" Return a PhotoAsset object
|
||||
|
||||
"""Return a PhotoAsset object
|
||||
|
||||
Args:
|
||||
manager = ImageManager object
|
||||
phasset: a PHAsset object
|
||||
@@ -241,32 +241,32 @@ class PhotoAsset:
|
||||
|
||||
@property
|
||||
def phasset(self):
|
||||
""" Return PHAsset instance """
|
||||
"""Return PHAsset instance"""
|
||||
return self._phasset
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" Return local identifier (UUID) of PHAsset """
|
||||
"""Return local identifier (UUID) of PHAsset"""
|
||||
return self._phasset.localIdentifier()
|
||||
|
||||
@property
|
||||
def isphoto(self):
|
||||
""" Return True if asset is photo (image), otherwise False """
|
||||
"""Return True if asset is photo (image), otherwise False"""
|
||||
return self.media_type == Photos.PHAssetMediaTypeImage
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
""" Return True if asset is movie (video), otherwise False """
|
||||
"""Return True if asset is movie (video), otherwise False"""
|
||||
return self.media_type == Photos.PHAssetMediaTypeVideo
|
||||
|
||||
@property
|
||||
def isaudio(self):
|
||||
""" Return True if asset is audio, otherwise False """
|
||||
"""Return True if asset is audio, otherwise False"""
|
||||
return self.media_type == Photos.PHAssetMediaTypeAudio
|
||||
|
||||
@property
|
||||
def original_filename(self):
|
||||
""" Return original filename asset was imported with """
|
||||
"""Return original filename asset was imported with"""
|
||||
resources = self._resources()
|
||||
for resource in resources:
|
||||
if (
|
||||
@@ -278,10 +278,22 @@ class PhotoAsset:
|
||||
return resource.originalFilename()
|
||||
return None
|
||||
|
||||
@property
|
||||
def raw_filename(self):
|
||||
"""Return RAW filename for RAW+JPEG photos or None if no RAW asset"""
|
||||
resources = self._resources()
|
||||
for resource in resources:
|
||||
if (
|
||||
self.isphoto
|
||||
and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto
|
||||
):
|
||||
return resource.originalFilename()
|
||||
return None
|
||||
|
||||
@property
|
||||
def hasadjustments(self):
|
||||
""" Check to see if a PHAsset has adjustment data associated with it
|
||||
Returns False if no adjustments, True if any adjustments """
|
||||
"""Check to see if a PHAsset has adjustment data associated with it
|
||||
Returns False if no adjustments, True if any adjustments"""
|
||||
|
||||
# reference: https://developer.apple.com/documentation/photokit/phassetresource/1623988-assetresourcesforasset?language=objc
|
||||
|
||||
@@ -298,112 +310,112 @@ class PhotoAsset:
|
||||
|
||||
@property
|
||||
def media_type(self):
|
||||
""" media type such as image or video """
|
||||
"""media type such as image or video"""
|
||||
return self.phasset.mediaType()
|
||||
|
||||
@property
|
||||
def media_subtypes(self):
|
||||
""" media subtype """
|
||||
"""media subtype"""
|
||||
return self.phasset.mediaSubtypes()
|
||||
|
||||
@property
|
||||
def panorama(self):
|
||||
""" return True if asset is panorama, otherwise False """
|
||||
"""return True if asset is panorama, otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoPanorama)
|
||||
|
||||
@property
|
||||
def hdr(self):
|
||||
""" return True if asset is HDR, otherwise False """
|
||||
"""return True if asset is HDR, otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoHDR)
|
||||
|
||||
@property
|
||||
def screenshot(self):
|
||||
""" return True if asset is screenshot, otherwise False """
|
||||
"""return True if asset is screenshot, otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoScreenshot)
|
||||
|
||||
@property
|
||||
def live(self):
|
||||
""" return True if asset is live, otherwise False """
|
||||
"""return True if asset is live, otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoLive)
|
||||
|
||||
@property
|
||||
def streamed(self):
|
||||
""" return True if asset is streamed video, otherwise False """
|
||||
"""return True if asset is streamed video, otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoStreamed)
|
||||
|
||||
@property
|
||||
def slow_mo(self):
|
||||
""" return True if asset is slow motion (high frame rate) video, otherwise False """
|
||||
"""return True if asset is slow motion (high frame rate) video, otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoHighFrameRate)
|
||||
|
||||
@property
|
||||
def time_lapse(self):
|
||||
""" return True if asset is time lapse video, otherwise False """
|
||||
"""return True if asset is time lapse video, otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoTimelapse)
|
||||
|
||||
@property
|
||||
def portrait(self):
|
||||
""" return True if asset is portrait (depth effect), otherwise False """
|
||||
"""return True if asset is portrait (depth effect), otherwise False"""
|
||||
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoDepthEffect)
|
||||
|
||||
@property
|
||||
def burstid(self):
|
||||
""" return burstIdentifier of image if image is burst photo otherwise None """
|
||||
"""return burstIdentifier of image if image is burst photo otherwise None"""
|
||||
return self.phasset.burstIdentifier()
|
||||
|
||||
@property
|
||||
def burst(self):
|
||||
""" return True if image is burst otherwise False """
|
||||
"""return True if image is burst otherwise False"""
|
||||
return bool(self.burstid)
|
||||
|
||||
@property
|
||||
def source_type(self):
|
||||
""" the means by which the asset entered the user's library """
|
||||
"""the means by which the asset entered the user's library"""
|
||||
return self.phasset.sourceType()
|
||||
|
||||
@property
|
||||
def pixel_width(self):
|
||||
""" width in pixels """
|
||||
"""width in pixels"""
|
||||
return self.phasset.pixelWidth()
|
||||
|
||||
@property
|
||||
def pixel_height(self):
|
||||
""" height in pixels """
|
||||
"""height in pixels"""
|
||||
return self.phasset.pixelHeight()
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
""" date asset was created """
|
||||
"""date asset was created"""
|
||||
return self.phasset.creationDate()
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
""" date asset was modified """
|
||||
"""date asset was modified"""
|
||||
return self.phasset.modificationDate()
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
""" location of the asset """
|
||||
"""location of the asset"""
|
||||
return self.phasset.location()
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
""" duration of the asset """
|
||||
"""duration of the asset"""
|
||||
return self.phasset.duration()
|
||||
|
||||
@property
|
||||
def favorite(self):
|
||||
""" True if asset is favorite, otherwise False """
|
||||
"""True if asset is favorite, otherwise False"""
|
||||
return self.phasset.isFavorite()
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
""" True if asset is hidden, otherwise False """
|
||||
"""True if asset is hidden, otherwise False"""
|
||||
return self.phasset.isHidden()
|
||||
|
||||
def metadata(self, version=PHOTOS_VERSION_CURRENT):
|
||||
""" Return dict of asset metadata
|
||||
|
||||
"""Return dict of asset metadata
|
||||
|
||||
Args:
|
||||
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
|
||||
"""
|
||||
@@ -411,17 +423,28 @@ class PhotoAsset:
|
||||
return imagedata.metadata
|
||||
|
||||
def uti(self, version=PHOTOS_VERSION_CURRENT):
|
||||
""" Return UTI of asset
|
||||
|
||||
"""Return UTI of asset
|
||||
|
||||
Args:
|
||||
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
|
||||
"""
|
||||
imagedata = self._request_image_data(version=version)
|
||||
return imagedata.uti
|
||||
|
||||
def uti_raw(self):
|
||||
"""Return UTI of RAW component of RAW+JPEG pair"""
|
||||
resources = self._resources()
|
||||
for resource in resources:
|
||||
if (
|
||||
self.isphoto
|
||||
and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto
|
||||
):
|
||||
return resource.uniformTypeIdentifier()
|
||||
return None
|
||||
|
||||
def url(self, version=PHOTOS_VERSION_CURRENT):
|
||||
""" Return URL of asset
|
||||
|
||||
"""Return URL of asset
|
||||
|
||||
Args:
|
||||
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
|
||||
"""
|
||||
@@ -429,8 +452,8 @@ class PhotoAsset:
|
||||
return str(imagedata.info["PHImageFileURLKey"])
|
||||
|
||||
def path(self, version=PHOTOS_VERSION_CURRENT):
|
||||
""" Return path of asset
|
||||
|
||||
"""Return path of asset
|
||||
|
||||
Args:
|
||||
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
|
||||
"""
|
||||
@@ -439,8 +462,8 @@ class PhotoAsset:
|
||||
return url.fileSystemRepresentation().decode("utf-8")
|
||||
|
||||
def orientation(self, version=PHOTOS_VERSION_CURRENT):
|
||||
""" Return orientation of asset
|
||||
|
||||
"""Return orientation of asset
|
||||
|
||||
Args:
|
||||
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
|
||||
"""
|
||||
@@ -449,8 +472,8 @@ class PhotoAsset:
|
||||
|
||||
@property
|
||||
def degraded(self, version=PHOTOS_VERSION_CURRENT):
|
||||
""" Return True if asset is degraded version
|
||||
|
||||
"""Return True if asset is degraded version
|
||||
|
||||
Args:
|
||||
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
|
||||
"""
|
||||
@@ -458,15 +481,21 @@ class PhotoAsset:
|
||||
return imagedata.info["PHImageResultIsDegradedKey"]
|
||||
|
||||
def export(
|
||||
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
|
||||
self,
|
||||
dest,
|
||||
filename=None,
|
||||
version=PHOTOS_VERSION_CURRENT,
|
||||
overwrite=False,
|
||||
raw=False,
|
||||
):
|
||||
""" Export image to path
|
||||
"""Export image to path
|
||||
|
||||
Args:
|
||||
dest: str, path to destination directory
|
||||
filename: str, optional name of exported file; if not provided, defaults to asset's original filename
|
||||
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
|
||||
overwrite: bool, if True, overwrites destination file if it already exists; default is False
|
||||
raw: bool, if True, export RAW component of RAW+JPEG pair, default is False
|
||||
|
||||
Returns:
|
||||
List of path to exported image(s)
|
||||
@@ -491,11 +520,28 @@ class PhotoAsset:
|
||||
|
||||
output_file = None
|
||||
if self.isphoto:
|
||||
imagedata = self._request_image_data(version=version)
|
||||
if not imagedata.image_data:
|
||||
raise PhotoKitExportError("Could not get image data")
|
||||
|
||||
ext = get_preferred_uti_extension(imagedata.uti)
|
||||
# will hold exported image data and needs to be cleaned up at end
|
||||
imagedata = None
|
||||
if raw:
|
||||
# export the raw component
|
||||
resources = self._resources()
|
||||
for resource in resources:
|
||||
if resource.type() == Photos.PHAssetResourceTypeAlternatePhoto:
|
||||
data = self._request_resource_data(resource)
|
||||
ext = pathlib.Path(self.raw_filename).suffix[1:]
|
||||
break
|
||||
else:
|
||||
raise PhotoKitExportError(
|
||||
"Could not get image data for RAW photo"
|
||||
)
|
||||
else:
|
||||
# TODO: if user has selected use RAW as original, this returns the RAW
|
||||
# can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto
|
||||
imagedata = self._request_image_data(version=version)
|
||||
if not imagedata.image_data:
|
||||
raise PhotoKitExportError("Could not get image data")
|
||||
ext = get_preferred_uti_extension(imagedata.uti)
|
||||
data = imagedata.image_data
|
||||
|
||||
output_file = dest / f"{filename.stem}.{ext}"
|
||||
|
||||
@@ -503,7 +549,9 @@ class PhotoAsset:
|
||||
output_file = pathlib.Path(increment_filename(output_file))
|
||||
|
||||
with open(output_file, "wb") as fd:
|
||||
fd.write(imagedata.image_data)
|
||||
fd.write(data)
|
||||
|
||||
if imagedata:
|
||||
del imagedata
|
||||
elif self.ismovie:
|
||||
videodata = self._request_video_data(version=version)
|
||||
@@ -525,14 +573,14 @@ class PhotoAsset:
|
||||
return [str(output_file)]
|
||||
|
||||
def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
|
||||
""" Request image data and metadata for self._phasset
|
||||
|
||||
"""Request image data and metadata for self._phasset
|
||||
|
||||
Args:
|
||||
version: which version to request
|
||||
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
|
||||
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
|
||||
PHOTOS_VERSION_CURRENT, request current version with all edits
|
||||
PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version
|
||||
|
||||
|
||||
Returns:
|
||||
ImageData instance
|
||||
|
||||
@@ -562,8 +610,8 @@ class PhotoAsset:
|
||||
event = threading.Event()
|
||||
|
||||
def handler(imageData, dataUTI, orientation, info):
|
||||
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
|
||||
"""result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
|
||||
|
||||
nonlocal requestdata
|
||||
|
||||
@@ -593,19 +641,63 @@ class PhotoAsset:
|
||||
del requestdata
|
||||
return data
|
||||
|
||||
def _request_resource_data(self, resource):
|
||||
"""Request asset resource data (either photo or video component)
|
||||
|
||||
Args:
|
||||
resource: PHAssetResource to request
|
||||
|
||||
Raises:
|
||||
"""
|
||||
|
||||
with objc.autorelease_pool():
|
||||
resource_manager = Photos.PHAssetResourceManager.defaultManager()
|
||||
options = Photos.PHAssetResourceRequestOptions.alloc().init()
|
||||
options.setNetworkAccessAllowed_(True)
|
||||
|
||||
requestdata = PHAssetResourceData()
|
||||
event = threading.Event()
|
||||
|
||||
def handler(data):
|
||||
"""result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
|
||||
|
||||
nonlocal requestdata
|
||||
|
||||
requestdata.data += data
|
||||
|
||||
def completion_handler(error):
|
||||
if error:
|
||||
raise PhotoKitExportError(
|
||||
"Error requesting data for asset resource"
|
||||
)
|
||||
event.set()
|
||||
|
||||
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
|
||||
resource, options, handler, completion_handler
|
||||
)
|
||||
|
||||
event.wait()
|
||||
|
||||
# not sure why this is needed -- some weird ref count thing maybe
|
||||
# if I don't do this, memory leaks
|
||||
data = copy.copy(requestdata.data)
|
||||
del requestdata
|
||||
return data
|
||||
|
||||
def _make_result_handle_(self, data):
|
||||
""" Make handler function and threading event to use with
|
||||
requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
data: Fetchdata class to hold resulting metadata
|
||||
returns: handler function, threading.Event() instance
|
||||
Following call to requestImageDataAndOrientationForAsset_options_resultHandler_,
|
||||
data will hold data from the fetch """
|
||||
"""Make handler function and threading event to use with
|
||||
requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
data: Fetchdata class to hold resulting metadata
|
||||
returns: handler function, threading.Event() instance
|
||||
Following call to requestImageDataAndOrientationForAsset_options_resultHandler_,
|
||||
data will hold data from the fetch"""
|
||||
|
||||
event = threading.Event()
|
||||
|
||||
def handler(imageData, dataUTI, orientation, info):
|
||||
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
|
||||
"""result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
|
||||
|
||||
nonlocal data
|
||||
|
||||
@@ -626,14 +718,14 @@ class PhotoAsset:
|
||||
return handler, event
|
||||
|
||||
def _resources(self):
|
||||
""" Return list of PHAssetResource for object """
|
||||
"""Return list of PHAssetResource for object"""
|
||||
resources = Photos.PHAssetResource.assetResourcesForAsset_(self.phasset)
|
||||
return [resources.objectAtIndex_(idx) for idx in range(resources.count())]
|
||||
|
||||
|
||||
class SlowMoVideoExporter(NSObject):
|
||||
def initWithAVAsset_path_(self, avasset, path):
|
||||
""" init helper class for exporting slow-mo video
|
||||
"""init helper class for exporting slow-mo video
|
||||
|
||||
Args:
|
||||
avasset: AVAsset
|
||||
@@ -648,15 +740,17 @@ class SlowMoVideoExporter(NSObject):
|
||||
return self
|
||||
|
||||
def exportSlowMoVideo(self):
|
||||
""" export slow-mo video with AVAssetExportSession
|
||||
|
||||
"""export slow-mo video with AVAssetExportSession
|
||||
|
||||
Returns:
|
||||
path to exported file
|
||||
"""
|
||||
|
||||
with objc.autorelease_pool():
|
||||
exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
|
||||
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
|
||||
exporter = (
|
||||
AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
|
||||
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
|
||||
)
|
||||
)
|
||||
exporter.setOutputURL_(self.url)
|
||||
exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie)
|
||||
@@ -665,7 +759,7 @@ class SlowMoVideoExporter(NSObject):
|
||||
self.done = False
|
||||
|
||||
def handler():
|
||||
""" result handler for exportAsynchronouslyWithCompletionHandler """
|
||||
"""result handler for exportAsynchronouslyWithCompletionHandler"""
|
||||
self.done = True
|
||||
|
||||
exporter.exportAsynchronouslyWithCompletionHandler_(handler)
|
||||
@@ -699,7 +793,7 @@ class SlowMoVideoExporter(NSObject):
|
||||
|
||||
|
||||
class VideoAsset(PhotoAsset):
|
||||
""" PhotoKit PHAsset representation of video asset """
|
||||
"""PhotoKit PHAsset representation of video asset"""
|
||||
|
||||
# TODO: doesn't work for slow-mo videos
|
||||
# see https://stackoverflow.com/questions/26152396/how-to-access-nsdata-nsurl-of-slow-motion-videos-using-photokit
|
||||
@@ -709,7 +803,7 @@ class VideoAsset(PhotoAsset):
|
||||
def export(
|
||||
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
|
||||
):
|
||||
""" Export video to path
|
||||
"""Export video to path
|
||||
|
||||
Args:
|
||||
dest: str, path to destination directory
|
||||
@@ -765,7 +859,7 @@ class VideoAsset(PhotoAsset):
|
||||
def _export_slow_mo(
|
||||
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
|
||||
):
|
||||
""" Export slow-motion video to path
|
||||
"""Export slow-motion video to path
|
||||
|
||||
Args:
|
||||
dest: str, path to destination directory
|
||||
@@ -814,14 +908,14 @@ class VideoAsset(PhotoAsset):
|
||||
|
||||
# todo: rewrite this with NotificationCenter and App event loop?
|
||||
def _request_video_data(self, version=PHOTOS_VERSION_ORIGINAL):
|
||||
""" Request video data for self._phasset
|
||||
|
||||
"""Request video data for self._phasset
|
||||
|
||||
Args:
|
||||
version: which version to request
|
||||
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
|
||||
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
|
||||
PHOTOS_VERSION_CURRENT, request current version with all edits
|
||||
PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError if passed invalid value for version
|
||||
"""
|
||||
@@ -843,7 +937,7 @@ class VideoAsset(PhotoAsset):
|
||||
event = threading.Event()
|
||||
|
||||
def handler(asset, audiomix, info):
|
||||
""" result handler for requestAVAssetForVideo:asset options:options resultHandler """
|
||||
"""result handler for requestAVAssetForVideo:asset options:options resultHandler"""
|
||||
nonlocal requestdata
|
||||
|
||||
requestdata.asset = asset
|
||||
@@ -865,8 +959,8 @@ class VideoAsset(PhotoAsset):
|
||||
|
||||
|
||||
class LivePhotoRequest(NSObject):
|
||||
""" Manage requests for live photo assets
|
||||
See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc
|
||||
"""Manage requests for live photo assets
|
||||
See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc
|
||||
"""
|
||||
|
||||
def initWithManager_Asset_(self, manager, asset):
|
||||
@@ -879,7 +973,7 @@ class LivePhotoRequest(NSObject):
|
||||
return self
|
||||
|
||||
def requestLivePhotoResources(self, version=PHOTOS_VERSION_CURRENT):
|
||||
""" return the photos and video components of a live video as [PHAssetResource] """
|
||||
"""return the photos and video components of a live video as [PHAssetResource]"""
|
||||
|
||||
with objc.autorelease_pool():
|
||||
options = Photos.PHLivePhotoRequestOptions.alloc().init()
|
||||
@@ -897,7 +991,7 @@ class LivePhotoRequest(NSObject):
|
||||
self.live_photo = None
|
||||
|
||||
def handler(result, info):
|
||||
""" result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """
|
||||
"""result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler:"""
|
||||
if not info["PHImageResultIsDegradedKey"]:
|
||||
self.live_photo = result
|
||||
self.info = info
|
||||
@@ -939,7 +1033,7 @@ class LivePhotoRequest(NSObject):
|
||||
|
||||
|
||||
class LivePhotoAsset(PhotoAsset):
|
||||
""" Represents a live photo """
|
||||
"""Represents a live photo"""
|
||||
|
||||
def export(
|
||||
self,
|
||||
@@ -950,7 +1044,7 @@ class LivePhotoAsset(PhotoAsset):
|
||||
photo=True,
|
||||
video=True,
|
||||
):
|
||||
""" Export image to path
|
||||
"""Export image to path
|
||||
|
||||
Args:
|
||||
dest: str, path to destination directory
|
||||
@@ -1061,50 +1155,6 @@ class LivePhotoAsset(PhotoAsset):
|
||||
request.dealloc()
|
||||
return exported
|
||||
|
||||
def _request_resource_data(self, resource):
|
||||
""" Request asset resource data (either photo or video component)
|
||||
|
||||
Args:
|
||||
resource: PHAssetResource to request
|
||||
|
||||
Raises:
|
||||
"""
|
||||
|
||||
with objc.autorelease_pool():
|
||||
resource_manager = Photos.PHAssetResourceManager.defaultManager()
|
||||
options = Photos.PHAssetResourceRequestOptions.alloc().init()
|
||||
options.setNetworkAccessAllowed_(True)
|
||||
|
||||
requestdata = PHAssetResourceData()
|
||||
event = threading.Event()
|
||||
|
||||
def handler(data):
|
||||
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
|
||||
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
|
||||
|
||||
nonlocal requestdata
|
||||
|
||||
requestdata.data += data
|
||||
|
||||
def completion_handler(error):
|
||||
if error:
|
||||
raise PhotoKitExportError(
|
||||
"Error requesting data for asset resource"
|
||||
)
|
||||
event.set()
|
||||
|
||||
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
|
||||
resource, options, handler, completion_handler
|
||||
)
|
||||
|
||||
event.wait()
|
||||
|
||||
# not sure why this is needed -- some weird ref count thing maybe
|
||||
# if I don't do this, memory leaks
|
||||
data = copy.copy(requestdata.data)
|
||||
del requestdata
|
||||
return data
|
||||
|
||||
# def request_image_data(self, version=PHOTOS_VERSION_CURRENT):
|
||||
# # Returns an NSImage which isn't overly useful
|
||||
# # https://developer.apple.com/documentation/photokit/phimagemanager/1616964-requestimageforasset?language=objc
|
||||
@@ -1142,12 +1192,12 @@ class LivePhotoAsset(PhotoAsset):
|
||||
|
||||
|
||||
class PhotoLibrary:
|
||||
""" Interface to PhotoKit PHImageManager and PHPhotoLibrary """
|
||||
"""Interface to PhotoKit PHImageManager and PHPhotoLibrary"""
|
||||
|
||||
def __init__(self):
|
||||
""" Initialize ImageManager instance. Requests authorization to use the
|
||||
"""Initialize ImageManager instance. Requests authorization to use the
|
||||
Photos library if authorization has not already been granted.
|
||||
|
||||
|
||||
Raises:
|
||||
PhotoKitAuthError if unable to authorize access to PhotoKit
|
||||
"""
|
||||
@@ -1166,7 +1216,7 @@ class PhotoLibrary:
|
||||
self._phimagemanager = Photos.PHCachingImageManager.defaultManager()
|
||||
|
||||
def request_authorization(self):
|
||||
""" Request authorization to user's Photos Library
|
||||
"""Request authorization to user's Photos Library
|
||||
|
||||
Returns:
|
||||
authorization status
|
||||
@@ -1176,7 +1226,7 @@ class PhotoLibrary:
|
||||
return self.auth_status
|
||||
|
||||
def fetch_uuid_list(self, uuid_list):
|
||||
""" fetch PHAssets with uuids in uuid_list
|
||||
"""fetch PHAssets with uuids in uuid_list
|
||||
|
||||
Args:
|
||||
uuid_list: list of str (UUID of image assets to fetch)
|
||||
@@ -1205,7 +1255,7 @@ class PhotoLibrary:
|
||||
)
|
||||
|
||||
def fetch_uuid(self, uuid):
|
||||
""" fetch PHAsset with uuid = uuid
|
||||
"""fetch PHAsset with uuid = uuid
|
||||
|
||||
Args:
|
||||
uuid: str; UUID of image asset to fetch
|
||||
@@ -1223,8 +1273,8 @@ class PhotoLibrary:
|
||||
raise PhotoKitFetchFailed(f"Fetch did not return result for uuid {uuid}")
|
||||
|
||||
def fetch_burst_uuid(self, burstid, all=False):
|
||||
""" fetch PhotoAssets with burst ID = burstid
|
||||
|
||||
"""fetch PhotoAssets with burst ID = burstid
|
||||
|
||||
Args:
|
||||
burstid: str, burst UUID
|
||||
all: return all burst assets; if False returns only those selected by the user (including the "key photo" even if user hasn't manually selected it)
|
||||
@@ -1253,11 +1303,11 @@ class PhotoLibrary:
|
||||
)
|
||||
|
||||
def _asset_factory(self, phasset):
|
||||
""" creates a PhotoAsset, VideoAsset, or LivePhotoAsset
|
||||
"""creates a PhotoAsset, VideoAsset, or LivePhotoAsset
|
||||
|
||||
Args:
|
||||
phasset: PHAsset object
|
||||
|
||||
phasset: PHAsset object
|
||||
|
||||
Returns:
|
||||
PhotoAsset, VideoAsset, or LivePhotoAsset depending on type of PHAsset
|
||||
"""
|
||||
|
||||
@@ -17,6 +17,7 @@ from pprint import pformat
|
||||
from typing import List
|
||||
|
||||
import bitmath
|
||||
import photoscript
|
||||
|
||||
from .._constants import (
|
||||
_DB_TABLE_NAMES,
|
||||
@@ -246,6 +247,9 @@ class PhotosDB:
|
||||
# key is tuple of (original_filesize, date) and value is list of uuids that match that signature
|
||||
self._db_signatures = {}
|
||||
|
||||
# Dict to hold information on volume names (Photos 5+)
|
||||
self._db_filesystem_volumes = {}
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"dbfile = {dbfile}")
|
||||
|
||||
@@ -600,6 +604,8 @@ class PhotosDB:
|
||||
verbose("Processing database.")
|
||||
verbose(f"Database version: {self._db_version}.")
|
||||
|
||||
self._photos_ver = 4 # only used in Photos 5+
|
||||
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# get info to associate persons with photos
|
||||
@@ -784,6 +790,8 @@ class PhotosDB:
|
||||
"creation_date": album[8],
|
||||
"start_date": None, # Photos 5 only
|
||||
"end_date": None, # Photos 5 only
|
||||
"customsortascending": None, # Photos 5 only
|
||||
"customsortkey": None, # Photos 5 only
|
||||
}
|
||||
|
||||
# get details about folders
|
||||
@@ -1595,10 +1603,14 @@ class PhotosDB:
|
||||
verbose(f"Database version: {self._db_version}, {photos_ver}.")
|
||||
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
|
||||
keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
|
||||
asset_album_table = _DB_TABLE_NAMES[photos_ver]["ASSET_ALBUM_TABLE"]
|
||||
album_join = _DB_TABLE_NAMES[photos_ver]["ALBUM_JOIN"]
|
||||
album_sort = _DB_TABLE_NAMES[photos_ver]["ALBUM_SORT_ORDER"]
|
||||
asset_album_join = _DB_TABLE_NAMES[photos_ver]["ASSET_ALBUM_JOIN"]
|
||||
import_fok = _DB_TABLE_NAMES[photos_ver]["IMPORT_FOK"]
|
||||
depth_state = _DB_TABLE_NAMES[photos_ver]["DEPTH_STATE"]
|
||||
uti_original_column = _DB_TABLE_NAMES[photos_ver]["UTI_ORIGINAL"]
|
||||
hdr_type_column = _DB_TABLE_NAMES[photos_ver]["HDR_TYPE"]
|
||||
|
||||
# Look for all combinations of persons and pictures
|
||||
if _debug():
|
||||
@@ -1718,8 +1730,8 @@ class PhotosDB:
|
||||
{asset_table}.ZUUID,
|
||||
{album_sort}
|
||||
FROM {asset_table}
|
||||
JOIN Z_26ASSETS ON {album_join} = {asset_table}.Z_PK
|
||||
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS
|
||||
JOIN {asset_album_table} ON {album_join} = {asset_table}.Z_PK
|
||||
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = {asset_album_join}
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1757,7 +1769,9 @@ class PhotosDB:
|
||||
"ZTRASHEDSTATE, " # 9
|
||||
"ZCREATIONDATE, " # 10
|
||||
"ZSTARTDATE, " # 11
|
||||
"ZENDDATE " # 12
|
||||
"ZENDDATE, " # 12
|
||||
"ZCUSTOMSORTASCENDING, " # 13
|
||||
"ZCUSTOMSORTKEY " # 14
|
||||
"FROM ZGENERICALBUM "
|
||||
)
|
||||
for album in c:
|
||||
@@ -1777,6 +1791,8 @@ class PhotosDB:
|
||||
"creation_date": album[10],
|
||||
"start_date": album[11],
|
||||
"end_date": album[12],
|
||||
"customsortascending": album[13],
|
||||
"customsortkey": album[14],
|
||||
}
|
||||
|
||||
# add cross-reference by pk to uuid
|
||||
@@ -1886,7 +1902,7 @@ class PhotosDB:
|
||||
{asset_table}.ZAVALANCHEUUID,
|
||||
{asset_table}.ZAVALANCHEPICKTYPE,
|
||||
{asset_table}.ZKINDSUBTYPE,
|
||||
{asset_table}.ZCUSTOMRENDEREDVALUE,
|
||||
{asset_table}.{hdr_type_column},
|
||||
ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
|
||||
{asset_table}.ZCLOUDASSETGUID,
|
||||
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
|
||||
@@ -2250,20 +2266,33 @@ class PhotosDB:
|
||||
|
||||
# Get info on remote/local availability for photos in shared albums
|
||||
# Also get UTI of original image (zdatastoresubtype = 1)
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
if self._photos_ver >= 7:
|
||||
sql_missing = f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
|
||||
ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
|
||||
{uti_original_column},
|
||||
null
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
|
||||
else:
|
||||
sql_missing = f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
|
||||
{uti_original_column},
|
||||
ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
|
||||
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
|
||||
)
|
||||
|
||||
c.execute(sql_missing)
|
||||
|
||||
# Order of results:
|
||||
# 0 {asset_table}.ZUUID,
|
||||
@@ -2323,20 +2352,36 @@ class PhotosDB:
|
||||
|
||||
# get information about associted RAW images
|
||||
# RAW images have ZDATASTORESUBTYPE = 17
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
if self._photos_ver >= 7:
|
||||
sql_raw = f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZDATALENGTH,
|
||||
null,
|
||||
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
|
||||
ZINTERNALRESOURCE.ZRESOURCETYPE,
|
||||
ZINTERNALRESOURCE.ZFILESYSTEMBOOKMARK
|
||||
FROM {asset_table}
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
|
||||
"""
|
||||
else:
|
||||
sql_raw = f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZDATALENGTH,
|
||||
ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER,
|
||||
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
|
||||
ZINTERNALRESOURCE.ZRESOURCETYPE
|
||||
ZINTERNALRESOURCE.ZRESOURCETYPE,
|
||||
ZINTERNALRESOURCE.ZFILESYSTEMBOOKMARK
|
||||
FROM {asset_table}
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
|
||||
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
|
||||
"""
|
||||
)
|
||||
"""
|
||||
|
||||
c.execute(sql_raw)
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
if uuid in self._dbphotos:
|
||||
@@ -2345,6 +2390,33 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["UTI_raw"] = row[2]
|
||||
self._dbphotos[uuid]["datastore_subtype"] = row[3]
|
||||
self._dbphotos[uuid]["resource_type"] = row[4]
|
||||
self._dbphotos[uuid]["raw_bookmark"] = row[5]
|
||||
|
||||
# get paths for the relative imports for RAW+JPEG images
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZFILESYSTEMVOLUME.ZNAME,
|
||||
ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME
|
||||
FROM {asset_table}
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZFILESYSTEMBOOKMARK ON ZFILESYSTEMBOOKMARK.ZRESOURCE = ZINTERNALRESOURCE.Z_PK
|
||||
JOIN ZFILESYSTEMVOLUME ON ZFILESYSTEMVOLUME.Z_PK = ZINTERNALRESOURCE.ZFILESYSTEMVOLUME
|
||||
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
|
||||
"""
|
||||
)
|
||||
|
||||
# path to the raw image will be /Volumes/ZFILESYSTEMVOLUME.ZNAME/ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME
|
||||
# 0: {asset_table}.ZUUID, -- UUID
|
||||
# 1: ZFILESYSTEMVOLUME.ZNAME, -- name of the volume
|
||||
# 2: ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME -- path to the raw image
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
if uuid in self._dbphotos:
|
||||
self._dbphotos[uuid]["raw_volume"] = row[1]
|
||||
self._dbphotos[uuid]["raw_relative_path"] = row[2]
|
||||
|
||||
# add faces and keywords to photo data
|
||||
for uuid in self._dbphotos:
|
||||
@@ -3259,6 +3331,18 @@ class PhotosDB:
|
||||
elif options.no_location:
|
||||
photos = [p for p in photos if p.location == (None, None)]
|
||||
|
||||
if options.selected:
|
||||
# photos selected in Photos app
|
||||
try:
|
||||
# catch AppleScript errors as the scripting interfce to Photos is flaky
|
||||
selected = photoscript.PhotosLibrary().selection
|
||||
selected_uuid = [p.uuid for p in selected]
|
||||
photos = [p for p in photos if p.uuid in selected_uuid]
|
||||
except Exception:
|
||||
# no photos selected or a selected photo was "open"
|
||||
# selection only works if photos selected in main media browser
|
||||
photos = []
|
||||
|
||||
if options.function:
|
||||
for function in options.function:
|
||||
photos = function[0](photos)
|
||||
|
||||
@@ -6,6 +6,7 @@ import plistlib
|
||||
from .._constants import (
|
||||
_PHOTOS_5_MODEL_VERSION,
|
||||
_PHOTOS_6_MODEL_VERSION,
|
||||
_PHOTOS_7_MODEL_VERSION,
|
||||
_TESTED_DB_VERSIONS,
|
||||
)
|
||||
from ..utils import _open_sql_file
|
||||
@@ -73,12 +74,12 @@ def get_db_model_version(db_file):
|
||||
|
||||
model_ver = get_model_version(db_file)
|
||||
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
|
||||
db_ver = 5
|
||||
return 5
|
||||
elif _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
|
||||
db_ver = 6
|
||||
return 6
|
||||
elif _PHOTOS_7_MODEL_VERSION[0] <= model_ver <= _PHOTOS_7_MODEL_VERSION[1]:
|
||||
return 7
|
||||
else:
|
||||
logging.warning(f"Unknown model version: {model_ver}")
|
||||
# cross our fingers and try latest version
|
||||
db_ver = 6
|
||||
|
||||
return db_ver
|
||||
return 7
|
||||
|
||||
@@ -250,6 +250,7 @@ class RenderOptions:
|
||||
strip: if True, strips leading/trailing whitespace from rendered templates
|
||||
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
|
||||
export_dir: set to the export directory if you want to evalute {export_dir} template
|
||||
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
|
||||
"""
|
||||
@@ -263,6 +264,7 @@ class RenderOptions:
|
||||
strip: bool = False
|
||||
edited_version: bool = False
|
||||
export_dir: Optional[str] = None
|
||||
dest_path: Optional[str] = None
|
||||
filepath: Optional[str] = None
|
||||
quote: bool = False
|
||||
|
||||
@@ -354,8 +356,10 @@ class PhotoTemplate:
|
||||
self.dirname = options.dirname
|
||||
self.strip = options.strip
|
||||
self.export_dir = options.export_dir
|
||||
self.dest_path = options.dest_path
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
self.options = options
|
||||
|
||||
try:
|
||||
model = self.parser.parse(template)
|
||||
@@ -1182,7 +1186,7 @@ class PhotoTemplate:
|
||||
raise ValueError(f"'{filename}' does not appear to be a file")
|
||||
|
||||
template_func = load_function(filename_validated, funcname)
|
||||
values = template_func(self.photo)
|
||||
values = template_func(self.photo, options=self.options)
|
||||
|
||||
if not isinstance(values, (str, list)):
|
||||
raise TypeError(
|
||||
|
||||
@@ -83,6 +83,7 @@ class QueryOptions:
|
||||
location: Optional[bool] = None
|
||||
no_location: Optional[bool] = None
|
||||
function: Optional[List[Tuple[callable, str]]] = None
|
||||
selected: Optional[bool] = None
|
||||
|
||||
def asdict(self):
|
||||
return asdict(self)
|
||||
|
||||
@@ -238,6 +238,12 @@ To export only photos contained in the album "Summer Vacation":
|
||||
|
||||
`osxphotos export /path/to/export --album "Summer Vacation"`
|
||||
|
||||
In Photos, it's possible to have multiple albums with the same name. In this case, osxphotos would export photos from all albums matching the value passed to `--album`. If you wanted to export only one of the albums and this album is in a folder, the `--regex` option (short for "regular expression"), which does pattern matching, could be used with the `{folder_album}` template to match the specific album. For example, if you had a "Summer Vacation" album inside the folder "2018" and also one with the same name inside the folder "2019", you could export just the album "2018/Summer Vacation" using this command:
|
||||
|
||||
`osxphotos export /path/to/export --regex "2018/Summer Vacation" "{folder_album}"`
|
||||
|
||||
This command matches the pattern "2018/Summer Vacation" against the full folder/album path for every photo.
|
||||
|
||||
There are also a number of query options to export only certain types of photos. For example, to export only photos taken with iPhone "Portrait Mode":
|
||||
|
||||
`osxphotos export /path/to/export --portrait`
|
||||
|
||||
621
osxphotos/uti.py
Normal file
621
osxphotos/uti.py
Normal file
@@ -0,0 +1,621 @@
|
||||
""" get UTI for a given file extension and the preferred extension for a given UTI """
|
||||
|
||||
""" Implementation note: runs only on macOS
|
||||
|
||||
On macOS <= 11 (Big Sur), uses objective C CoreServices methods
|
||||
UTTypeCopyPreferredTagWithClass and UTTypeCreatePreferredIdentifierForTag to retrieve the
|
||||
UTI and the extension. These are deprecated in 10.15 (Catalina) and no longer supported on Monterey.
|
||||
|
||||
On Monterey, these calls are replaced with Swift methods that I can't call from python so
|
||||
this code uses a cached dict of UTI values. The code first checks to see if the extension or UTI
|
||||
is available in the cache and if so, returns it. If not, it performs a subprocess call to `mdls` to
|
||||
retrieve the UTI (by creating a temp file with the correct extension) and returns the UTI. This only
|
||||
works for the extension -> UTI lookup. On Monterey, if there is no cached value for UTI -> extension lookup,
|
||||
returns None.
|
||||
|
||||
It's a bit hacky but best I can think of to make this robust on different versions of macOS. PRs welcome.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import CoreServices
|
||||
import objc
|
||||
|
||||
from .utils import _get_os_version
|
||||
|
||||
# cached values of all the UTIs (< 6 chars long) known to my Mac running macOS 10.15.7
|
||||
UTI_CSV = """extension,UTI,preferred_extension,MIME_type
|
||||
c,public.c-source,c,None
|
||||
f,public.fortran-source,f,None
|
||||
h,public.c-header,h,None
|
||||
i,public.c-source.preprocessed,i,None
|
||||
l,public.lex-source,l,None
|
||||
m,public.objective-c-source,m,None
|
||||
o,public.object-code,o,None
|
||||
r,com.apple.rez-source,r,None
|
||||
s,public.assembly-source,s,None
|
||||
y,public.yacc-source,y,None
|
||||
z,public.z-archive,z,application/x-compress
|
||||
aa,com.audible.aa-audiobook,aa,audio/audible
|
||||
ai,com.adobe.illustrator.ai-image,ai,None
|
||||
as,com.apple.applesingle-archive,as,None
|
||||
au,public.au-audio,au,audio/basic
|
||||
bz,public.bzip2-archive,bz2,application/x-bzip2
|
||||
cc,public.c-plus-plus-source,cp,None
|
||||
cp,public.c-plus-plus-source,cp,None
|
||||
dv,public.dv-movie,dv,video/x-dv
|
||||
gz,org.gnu.gnu-zip-archive,gz,application/x-gzip
|
||||
hh,public.c-plus-plus-header,hh,None
|
||||
hp,public.c-plus-plus-header,hh,None
|
||||
ii,public.c-plus-plus-source.preprocessed,ii,None
|
||||
js,com.netscape.javascript-source,js,text/javascript
|
||||
lm,public.lex-source,l,None
|
||||
mi,public.objective-c-source.preprocessed,mi,None
|
||||
mm,public.objective-c-plus-plus-source,mm,None
|
||||
pf,com.apple.colorsync-profile,icc,None
|
||||
pl,public.perl-script,pl,text/x-perl-script
|
||||
pm,public.perl-script,pl,text/x-perl-script
|
||||
ps,com.adobe.postscript,ps,application/postscript
|
||||
py,public.python-script,py,text/x-python-script
|
||||
qt,com.apple.quicktime-movie,mov,video/quicktime
|
||||
ra,com.real.realaudio,ram,audio/vnd.rn-realaudio
|
||||
rb,public.ruby-script,rb,text/x-ruby-script
|
||||
rm,com.real.realmedia,rm,application/vnd.rn-realmedia
|
||||
sh,public.shell-script,sh,None
|
||||
ts,public.mpeg-2-transport-stream,ts,None
|
||||
ul,public.ulaw-audio,ul,None
|
||||
uu,public.uuencoded-archive,uu,text/x-uuencode
|
||||
wm,com.microsoft.windows-media-wm,wm,video/x-ms-wm
|
||||
ym,public.yacc-source,y,None
|
||||
aac,public.aac-audio,aac,audio/aac
|
||||
aae,com.apple.photos.apple-adjustment-envelope,aae,None
|
||||
aaf,org.aafassociation.advanced-authoring-format,aaf,None
|
||||
aax,com.audible.aax-audiobook,aax,audio/vnd.audible.aax
|
||||
abc,public.alembic,abc,None
|
||||
ac3,public.ac3-audio,ac3,audio/ac3
|
||||
ada,public.ada-source,ada,None
|
||||
adb,public.ada-source,ada,None
|
||||
ads,public.ada-source,ada,None
|
||||
aif,public.aifc-audio,aifc,audio/aiff
|
||||
amr,org.3gpp.adaptive-multi-rate-audio,amr,audio/amr
|
||||
app,com.apple.application-bundle,app,None
|
||||
arw,com.sony.arw-raw-image,arw,None
|
||||
asf,com.microsoft.advanced-systems-format,asf,video/x-ms-asf
|
||||
asx,com.microsoft.advanced-stream-redirector,asx,video/x-ms-asx
|
||||
avi,public.avi,avi,video/avi
|
||||
bdm,public.avchd-content,bdm,None
|
||||
bin,com.apple.macbinary-archive,bin,application/macbinary
|
||||
bmp,com.microsoft.bmp,bmp,image/bmp
|
||||
bwf,com.microsoft.waveform-audio,wav,audio/vnd.wave
|
||||
bz2,public.bzip2-archive,bz2,application/x-bzip2
|
||||
caf,com.apple.coreaudio-format,caf,None
|
||||
cdr,com.apple.disk-image-cdr,dvdr,None
|
||||
cel,public.flc-animation,flc,video/flc
|
||||
cer,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
cpp,public.c-plus-plus-source,cp,None
|
||||
crt,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
crw,com.canon.crw-raw-image,crw,image/x-canon-crw
|
||||
cr2,com.canon.cr2-raw-image,cr2,None
|
||||
cr3,com.canon.cr3-raw-image,cr3,None
|
||||
csh,public.csh-script,csh,None
|
||||
css,public.css,css,text/css
|
||||
csv,public.comma-separated-values-text,csv,text/csv
|
||||
cxx,public.c-plus-plus-source,cp,None
|
||||
dae,org.khronos.collada.digital-asset-exchange,dae,None
|
||||
dcm,org.nema.dicom,dcm,application/dicom
|
||||
dcr,com.kodak.raw-image,dcr,None
|
||||
dds,com.microsoft.dds,dds,None
|
||||
der,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
dif,public.dv-movie,dv,video/x-dv
|
||||
dll,com.microsoft.windows-dynamic-link-library,dll,application/x-msdownload
|
||||
dls,public.downloadable-sound,dls,audio/dls
|
||||
dmg,com.apple.disk-image-udif,dmg,None
|
||||
dng,com.adobe.raw-image,dng,image/x-adobe-dng
|
||||
doc,com.microsoft.word.doc,doc,application/msword
|
||||
dot,com.microsoft.word.dot,dot,application/msword
|
||||
dxo,com.dxo.raw-image,dxo,image/x-dxo-dxo
|
||||
ec3,public.enhanced-ac3-audio,eac3,audio/eac3
|
||||
edn,com.adobe.edn,edn,None
|
||||
efx,com.j2.efx-fax,efx,image/efax
|
||||
eml,com.apple.mail.email,eml,message/rfc822
|
||||
eps,com.adobe.encapsulated-postscript,eps,None
|
||||
erf,com.epson.raw-image,erf,image/x-epson-erf
|
||||
etd,com.adobe.etd,etd,None
|
||||
exe,com.microsoft.windows-executable,exe,application/x-msdownload
|
||||
exp,com.apple.symbol-export,exp,None
|
||||
exr,com.ilm.openexr-image,exr,None
|
||||
fdf,com.adobe.fdf,fdf,None
|
||||
fff,com.hasselblad.fff-raw-image,fff,None
|
||||
flc,public.flc-animation,flc,video/flc
|
||||
fli,public.flc-animation,flc,video/flc
|
||||
flv,com.adobe.flash.video,flv,video/x-flv
|
||||
for,public.fortran-source,f,None
|
||||
fpx,com.kodak.flashpix-image,fpx,image/fpx
|
||||
f4a,com.adobe.flash.video,flv,video/x-flv
|
||||
f4b,com.adobe.flash.video,flv,video/x-flv
|
||||
f4p,com.adobe.flash.video,flv,video/x-flv
|
||||
f4v,com.adobe.flash.video,flv,video/x-flv
|
||||
f77,public.fortran-77-source,f77,None
|
||||
f90,public.fortran-90-source,f90,None
|
||||
f95,public.fortran-95-source,f95,None
|
||||
gif,com.compuserve.gif,gif,image/gif
|
||||
hdr,public.radiance,pic,None
|
||||
hpp,public.c-plus-plus-header,hh,None
|
||||
hqx,com.apple.binhex-archive,hqx,application/mac-binhex40
|
||||
htm,public.html,html,text/html
|
||||
hxx,public.c-plus-plus-header,hh,None
|
||||
iba,com.apple.ibooksauthor.pkgbook,iba,None
|
||||
icc,com.apple.colorsync-profile,icc,None
|
||||
icm,com.apple.colorsync-profile,icc,None
|
||||
ico,com.microsoft.ico,ico,image/vnd.microsoft.icon
|
||||
ics,com.apple.ical.ics,ics,text/calendar
|
||||
iig,com.apple.iig-source,iig,None
|
||||
iiq,com.phaseone.raw-image,iiq,None
|
||||
img,com.apple.disk-image-ndif,ndif,None
|
||||
inl,public.c-plus-plus-inline-header,inl,None
|
||||
ipa,com.apple.itunes.ipa,ipa,None
|
||||
ipp,public.c-plus-plus-header,hh,None
|
||||
ips,com.apple.ips,ips,None
|
||||
iso,public.iso-image,iso,None
|
||||
ite,com.apple.tv.ite,ite,None
|
||||
itl,com.apple.itunes.db,itl,None
|
||||
jar,com.sun.java-archive,jar,application/java-archive
|
||||
jav,com.sun.java-source,java,None
|
||||
jfx,com.j2.jfx-fax,jfx,None
|
||||
jpe,public.jpeg,jpeg,image/jpeg
|
||||
jpf,public.jpeg-2000,jp2,image/jp2
|
||||
jpg,public.jpeg,jpeg,image/jpeg
|
||||
jpx,public.jpeg-2000,jp2,image/jp2
|
||||
jp2,public.jpeg-2000,jp2,image/jp2
|
||||
j2c,public.jpeg-2000,jp2,image/jp2
|
||||
j2k,public.jpeg-2000,jp2,image/jp2
|
||||
kar,public.midi-audio,midi,audio/midi
|
||||
key,com.apple.iwork.keynote.key,key,None
|
||||
ksh,public.ksh-script,ksh,None
|
||||
kth,com.apple.iwork.keynote.kth,kth,None
|
||||
ktx,org.khronos.ktx,ktx,None
|
||||
lid,public.dylan-source,dlyan,None
|
||||
lmm,public.lex-source,l,None
|
||||
log,com.apple.log,log,None
|
||||
lpp,public.lex-source,l,None
|
||||
lxx,public.lex-source,l,None
|
||||
mid,public.midi-audio,midi,audio/midi
|
||||
mig,public.mig-source,defs,None
|
||||
mii,public.objective-c-plus-plus-source.preprocessed,mii,None
|
||||
mjs,com.netscape.javascript-source,js,text/javascript
|
||||
mnc,ca.mcgill.mni.bic.mnc,mnc,None
|
||||
mos,com.leafamerica.raw-image,mos,None
|
||||
mov,com.apple.quicktime-movie,mov,video/quicktime
|
||||
mpe,public.mpeg,mpg,video/mpeg
|
||||
mpg,public.mpeg,mpg,video/mpeg
|
||||
mpo,public.mpo-image,mpo,None
|
||||
mp2,public.mp2,mp2,None
|
||||
mp3,public.mp3,mp3,audio/mpeg
|
||||
mp4,public.mpeg-4,mp4,video/mp4
|
||||
mrw,com.konicaminolta.raw-image,mrw,None
|
||||
mts,public.avchd-mpeg-2-transport-stream,mts,None
|
||||
mxf,org.smpte.mxf,mxf,application/mxf
|
||||
m15,public.mpeg,mpg,video/mpeg
|
||||
m2v,public.mpeg-2-video,m2v,video/mpeg2
|
||||
m3u,public.m3u-playlist,m3u,audio/mpegurl
|
||||
m4a,com.apple.m4a-audio,m4a,audio/x-m4a
|
||||
m4b,com.apple.protected-mpeg-4-audio-b,m4b,None
|
||||
m4p,com.apple.protected-mpeg-4-audio,m4p,None
|
||||
m4r,com.apple.mpeg-4-ringtone,m4r,audio/x-m4r
|
||||
m4v,com.apple.m4v-video,m4v,video/x-m4v
|
||||
m75,public.mpeg,mpg,video/mpeg
|
||||
nef,com.nikon.raw-image,nef,None
|
||||
nii,gov.nih.nifti-1,nii,None
|
||||
nrw,com.nikon.nrw-raw-image,nrw,image/x-nikon-nrw
|
||||
obj,public.geometry-definition-format,obj,None
|
||||
odb,org.oasis-open.opendocument.database,odb,application/vnd.oasis.opendocument.database
|
||||
odc,org.oasis-open.opendocument.chart,odc,application/vnd.oasis.opendocument.chart
|
||||
odf,org.oasis-open.opendocument.formula,odf,application/vnd.oasis.opendocument.formula
|
||||
odg,org.oasis-open.opendocument.graphics,odg,application/vnd.oasis.opendocument.graphics
|
||||
odi,org.oasis-open.opendocument.image,odi,application/vnd.oasis.opendocument.image
|
||||
odm,org.oasis-open.opendocument.text-master,odm,application/vnd.oasis.opendocument.text-master
|
||||
odp,org.oasis-open.opendocument.presentation,odp,application/vnd.oasis.opendocument.presentation
|
||||
ods,org.oasis-open.opendocument.spreadsheet,ods,application/vnd.oasis.opendocument.spreadsheet
|
||||
odt,org.oasis-open.opendocument.text,odt,application/vnd.oasis.opendocument.text
|
||||
omf,com.avid.open-media-framework,omf,None
|
||||
orf,com.olympus.raw-image,orf,None
|
||||
otc,public.opentype-collection-font,otc,None
|
||||
otf,public.opentype-font,otf,None
|
||||
otg,org.oasis-open.opendocument.graphics-template,otg,application/vnd.oasis.opendocument.graphics-template
|
||||
oth,org.oasis-open.opendocument.text-web,oth,application/vnd.oasis.opendocument.text-web
|
||||
oti,org.oasis-open.opendocument.image-template,oti,application/vnd.oasis.opendocument.image-template
|
||||
otp,org.oasis-open.opendocument.presentation-template,otp,application/vnd.oasis.opendocument.presentation-template
|
||||
ots,org.oasis-open.opendocument.spreadsheet-template,ots,application/vnd.oasis.opendocument.spreadsheet-template
|
||||
ott,org.oasis-open.opendocument.text-template,ott,application/vnd.oasis.opendocument.text-template
|
||||
pas,public.pascal-source,pas,None
|
||||
pax,public.cpio-archive,cpio,None
|
||||
pbm,public.pbm,pbm,None
|
||||
pch,public.precompiled-c-header,pch,None
|
||||
pct,com.apple.pict,pict,image/pict
|
||||
pdf,com.adobe.pdf,pdf,application/pdf
|
||||
pef,com.pentax.raw-image,pef,None
|
||||
pem,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
pfa,com.adobe.postscript-pfa-font,pfa,None
|
||||
pfb,com.adobe.postscript-pfb-font,pfb,None
|
||||
pfm,public.pbm,pbm,None
|
||||
pfx,com.rsa.pkcs-12,p12,application/x-pkcs12
|
||||
pgm,public.pbm,pbm,None
|
||||
pgn,com.apple.chess.pgn,pgn,None
|
||||
php,public.php-script,php,text/php
|
||||
ph3,public.php-script,php,text/php
|
||||
ph4,public.php-script,php,text/php
|
||||
pic,com.apple.pict,pict,image/pict
|
||||
pkg,com.apple.installer-package-archive,pkg,None
|
||||
pls,public.pls-playlist,pls,audio/x-scpls
|
||||
ply,public.polygon-file-format,ply,None
|
||||
png,public.png,png,image/png
|
||||
pot,com.microsoft.powerpoint.pot,pot,application/vnd.ms-powerpoint
|
||||
ppm,public.pbm,pbm,None
|
||||
pps,com.microsoft.powerpoint.pps,pps,application/vnd.ms-powerpoint
|
||||
ppt,com.microsoft.powerpoint.ppt,ppt,application/vnd.ms-powerpoint
|
||||
psb,com.adobe.photoshop-large-image,psb,None
|
||||
psd,com.adobe.photoshop-image,psd,image/vnd.adobe.photoshop
|
||||
pvr,public.pvr,pvr,None
|
||||
pvt,com.apple.private.live-photo-bundle,pvt,None
|
||||
pwl,com.leica.pwl-raw-image,pwl,image/x-leica-pwl
|
||||
p12,com.rsa.pkcs-12,p12,application/x-pkcs12
|
||||
qti,com.apple.quicktime-image,qtif,image/x-quicktime
|
||||
qtz,com.apple.quartz-composer-composition,qtz,application/x-quartzcomposer
|
||||
raf,com.fuji.raw-image,raf,None
|
||||
ram,com.real.realaudio,ram,audio/vnd.rn-realaudio
|
||||
raw,com.panasonic.raw-image,raw,None
|
||||
rbw,public.ruby-script,rb,text/x-ruby-script
|
||||
rmp,com.apple.music.rmp-playlist,rmp,application/vnd.rn-rn_music_package
|
||||
rss,public.rss,rss,application/rss+xml
|
||||
rtf,public.rtf,rtf,text/rtf
|
||||
rwl,com.leica.rwl-raw-image,rwl,None
|
||||
rw2,com.panasonic.rw2-raw-image,rw2,None
|
||||
scc,com.scenarist.closed-caption,scc,None
|
||||
scn,com.apple.scenekit.scene,scn,None
|
||||
sda,org.openoffice.graphics,sxd,application/vnd.sun.xml.draw
|
||||
sdc,org.openoffice.spreadsheet,sxc,application/vnd.sun.xml.calc
|
||||
sdd,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
|
||||
sdp,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
|
||||
sdv,public.3gpp,3gp,video/3gpp
|
||||
sdw,org.openoffice.text,sxw,application/vnd.sun.xml.writer
|
||||
sd2,com.digidesign.sd2-audio,sd2,None
|
||||
sea,com.stuffit.archive.sit,sit,application/x-stuffit
|
||||
sf2,com.soundblaster.soundfont,sf2,None
|
||||
sgi,com.sgi.sgi-image,sgi,image/sgi
|
||||
sit,com.stuffit.archive.sit,sit,application/x-stuffit
|
||||
slm,com.apple.photos.slow-motion-video-sidecar,slm,None
|
||||
smf,public.midi-audio,midi,audio/midi
|
||||
smi,com.apple.disk-image-smi,smi,None
|
||||
snd,public.au-audio,au,audio/basic
|
||||
spx,com.apple.systemprofiler.document,spx,None
|
||||
srf,com.sony.raw-image,srf,None
|
||||
srw,com.samsung.raw-image,srw,None
|
||||
sr2,com.sony.sr2-raw-image,sr2,image/x-sony-sr2
|
||||
stc,org.openoffice.spreadsheet-template,stc,application/vnd.sun.xml.calc.template
|
||||
std,org.openoffice.graphics-template,std,application/vnd.sun.xml.draw.template
|
||||
sti,org.openoffice.presentation-template,sti,application/vnd.sun.xml.impress.template
|
||||
stl,public.standard-tesselated-geometry-format,stl,None
|
||||
stw,org.openoffice.text-template,stw,application/vnd.sun.xml.writer.template
|
||||
svg,public.svg-image,svg,image/svg+xml
|
||||
sxc,org.openoffice.spreadsheet,sxc,application/vnd.sun.xml.calc
|
||||
sxd,org.openoffice.graphics,sxd,application/vnd.sun.xml.draw
|
||||
sxg,org.openoffice.text-master,sxg,application/vnd.sun.xml.writer.global
|
||||
sxi,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
|
||||
sxm,org.openoffice.formula,sxm,application/vnd.sun.xml.math
|
||||
sxw,org.openoffice.text,sxw,application/vnd.sun.xml.writer
|
||||
tar,public.tar-archive,tar,application/x-tar
|
||||
tbz,public.tar-bzip2-archive,tbz2,None
|
||||
tga,com.truevision.tga-image,tga,image/targa
|
||||
tgz,org.gnu.gnu-zip-tar-archive,tgz,None
|
||||
tif,public.tiff,tiff,image/tiff
|
||||
tsv,public.tab-separated-values-text,tsv,text/tab-separated-values
|
||||
ttc,public.truetype-collection-font,ttc,None
|
||||
ttf,public.truetype-ttf-font,ttf,None
|
||||
txt,public.plain-text,txt,text/plain
|
||||
ulw,public.ulaw-audio,ul,None
|
||||
url,com.microsoft.internet-shortcut,url,None
|
||||
usd,com.pixar.universal-scene-description,usd,None
|
||||
vcf,public.vcard,vcf,text/directory
|
||||
vcs,com.apple.ical.vcs,vcs,text/x-vcalendar
|
||||
vfw,public.avi,avi,video/avi
|
||||
vtt,org.w3.webvtt,vtt,text/vtt
|
||||
war,com.sun.web-application-archive,war,None
|
||||
wav,com.microsoft.waveform-audio,wav,audio/vnd.wave
|
||||
wax,com.microsoft.windows-media-wax,wax,video/x-ms-wax
|
||||
web,com.getdropbox.dropbox.shortcut,web,None
|
||||
wma,com.microsoft.windows-media-wma,wma,video/x-ms-wma
|
||||
wmp,com.microsoft.windows-media-wmp,wmp,video/x-ms-wmp
|
||||
wmv,com.microsoft.windows-media-wmv,wmv,video/x-ms-wmv
|
||||
wmx,com.microsoft.windows-media-wmx,wmx,video/x-ms-wmx
|
||||
wvx,com.microsoft.windows-media-wvx,wvx,video/x-ms-wvx
|
||||
xar,com.apple.xar-archive,xar,None
|
||||
xbm,public.xbitmap-image,xbm,image/x-xbitmap
|
||||
xfd,public.xfd,xfd,None
|
||||
xht,public.xhtml,xhtml,application/xhtml+xml
|
||||
xip,com.apple.xip-archive,xip,None
|
||||
xla,com.microsoft.excel.xla,xla,None
|
||||
xls,com.microsoft.excel.xls,xls,application/vnd.ms-excel
|
||||
xlt,com.microsoft.excel.xlt,xlt,application/vnd.ms-excel
|
||||
xlw,com.microsoft.excel.xlw,xlw,application/vnd.ms-excel
|
||||
xml,public.xml,xml,application/xml
|
||||
xmp,com.seriflabs.xmp,xmp,application/rdf+xml
|
||||
xpc,com.apple.xpc-service,xpc,None
|
||||
yml,public.yaml,yml,application/x-yaml
|
||||
ymm,public.yacc-source,y,None
|
||||
ypp,public.yacc-source,y,None
|
||||
yxx,public.yacc-source,y,None
|
||||
zip,public.zip-archive,zip,application/zip
|
||||
zsh,public.zsh-script,zsh,None
|
||||
3fr,com.hasselblad.3fr-raw-image,3fr,None
|
||||
3gp,public.3gpp,3gp,video/3gpp
|
||||
3g2,public.3gpp2,3g2,video/3gpp2
|
||||
adts,public.aac-audio,aac,audio/aac
|
||||
ahap,com.apple.haptics.ahap,ahap,None
|
||||
aifc,public.aifc-audio,aifc,audio/aiff
|
||||
aiff,public.aifc-audio,aifc,audio/aiff
|
||||
astc,org.khronos.astc,astc,None
|
||||
avci,public.avci,avci,image/avci
|
||||
avcs,public.avcs,avcs,image/avcs
|
||||
band,com.apple.garageband.project,band,None
|
||||
bash,public.bash-script,bash,None
|
||||
bdmv,public.avchd-content,bdm,None
|
||||
book,com.apple.ibooksauthor.pkgbook,iba,None
|
||||
cdda,public.aifc-audio,aifc,audio/aiff
|
||||
chat,com.apple.ichat.transcript,ichat,None
|
||||
cpgz,com.apple.bom-compressed-cpio,cpgz,None
|
||||
cpio,public.cpio-archive,cpio,None
|
||||
dart,com.apple.disk-image-dart,dart,None
|
||||
dc42,com.apple.disk-image-dc42,dc42,None
|
||||
defs,public.mig-source,defs,None
|
||||
dext,com.apple.driver-extension,dext,None
|
||||
diff,public.patch-file,patch,None
|
||||
dist,com.apple.installer-distribution-package,dist,None
|
||||
docm,org.openxmlformats.wordprocessingml.document.macroenabled,docm,application/vnd.ms-word.document.macroenabled.12
|
||||
docx,org.openxmlformats.wordprocessingml.document,docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
dotm,org.openxmlformats.wordprocessingml.template.macroenabled,dotm,application/vnd.ms-word.template.macroenabled.12
|
||||
dotx,org.openxmlformats.wordprocessingml.template,dotx,application/vnd.openxmlformats-officedocument.wordprocessingml.template
|
||||
dsym,com.apple.xcode.dsym,dsym,None
|
||||
dvdr,com.apple.disk-image-cdr,dvdr,None
|
||||
eac3,public.enhanced-ac3-audio,eac3,audio/eac3
|
||||
emlx,com.apple.mail.emlx,emlx,None
|
||||
enex,com.evernote.enex,enex,None
|
||||
epub,org.idpf.epub-container,epub,application/epub+zip
|
||||
fh10,com.seriflabs.affinity,fh10,None
|
||||
fh11,com.seriflabs.affinity,fh10,None
|
||||
flac,org.xiph.flac,flac,audio/flac
|
||||
fpbf,com.apple.finder.burn-folder,fpbf,None
|
||||
game,com.apple.chess.game,game,None
|
||||
gdoc,com.google.gdoc,gdoc,None
|
||||
gtar,org.gnu.gnu-tar-archive,gtar,application/x-gtar
|
||||
gzip,org.gnu.gnu-zip-archive,gz,application/x-gzip
|
||||
hang,com.apple.hangreport,hang,None
|
||||
heic,public.heic,heic,image/heic
|
||||
heif,public.heif,heif,image/heif
|
||||
html,public.html,html,text/html
|
||||
hvpl,com.apple.music.visual,hvpl,None
|
||||
icbu,com.apple.ical.backup,icbu,None
|
||||
icns,com.apple.icns,icns,None
|
||||
ipsw,com.apple.itunes.ipsw,ipsw,None
|
||||
itlp,com.apple.music.itlp,itlp,None
|
||||
itms,com.apple.itunes.store-url,itms,None
|
||||
java,com.sun.java-source,java,None
|
||||
jnlp,com.sun.java-web-start,jnlp,application/x-java-jnlp-file
|
||||
jpeg,public.jpeg,jpeg,image/jpeg
|
||||
json,public.json,json,application/json
|
||||
latm,public.mp4a-loas,loas,None
|
||||
loas,public.mp4a-loas,loas,None
|
||||
lpdf,com.apple.localized-pdf-bundle,lpdf,None
|
||||
mbox,com.apple.mail.mbox,mbox,None
|
||||
menu,com.apple.menu-extra,menu,None
|
||||
midi,public.midi-audio,midi,audio/midi
|
||||
minc,ca.mcgill.mni.bic.mnc,mnc,None
|
||||
mpeg,public.mpeg,mpg,video/mpeg
|
||||
mpga,public.mp3,mp3,audio/mpeg
|
||||
mpg4,public.mpeg-4,mp4,video/mp4
|
||||
mpkg,com.apple.installer-package-archive,pkg,None
|
||||
m2ts,public.avchd-mpeg-2-transport-stream,mts,None
|
||||
m3u8,public.m3u-playlist,m3u,audio/mpegurl
|
||||
ndif,com.apple.disk-image-ndif,ndif,None
|
||||
note,com.apple.notes.note,note,None
|
||||
php3,public.php-script,php,text/php
|
||||
php4,public.php-script,php,text/php
|
||||
pict,com.apple.pict,pict,image/pict
|
||||
pntg,com.apple.macpaint-image,pntg,None
|
||||
potm,org.openxmlformats.presentationml.template.macroenabled,potm,application/vnd.ms-powerpoint.template.macroenabled.12
|
||||
potx,org.openxmlformats.presentationml.template,potx,application/vnd.openxmlformats-officedocument.presentationml.template
|
||||
ppsm,org.openxmlformats.presentationml.slideshow.macroenabled,ppsm,application/vnd.ms-powerpoint.slideshow.macroenabled.12
|
||||
ppsx,org.openxmlformats.presentationml.slideshow,ppsx,application/vnd.openxmlformats-officedocument.presentationml.slideshow
|
||||
pptm,org.openxmlformats.presentationml.presentation.macroenabled,pptm,application/vnd.ms-powerpoint.presentation.macroenabled.12
|
||||
pptx,org.openxmlformats.presentationml.presentation,pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||
pset,com.apple.pdf-printer-settings,pset,None
|
||||
qtif,com.apple.quicktime-image,qtif,image/x-quicktime
|
||||
rmvb,com.real.realmedia-vbr,rmvb,application/vnd.rn-realmedia-vbr
|
||||
rtfd,com.apple.rtfd,rtfd,None
|
||||
scnz,com.apple.scenekit.scene,scn,None
|
||||
scpt,com.apple.applescript.script,scpt,None
|
||||
shtm,public.html,html,text/html
|
||||
sidx,com.stuffit.archive.sidx,sidx,application/x-stuffitx-index
|
||||
sitx,com.stuffit.archive.sitx,sitx,application/x-stuffitx
|
||||
spin,com.apple.spinreport,spin,None
|
||||
suit,com.apple.font-suitcase,suit,None
|
||||
svgz,public.svg-image,svg,image/svg+xml
|
||||
tbz2,public.tar-bzip2-archive,tbz2,None
|
||||
tcsh,public.tcsh-script,tcsh,None
|
||||
term,com.apple.terminal.session,term,None
|
||||
text,public.plain-text,txt,text/plain
|
||||
tiff,public.tiff,tiff,image/tiff
|
||||
tool,com.apple.terminal.shell-script,command,None
|
||||
udif,com.apple.disk-image-udif,dmg,None
|
||||
ulaw,public.ulaw-audio,ul,None
|
||||
usda,com.pixar.universal-scene-description,usd,None
|
||||
usdc,com.pixar.universal-scene-description,usd,None
|
||||
usdz,com.pixar.universal-scene-description-mobile,usdz,model/vnd.usdz+zip
|
||||
vcal,com.apple.ical.vcs,vcs,text/x-vcalendar
|
||||
wave,com.microsoft.waveform-audio,wav,audio/vnd.wave
|
||||
wdgt,com.apple.dashboard-widget,wdgt,None
|
||||
webp,public.webp,webp,None
|
||||
xfdf,com.adobe.xfdf,xfdf,None
|
||||
xhtm,public.xhtml,xhtml,application/xhtml+xml
|
||||
xlsb,com.microsoft.excel.sheet.binary.macroenabled,xlsb,application/vnd.ms-excel.sheet.binary.macroenabled.12
|
||||
xlsm,org.openxmlformats.spreadsheetml.sheet.macroenabled,xlsm,application/vnd.ms-excel.sheet.macroenabled.12
|
||||
xlsx,org.openxmlformats.spreadsheetml.sheet,xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
xltm,org.openxmlformats.spreadsheetml.template.macroenabled,xltm,application/vnd.ms-excel.template.macroenabled.12
|
||||
xltx,org.openxmlformats.spreadsheetml.template,xltx,application/vnd.openxmlformats-officedocument.spreadsheetml.template
|
||||
yaml,public.yaml,yml,application/x-yaml
|
||||
3gpp,public.3gpp,3gp,video/3gpp
|
||||
3gp2,public.3gpp2,3g2,video/3gpp2
|
||||
abcdg,com.apple.addressbook.group,abcdg,None
|
||||
abcdp,com.apple.addressbook.person,abcdp,None
|
||||
afpub,com.seriflabs.affinitypublisher.document,afpub,None
|
||||
appex,com.apple.application-and-system-extension,appex,None
|
||||
avchd,public.avchd-collection,avchd,None
|
||||
blank,com.apple.preview.blank,blank,None
|
||||
class,com.sun.java-class,class,None
|
||||
crash,com.apple.crashreport,crash,None
|
||||
dfont,com.apple.truetype-datafork-suitcase-font,dfont,None
|
||||
dicom,org.nema.dicom,dcm,application/dicom
|
||||
distz,com.apple.installer-distribution-package,dist,None
|
||||
dlyan,public.dylan-source,dlyan,None
|
||||
dylib,com.apple.mach-o-dylib,dylib,None
|
||||
heics,public.heics,heics,image/heic-sequence
|
||||
heifs,public.heifs,heifs,image/heif-sequence
|
||||
ichat,com.apple.ichat.transcript,ichat,None
|
||||
pages,com.apple.iwork.pages.pages,pages,None
|
||||
panic,com.apple.panicreport,panic,None
|
||||
paper,com.getdropbox.dropbox.paper,paper,None
|
||||
patch,public.patch-file,patch,None
|
||||
phtml,public.php-script,php,text/php
|
||||
plist,com.apple.property-list,plist,None
|
||||
saver,com.apple.systempreference.screen-saver,saver,None
|
||||
scptd,com.apple.applescript.script-bundle,scptd,None
|
||||
sfont,com.apple.cfr-font,sfont,None
|
||||
shtml,public.html,html,text/html
|
||||
swift,public.swift-source,swift,None
|
||||
toast,com.roxio.disk-image-toast,toast,None
|
||||
vcard,public.vcard,vcf,text/directory
|
||||
wdmon,com.apple.wireless-diagnostics.wdmon,wdmon,None
|
||||
xhtml,public.xhtml,xhtml,application/xhtml+xml
|
||||
action,com.apple.automator-action,action,None
|
||||
afploc,com.apple.afp-internet-location,afploc,None
|
||||
"""
|
||||
|
||||
# load CSV separated uti data into dictionaries with key of extension and UTI
|
||||
EXT_UTI_DICT = {}
|
||||
UTI_EXT_DICT = {}
|
||||
|
||||
|
||||
def _load_uti_dict():
|
||||
"""load an initialize the cached UTI and extension dicts"""
|
||||
_reader = csv.DictReader(UTI_CSV.split("\n"), delimiter=",")
|
||||
for row in _reader:
|
||||
EXT_UTI_DICT[row["extension"]] = row["UTI"]
|
||||
UTI_EXT_DICT[row["UTI"]] = row["preferred_extension"]
|
||||
|
||||
|
||||
_load_uti_dict()
|
||||
|
||||
# OS version for determining which methods can be used
|
||||
OS_VER, OS_MAJOR, _ = (int(x) for x in _get_os_version())
|
||||
|
||||
|
||||
def _get_uti_from_mdls(extension):
|
||||
"""use mdls to get the UTI for a given extension on systems that don't support UTTypeCreatePreferredIdentifierForTag
|
||||
Returns: UTI or None if UTI cannot be determined"""
|
||||
|
||||
# mdls -name kMDItemContentType foo.3fr
|
||||
# kMDItemContentType = "com.hasselblad.3fr-raw-image"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix="." + extension) as temp:
|
||||
output = subprocess.check_output(
|
||||
[
|
||||
"/usr/bin/mdls",
|
||||
"-name",
|
||||
"kMDItemContentType",
|
||||
temp.name,
|
||||
]
|
||||
).splitlines()
|
||||
output = output[0].decode("UTF-8") if output else None
|
||||
if not output:
|
||||
return None
|
||||
|
||||
match = re.match(r'kMDItemContentType\s+\=\s+"(.*)"', output)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _get_uti_from_ext_dict(ext):
|
||||
try:
|
||||
return EXT_UTI_DICT[ext]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_ext_from_uti_dict(uti):
|
||||
try:
|
||||
return UTI_EXT_DICT[uti]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def get_preferred_uti_extension(uti):
|
||||
"""get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str or None if cannot be determined"""
|
||||
|
||||
if (OS_VER, OS_MAJOR) <= (10, 16):
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
# deprecated in Catalina+, likely won't work at all on macOS 12
|
||||
with objc.autorelease_pool():
|
||||
extension = CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
uti, CoreServices.kUTTagClassFilenameExtension
|
||||
)
|
||||
if extension:
|
||||
return extension
|
||||
|
||||
# on MacOS 10.12, HEIC files are not supported and UTTypeCopyPreferredTagWithClass will return None for HEIC
|
||||
if uti == "public.heic":
|
||||
return "HEIC"
|
||||
|
||||
return None
|
||||
|
||||
return _get_ext_from_uti_dict(uti)
|
||||
|
||||
|
||||
def get_uti_for_extension(extension):
|
||||
"""get UTI for a given file extension"""
|
||||
|
||||
# accepts extension with or without leading 0
|
||||
if extension[0] == ".":
|
||||
extension = extension[1:]
|
||||
|
||||
if (OS_VER, OS_MAJOR) <= (10, 16):
|
||||
# https://developer.apple.com/documentation/coreservices/1448939-uttypecreatepreferredidentifierf
|
||||
with objc.autorelease_pool():
|
||||
uti = CoreServices.UTTypeCreatePreferredIdentifierForTag(
|
||||
CoreServices.kUTTagClassFilenameExtension, extension, None
|
||||
)
|
||||
if uti:
|
||||
return uti
|
||||
|
||||
# on MacOS 10.12, HEIC files are not supported and UTTypeCopyPreferredTagWithClass will return None for HEIC
|
||||
if extension.lower() == "heic":
|
||||
return "public.heic"
|
||||
|
||||
return None
|
||||
|
||||
uti = _get_uti_from_ext_dict(extension)
|
||||
if uti:
|
||||
return uti
|
||||
|
||||
uti = _get_uti_from_mdls(extension)
|
||||
if uti:
|
||||
# cache the UTI
|
||||
EXT_UTI_DICT[extension.lower()] = uti
|
||||
UTI_EXT_DICT[uti] = extension.lower()
|
||||
return uti
|
||||
|
||||
return None
|
||||
@@ -19,8 +19,6 @@ from plistlib import load as plistload
|
||||
from typing import Callable
|
||||
|
||||
import CoreFoundation
|
||||
import CoreServices
|
||||
import objc
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
|
||||
@@ -265,27 +263,6 @@ def list_photo_libraries():
|
||||
return lib_list
|
||||
|
||||
|
||||
def get_preferred_uti_extension(uti):
|
||||
"""get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str or None if cannot be determined"""
|
||||
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
with objc.autorelease_pool():
|
||||
extension = CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
uti, CoreServices.kUTTagClassFilenameExtension
|
||||
)
|
||||
|
||||
if extension:
|
||||
return extension
|
||||
|
||||
# on MacOS 10.12, HEIC files are not supported and UTTypeCopyPreferredTagWithClass will return None for HEIC
|
||||
if uti == "public.heic":
|
||||
return "HEIC"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def findfiles(pattern, path_):
|
||||
"""Returns list of filenames from path_ matched by pattern
|
||||
shell pattern. Matching is case-insensitive.
|
||||
@@ -298,23 +275,6 @@ def findfiles(pattern, path_):
|
||||
return [name for name in os.listdir(path_) if rule.match(name)]
|
||||
|
||||
|
||||
# TODO: this doesn't always work, still looking for a way to
|
||||
# force Photos to open the library being operated on
|
||||
# def _open_photos_library_applescript(library_path):
|
||||
# """ Force Photos to open a specific library
|
||||
# library_path: path to the Photos library """
|
||||
# open_scpt = AppleScript(
|
||||
# f"""
|
||||
# on openLibrary
|
||||
# tell application "Photos"
|
||||
# open POSIX file "{library_path}"
|
||||
# end tell
|
||||
# end openLibrary
|
||||
# """
|
||||
# )
|
||||
# open_scpt.run()
|
||||
|
||||
|
||||
def _open_sql_file(dbname):
|
||||
"""opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor)"""
|
||||
|
||||
@@ -15,7 +15,7 @@ dataclasses==0.7;python_version<'3.7'
|
||||
wurlitzer==2.1.0
|
||||
photoscript==0.1.3
|
||||
toml==0.10.2
|
||||
osxmetadata==0.99.14
|
||||
osxmetadata==0.99.25
|
||||
textx==2.3.0
|
||||
rich==10.2.2
|
||||
bitmath==1.3.3.1
|
||||
|
||||
2
setup.py
2
setup.py
@@ -90,7 +90,7 @@ setup(
|
||||
"wurlitzer==2.1.0",
|
||||
"photoscript==0.1.3",
|
||||
"toml==0.10.2",
|
||||
"osxmetadata==0.99.14",
|
||||
"osxmetadata==0.99.25",
|
||||
"textx==2.3.0",
|
||||
"rich==10.2.2",
|
||||
"bitmath==1.3.3.1",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>585B80BF-8D1F-55EF-A9E8-6CF4E5523959</string>
|
||||
<key>pid</key>
|
||||
<integer>570</integer>
|
||||
<integer>1961</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,24 +3,24 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<date>2021-07-20T05:48:01Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2021-03-13T16:38:24Z</date>
|
||||
<date>2021-07-20T05:48:00Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<date>2021-07-20T07:05:31Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<date>2021-07-20T05:48:01Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2021-03-13T16:38:23Z</date>
|
||||
<date>2021-07-20T05:48:00Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<date>2021-07-20T05:48:01Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-10-17T23:45:33Z</date>
|
||||
<date>2021-07-20T05:48:08Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-10-17T23:45:24Z</date>
|
||||
<date>2021-07-20T05:47:59Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<date>2021-07-20T05:48:01Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<date>2021-07-20T05:48:01Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FaceIDModelLastGenerationKey</key>
|
||||
<date>2020-10-17T23:45:32Z</date>
|
||||
<date>2021-07-20T05:48:02Z</date>
|
||||
<key>LastContactClassificationKey</key>
|
||||
<date>2020-10-17T23:45:54Z</date>
|
||||
<date>2021-07-20T05:48:05Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>coalesceDate</key>
|
||||
<date>2020-04-11T19:26:12Z</date>
|
||||
<date>2021-07-20T12:21:20Z</date>
|
||||
<key>coalescePayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>currentPayloadVersion</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>coalesceDate</key>
|
||||
<date>2020-05-29T15:20:05Z</date>
|
||||
<date>2021-07-20T12:21:21Z</date>
|
||||
<key>coalescePayloadVersion</key>
|
||||
<integer>10</integer>
|
||||
<key>currentPayloadVersion</key>
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>coalesceDate</key>
|
||||
<date>2019-10-27T15:36:05Z</date>
|
||||
<date>2021-07-20T12:21:20Z</date>
|
||||
<key>coalescePayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>currentPayloadVersion</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>coalesceDate</key>
|
||||
<date>2020-05-29T15:20:05Z</date>
|
||||
<date>2021-07-20T12:21:21Z</date>
|
||||
<key>coalescePayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>currentPayloadVersion</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>coalesceDate</key>
|
||||
<date>2020-05-29T15:20:05Z</date>
|
||||
<date>2021-07-20T12:21:20Z</date>
|
||||
<key>coalescePayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>currentPayloadVersion</key>
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>coalesceDate</key>
|
||||
<date>2021-07-20T12:21:20Z</date>
|
||||
<key>coalescePayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>currentPayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>snapshotDate</key>
|
||||
|
||||
10
tests/Test-12.0.0.dev-beta.photoslibrary/database/DataModelVersion.plist
Executable file
10
tests/Test-12.0.0.dev-beta.photoslibrary/database/DataModelVersion.plist
Executable file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>5001</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite
Executable file
Binary file not shown.
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite-shm
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite-shm
Executable file
Binary file not shown.
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite-wal
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite-wal
Executable file
Binary file not shown.
16
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite.lock
Executable file
16
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite.lock
Executable file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>hostname</key>
|
||||
<string>ms-MacBook-Pro.local</string>
|
||||
<key>hostuuid</key>
|
||||
<string>793FB248-A75B-5F63-9A2E-76E4BFA8E877</string>
|
||||
<key>pid</key>
|
||||
<integer>391</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
<integer>501</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/metaSchema.db
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/metaSchema.db
Executable file
Binary file not shown.
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/photos.db
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/photos.db
Executable file
Binary file not shown.
0
tests/Test-12.0.0.dev-beta.photoslibrary/database/protection
Executable file
0
tests/Test-12.0.0.dev-beta.photoslibrary/database/protection
Executable file
Binary file not shown.
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/search/psi.sqlite
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/search/psi.sqlite
Executable file
Binary file not shown.
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/search/psi.sqlite-shm
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/search/psi.sqlite-shm
Executable file
Binary file not shown.
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>insertAlbum</key>
|
||||
<array/>
|
||||
<key>insertAsset</key>
|
||||
<array/>
|
||||
<key>insertHighlight</key>
|
||||
<array/>
|
||||
<key>insertMemory</key>
|
||||
<array/>
|
||||
<key>insertMoment</key>
|
||||
<array/>
|
||||
<key>removeAlbum</key>
|
||||
<array/>
|
||||
<key>removeAsset</key>
|
||||
<array/>
|
||||
<key>removeHighlight</key>
|
||||
<array/>
|
||||
<key>removeMemory</key>
|
||||
<array/>
|
||||
<key>removeMoment</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>embeddingVersion</key>
|
||||
<string>1</string>
|
||||
<key>localeIdentifier</key>
|
||||
<string>en_US</string>
|
||||
<key>sceneTaxonomySHA</key>
|
||||
<string>dd7ff94fd9919a493393b86581994e1db06a3553f80052c5e8343c0443848344</string>
|
||||
<key>searchIndexVersion</key>
|
||||
<string>15065</string>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/search/synonymsProcess.plist
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/search/synonymsProcess.plist
Executable file
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 577 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 500 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 524 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user