Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
64fd852535 | ||
|
|
3fbfc55e84 | ||
|
|
49317582c4 | ||
|
|
5ea01df69b | ||
|
|
4a9f8a9ef5 | ||
|
|
49adff1f3b | ||
|
|
377e165be4 | ||
|
|
07da8031c6 | ||
|
|
be363b9727 | ||
|
|
870a59a2fa | ||
|
|
500cf71f7e | ||
|
|
821e338b75 | ||
|
|
987c91a9ff | ||
|
|
233942c9b6 | ||
|
|
a0ab64a841 | ||
|
|
0cd8f32893 | ||
|
|
904acbc576 | ||
|
|
37dc023fcb | ||
|
|
876ff17e3f | ||
|
|
130df1a767 | ||
|
|
5d7dea3fc3 | ||
|
|
ca8397bc97 | ||
|
|
91023ac8ec | ||
|
|
0ad59e9e29 | ||
|
|
42c551de8a | ||
|
|
62d49a7138 | ||
|
|
bc5cd93e97 | ||
|
|
7bd1ba8075 |
@@ -222,6 +222,24 @@
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mkirkland4874",
|
||||
"name": "mkirkland4874",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36466711?v=4",
|
||||
"profile": "https://github.com/mkirkland4874",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jcommisso07",
|
||||
"name": "Joseph Commisso",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3111054?v=4",
|
||||
"profile": "https://github.com/jcommisso07",
|
||||
"contributions": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
3
.isort.cfg
Normal file
@@ -0,0 +1,3 @@
|
||||
[settings]
|
||||
profile=black
|
||||
multi_line_output=3
|
||||
102
CHANGELOG.md
@@ -4,6 +4,108 @@ 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.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
|
||||
|
||||
- Implemented --query-function, #430 [`07da803`](https://github.com/RhetTbull/osxphotos/commit/07da8031c63487eb42cb3e524f20971e6d2fc929)
|
||||
- Added query function [skip ci] [`be363b9`](https://github.com/RhetTbull/osxphotos/commit/be363b9727d6fca6e747b0d952cd3252ddfe6e3b)
|
||||
- Updated README.md [skip ci] [`377e165`](https://github.com/RhetTbull/osxphotos/commit/377e165be48b84c7678ca2f86fc2ffdcbcb93736)
|
||||
|
||||
#### [v0.42.44](https://github.com/RhetTbull/osxphotos/compare/v0.42.43...v0.42.44)
|
||||
|
||||
> 20 June 2021
|
||||
|
||||
- Added --location, --no-location, #474 [`870a59a`](https://github.com/RhetTbull/osxphotos/commit/870a59a2fa10766361b384216594af36d3605850)
|
||||
|
||||
#### [v0.42.43](https://github.com/RhetTbull/osxphotos/compare/v0.42.42...v0.42.43)
|
||||
|
||||
> 20 June 2021
|
||||
|
||||
- Implemented --post-function, #442 [`987c91a`](https://github.com/RhetTbull/osxphotos/commit/987c91a9ff4b9936d479d7d238a5e5b842265dec)
|
||||
- Added post_function.py [`233942c`](https://github.com/RhetTbull/osxphotos/commit/233942c9b6836fb6fa9907e9264ec3513322930b)
|
||||
- Fixed function names to work around Click.runner issue [`821e338`](https://github.com/RhetTbull/osxphotos/commit/821e338b7575c6e053b8d3d958c481dfa62a00bc)
|
||||
|
||||
#### [v0.42.42](https://github.com/RhetTbull/osxphotos/compare/v0.42.41...v0.42.42)
|
||||
|
||||
> 19 June 2021
|
||||
|
||||
- Bug fix for --download-missing, #456 [`0cd8f32`](https://github.com/RhetTbull/osxphotos/commit/0cd8f32893046b679ea6280822f4dba5aa7de1fd)
|
||||
- Updated README.md [skip ci] [`37dc023`](https://github.com/RhetTbull/osxphotos/commit/37dc023fcbfddca8abd2b72119138d72e0bfed53)
|
||||
- Added isort cfg to match black [`904acbc`](https://github.com/RhetTbull/osxphotos/commit/904acbc576b27d7d05d770e061a6c01a439b8fad)
|
||||
|
||||
#### [v0.42.41](https://github.com/RhetTbull/osxphotos/compare/v0.42.40...v0.42.41)
|
||||
|
||||
> 19 June 2021
|
||||
|
||||
- Added repl command to CLI; closes #472 [`#472`](https://github.com/RhetTbull/osxphotos/issues/472)
|
||||
- Updated README.md [skip ci] [`130df1a`](https://github.com/RhetTbull/osxphotos/commit/130df1a76794f77bc0e8f148185c6407d6b480bc)
|
||||
|
||||
#### [v0.42.40](https://github.com/RhetTbull/osxphotos/compare/v0.42.39...v0.42.40)
|
||||
|
||||
> 19 June 2021
|
||||
|
||||
- Added tutorial, closes #432 [`#432`](https://github.com/RhetTbull/osxphotos/issues/432)
|
||||
|
||||
#### [v0.42.39](https://github.com/RhetTbull/osxphotos/compare/v0.42.38...v0.42.39)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Updated help text, #469 [`42c551d`](https://github.com/RhetTbull/osxphotos/commit/42c551de8a1e6f682c04b6071c1147eb8039ed3a)
|
||||
|
||||
#### [v0.42.38](https://github.com/RhetTbull/osxphotos/compare/v0.42.37...v0.42.38)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Added error handling for --add-to-album [`bc5cd93`](https://github.com/RhetTbull/osxphotos/commit/bc5cd93e974214e2327d604ff92b3c6b6ce62f04)
|
||||
- Updated README.md [skip ci] [`62d49a7`](https://github.com/RhetTbull/osxphotos/commit/62d49a7138971c43625e55518f069b1b36b787ff)
|
||||
|
||||
#### [v0.42.37](https://github.com/RhetTbull/osxphotos/compare/v0.42.36...v0.42.37)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Added additional info to error message for --add-to-album [`64bb07a`](https://github.com/RhetTbull/osxphotos/commit/64bb07a0267f2fdd024a7150fe1788b07218ac2f)
|
||||
|
||||
#### [v0.42.36](https://github.com/RhetTbull/osxphotos/compare/v0.42.35...v0.42.36)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Fix for #471 [`8e3f8fc`](https://github.com/RhetTbull/osxphotos/commit/8e3f8fc7d089b644b85e8e52fe220519133d2bea)
|
||||
- Updated README.md [skip ci] [`f1902b7`](https://github.com/RhetTbull/osxphotos/commit/f1902b7fd4d22c47bcf9fd101b077bbbabb71a9a)
|
||||
|
||||
#### [v0.42.35](https://github.com/RhetTbull/osxphotos/compare/v0.42.34...v0.42.35)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
297
README.md
@@ -3,9 +3,7 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
|
||||

|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
[](#contributors)
|
||||
|
||||
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
|
||||
|
||||
@@ -50,11 +48,14 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
|
||||
|
||||
## Supported operating systems
|
||||
|
||||
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Big Sur (10.16/11.1).
|
||||
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Big Sur (10.16/11.3).
|
||||
|
||||
If you have access to the macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please visit the [Discussions](https://github.com/RhetTbull/osxphotos/discussions) page and let me know!
|
||||
|
||||
| macOS Version | macOS name | Photos.app version |
|
||||
| ----------------- |------------|:-------------------|
|
||||
| 10.16, 11.0-11.3 | Big Sur | 6.0 ✅ |
|
||||
| 12.0 | Monterey | ?.0 UNKNOWN |
|
||||
| 10.16, 11.0-11.4 | Big Sur | 6.0 ✅ |
|
||||
| 10.15.1 - 10.15.7 | Catalina | 5.0 ✅ |
|
||||
| 10.14.5, 10.14.6 | Mojave | 4.0 ✅ |
|
||||
| 10.13.6 | High Sierra| 3.0 ✅ |
|
||||
@@ -118,6 +119,7 @@ This package will install a command line utility called `osxphotos` that allows
|
||||
|
||||
```
|
||||
> osxphotos
|
||||
|
||||
Usage: osxphotos [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Options:
|
||||
@@ -146,6 +148,8 @@ Commands:
|
||||
persons Print out persons (faces) found in the Photos library.
|
||||
places Print out places found in the Photos library.
|
||||
query Query the Photos database using 1 or more search options; if...
|
||||
repl Run interactive osxphotos shell
|
||||
tutorial Display osxphotos tutorial.
|
||||
```
|
||||
|
||||
To get help on a specific command, use `osxphotos help <command_name>`
|
||||
@@ -562,13 +566,13 @@ osxphotos is very flexible. If you merely want to backup your Photos library, t
|
||||
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
||||
|
||||
Export photos from the Photos database. Export path DEST is required.
|
||||
Optionally, query the Photos database using 1 or more search options; if more
|
||||
than one option is provided, they are treated as "AND" (e.g. search for photos
|
||||
matching all options). If no query options are provided, all photos will be
|
||||
exported. By default, all versions of all photos will be exported including
|
||||
edited versions, live photo movies, burst photos, and associated raw images.
|
||||
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options to
|
||||
modify this behavior.
|
||||
Optionally, query the Photos database using 1 or more search options; if
|
||||
more than one option is provided, they are treated as "AND" (e.g. search for
|
||||
photos matching all options). If no query options are provided, all photos
|
||||
will be exported. By default, all versions of all photos will be exported
|
||||
including edited versions, live photo movies, burst photos, and associated
|
||||
raw images. See --skip-edited, --skip-live, --skip-bursts, and --skip-raw
|
||||
options to modify this behavior.
|
||||
|
||||
Options:
|
||||
--db <Photos database path> Specify Photos database path. Path to Photos
|
||||
@@ -579,45 +583,63 @@ Options:
|
||||
use in the following order: 1. last opened
|
||||
library, 2. system library, 3.
|
||||
~/Pictures/Photos Library.photoslibrary
|
||||
|
||||
-V, --verbose Print verbose output.
|
||||
--keyword KEYWORD Search for photos with keyword KEYWORD. If
|
||||
more than one keyword, treated as "OR", e.g.
|
||||
find photos matching any keyword
|
||||
|
||||
--person PERSON Search for photos with person PERSON. If more
|
||||
than one person, treated as "OR", e.g. find
|
||||
photos matching any person
|
||||
|
||||
--album ALBUM Search for photos in album ALBUM. If more than
|
||||
one album, treated as "OR", e.g. find photos
|
||||
matching any album
|
||||
|
||||
--folder FOLDER Search for photos in an album in folder
|
||||
FOLDER. If more than one folder, treated as
|
||||
"OR", e.g. find photos in any FOLDER. Only
|
||||
searches top level folders (e.g. does not look
|
||||
at subfolders)
|
||||
|
||||
--name FILENAME Search for photos with filename matching
|
||||
FILENAME. If more than one --name options is
|
||||
specified, they are treated as "OR", e.g. find
|
||||
photos matching any FILENAME.
|
||||
|
||||
--uuid UUID Search for photos with UUID(s).
|
||||
--uuid-from-file FILE Search for photos with UUID(s) loaded from
|
||||
FILE. Format is a single UUID per line. Lines
|
||||
preceded with # are ignored.
|
||||
|
||||
--title TITLE Search for TITLE in title of photo.
|
||||
--no-title Search for photos with no title.
|
||||
--description DESC Search for DESC in description of photo.
|
||||
--no-description Search for photos with no description.
|
||||
--place PLACE Search for PLACE in photo's reverse
|
||||
geolocation info
|
||||
|
||||
--no-place Search for photos with no associated place
|
||||
name info (no reverse geolocation info)
|
||||
|
||||
--location Search for photos with associated location
|
||||
info (e.g. GPS coordinates)
|
||||
|
||||
--no-location Search for photos with no associated location
|
||||
info (e.g. no GPS coordinates)
|
||||
|
||||
--label LABEL Search for photos with image classification
|
||||
label LABEL (Photos 5 only). If more than one
|
||||
label, treated as "OR", e.g. find photos
|
||||
matching any label
|
||||
|
||||
--uti UTI Search for photos whose uniform type
|
||||
identifier (UTI) matches UTI
|
||||
|
||||
-i, --ignore-case Case insensitive search for title,
|
||||
description, place, keyword, person, or album.
|
||||
|
||||
--edited Search for photos that have been edited.
|
||||
--external-edit Search for photos edited in external editor.
|
||||
--favorite Search for photos marked favorite.
|
||||
@@ -626,51 +648,67 @@ Options:
|
||||
--not-hidden Search for photos not marked hidden.
|
||||
--shared Search for photos in shared iCloud album
|
||||
(Photos 5 only).
|
||||
|
||||
--not-shared Search for photos not in shared iCloud album
|
||||
(Photos 5 only).
|
||||
|
||||
--burst Search for photos that were taken in a burst.
|
||||
--not-burst Search for photos that are not part of a
|
||||
burst.
|
||||
|
||||
--live Search for Apple live photos
|
||||
--not-live Search for photos that are not Apple live
|
||||
photos.
|
||||
|
||||
--portrait Search for Apple portrait mode photos.
|
||||
--not-portrait Search for photos that are not Apple portrait
|
||||
mode photos.
|
||||
|
||||
--screenshot Search for screenshot photos.
|
||||
--not-screenshot Search for photos that are not screenshot
|
||||
photos.
|
||||
|
||||
--slow-mo Search for slow motion videos.
|
||||
--not-slow-mo Search for photos that are not slow motion
|
||||
videos.
|
||||
|
||||
--time-lapse Search for time lapse videos.
|
||||
--not-time-lapse Search for photos that are not time lapse
|
||||
videos.
|
||||
|
||||
--hdr Search for high dynamic range (HDR) photos.
|
||||
--not-hdr Search for photos that are not HDR photos.
|
||||
--selfie Search for selfies (photos taken with front-
|
||||
facing cameras).
|
||||
|
||||
--not-selfie Search for photos that are not selfies.
|
||||
--panorama Search for panorama photos.
|
||||
--not-panorama Search for photos that are not panoramas.
|
||||
--has-raw Search for photos with both a jpeg and raw
|
||||
version
|
||||
|
||||
--only-movies Search only for movies (default searches both
|
||||
images and movies).
|
||||
|
||||
--only-photos Search only for photos/images (default
|
||||
searches both images and movies).
|
||||
|
||||
--from-date DATETIME Search by item start date, e.g.
|
||||
2000-01-12T12:00:00,
|
||||
2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO
|
||||
8601 with/without timezone).
|
||||
|
||||
--to-date DATETIME Search by item end date, e.g.
|
||||
2000-01-12T12:00:00,
|
||||
2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO
|
||||
8601 with/without timezone).
|
||||
|
||||
--from-time TIME Search by item start time of day, e.g. 12:00,
|
||||
or 12:00:00.
|
||||
|
||||
--to-time TIME Search by item end time of day, e.g. 12:00 or
|
||||
12:00:00.
|
||||
|
||||
--has-comment Search for photos that have comments.
|
||||
--no-comment Search for photos with no comments.
|
||||
--has-likes Search for photos that have likes.
|
||||
@@ -678,8 +716,10 @@ Options:
|
||||
--is-reference Search for photos that were imported as
|
||||
referenced files (not copied into Photos
|
||||
library).
|
||||
|
||||
--in-album Search for photos that are in one or more
|
||||
albums.
|
||||
|
||||
--not-in-album Search for photos that are not in any albums.
|
||||
--duplicate Search for photos with possible duplicates.
|
||||
osxphotos will compare signatures of photos,
|
||||
@@ -689,6 +729,7 @@ Options:
|
||||
for-byte nor compare hashes but should find
|
||||
photos imported multiple times or duplicated
|
||||
within Photos.
|
||||
|
||||
--min-size SIZE Search for photos with size >= SIZE bytes. The
|
||||
size evaluated is the photo's original size
|
||||
(when imported to Photos). Size may be
|
||||
@@ -696,6 +737,7 @@ Options:
|
||||
units. For example, the following are all
|
||||
valid and equivalent sizes: '1048576'
|
||||
'1.048576MB', '1 MiB'.
|
||||
|
||||
--max-size SIZE Search for photos with size <= SIZE bytes. The
|
||||
size evaluated is the photo's original size
|
||||
(when imported to Photos). Size may be
|
||||
@@ -703,12 +745,14 @@ Options:
|
||||
units. For example, the following are all
|
||||
valid and equivalent sizes: '1048576'
|
||||
'1.048576MB', '1 MiB'.
|
||||
|
||||
--regex REGEX TEMPLATE Search for photos where TEMPLATE matches
|
||||
regular expression REGEX. For example, to find
|
||||
photos in an album that begins with 'Beach': '
|
||||
--regex "^Beach" "{album}"'. You may specify
|
||||
more than one regular expression match by
|
||||
repeating '--regex' with different arguments.
|
||||
|
||||
--query-eval CRITERIA Evaluate CRITERIA to filter photos. CRITERIA
|
||||
will be evaluated in context of the following
|
||||
python list comprehension: `photos = [photo
|
||||
@@ -723,14 +767,36 @@ Options:
|
||||
https://rhettbull.github.io/osxphotos/ for
|
||||
additional documentation on the PhotoInfo
|
||||
class.
|
||||
|
||||
--query-function filename.py::function
|
||||
Run function to filter photos. Use this in
|
||||
format: --query-function filename.py::function
|
||||
where filename.py is a python file you've
|
||||
created and function is the name of the
|
||||
function in the python file you want to call.
|
||||
Your function will be passed a list of
|
||||
PhotoInfo objects and is expected to return a
|
||||
filtered list of PhotoInfo objects. You may
|
||||
use more than one function by repeating the
|
||||
--query-function option with a different
|
||||
value. Your query function will be called
|
||||
after all other query options have been
|
||||
evaluated. See https://github.com/RhetTbull/os
|
||||
xphotos/blob/master/examples/query_function.py
|
||||
for example of how to use this option.
|
||||
|
||||
--missing Export only photos missing from the Photos
|
||||
library; must be used with --download-missing.
|
||||
|
||||
--deleted Include photos from the 'Recently Deleted'
|
||||
folder.
|
||||
|
||||
--deleted-only Include only photos from the 'Recently
|
||||
Deleted' folder.
|
||||
|
||||
--update Only export new or updated files. See notes
|
||||
below on export and --update.
|
||||
|
||||
--ignore-signature When used with '--update', ignores file
|
||||
signature when updating files. This is useful
|
||||
if you have processed or edited exported
|
||||
@@ -749,12 +815,15 @@ Options:
|
||||
not; 3) if a sidecar does not exist for the
|
||||
photo, a sidecar will be written whether or
|
||||
not the photo file was written or updated.
|
||||
|
||||
--only-new If used with --update, ignores any previously
|
||||
exported files, even if missing from the
|
||||
export folder and only exports new files that
|
||||
haven't previously been exported.
|
||||
|
||||
--dry-run Dry run (test) the export but don't actually
|
||||
export any files; most useful with --verbose.
|
||||
|
||||
--export-as-hardlink Hardlink files instead of copying them. Cannot
|
||||
be used with --exiftool which creates copies
|
||||
of the files with embedded EXIF data. Note: on
|
||||
@@ -762,45 +831,77 @@ Options:
|
||||
giving many of the same advantages as
|
||||
hardlinks without having to use --export-as-
|
||||
hardlink.
|
||||
|
||||
--touch-file Sets the file's modification time to match
|
||||
photo date.
|
||||
|
||||
--overwrite Overwrite existing files. Default behavior is
|
||||
to add (1), (2), etc to filename if file
|
||||
already exists. Use this with caution as it
|
||||
may create name collisions on export. (e.g. if
|
||||
two files happen to have the same name)
|
||||
|
||||
--retry RETRY Automatically retry export up to RETRY times
|
||||
if an error occurs during export. This may be
|
||||
useful with network drives that experience
|
||||
intermittent errors.
|
||||
|
||||
--export-by-date Automatically create output folders to
|
||||
organize photos by date created (e.g.
|
||||
DEST/2019/12/20/photoname.jpg).
|
||||
|
||||
--skip-edited Do not export edited version of photo if an
|
||||
edited version exists.
|
||||
|
||||
--skip-original-if-edited Do not export original if there is an edited
|
||||
version (exports only the edited version).
|
||||
|
||||
--skip-bursts Do not export all associated burst images in
|
||||
the library if a photo is a burst photo.
|
||||
|
||||
--skip-live Do not export the associated live video
|
||||
component of a live photo.
|
||||
--skip-raw Do not export associated raw images of a
|
||||
RAW+JPEG pair. Note: this does not skip raw
|
||||
photos if the raw photo does not have an
|
||||
associated jpeg image (e.g. the raw file was
|
||||
imported to Photos without a jpeg preview).
|
||||
|
||||
--skip-raw Do not export associated RAW image of a
|
||||
RAW+JPEG pair. Note: this does not skip RAW
|
||||
photos if the RAW photo does not have an
|
||||
associated JPEG image (e.g. the RAW file was
|
||||
imported to Photos without a JPEG preview).
|
||||
|
||||
--current-name Use photo's current filename instead of
|
||||
original filename for export. Note: Starting
|
||||
with Photos 5, all photos are renamed upon
|
||||
import. By default, photos are exported with
|
||||
the the original name they had before import.
|
||||
--convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC,
|
||||
PNG, etc) to JPEG upon export. Only works if
|
||||
your Mac has a GPU.
|
||||
|
||||
--convert-to-jpeg Convert all non-JPEG images (e.g. RAW, HEIC,
|
||||
PNG, etc) to JPEG upon export. Note: does not
|
||||
convert the RAW component of a RAW+JPEG pair
|
||||
as the associated JPEG image will be exported.
|
||||
You can use --skip-raw to skip exporting the
|
||||
associated RAW image of a RAW+JPEG pair. See
|
||||
also --jpeg-quality and --jpeg-ext. Only works
|
||||
if your Mac has a GPU (thus may not work on
|
||||
virtual machines).
|
||||
|
||||
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
|
||||
--convert-to-jpeg. A value of 1.0 specifies
|
||||
best quality, a value of 0.0 specifies maximum
|
||||
compression. Defaults to 1.0 [0.0<=x<=1.0]
|
||||
compression. Defaults to 1.0
|
||||
|
||||
--preview Export preview image generated by Photos. This
|
||||
is a lower-resolution image used by Photos to
|
||||
quickly preview the image.
|
||||
|
||||
--preview-suffix SUFFIX Optional suffix template for naming preview
|
||||
photos. Default name for preview photos is in
|
||||
form 'photoname_preview.ext'. For example,
|
||||
with '--preview-suffix _low_res', the preview
|
||||
photo would be named 'photoname_low_res.ext'.
|
||||
The default suffix is '_preview'. Multi-value
|
||||
templates (see Templating System) are not
|
||||
permitted with --preview-suffix.
|
||||
|
||||
--download-missing Attempt to download missing photos from
|
||||
iCloud. The current implementation uses
|
||||
Applescript to interact with Photos to export
|
||||
@@ -813,6 +914,7 @@ Options:
|
||||
export all burst images; only the primary
|
||||
photo will be exported--associated burst
|
||||
images will be skipped.
|
||||
|
||||
--sidecar FORMAT Create sidecar for each photo exported; valid
|
||||
FORMAT values: xmp, json, exiftool; --sidecar
|
||||
xmp: create XMP sidecar used by Digikam, Adobe
|
||||
@@ -839,6 +941,7 @@ Options:
|
||||
tags exported in the JSON and exiftool
|
||||
sidecar, see '--exiftool'. See also '--ignore-
|
||||
signature'.
|
||||
|
||||
--sidecar-drop-ext Drop the photo's extension when naming sidecar
|
||||
files. By default, sidecar files are named in
|
||||
format 'photo_filename.photo_ext.sidecar_ext',
|
||||
@@ -850,6 +953,7 @@ Options:
|
||||
of different types but the same name in the
|
||||
output directory, e.g. 'IMG_1234.JPG' and
|
||||
'IMG_1234.MOV'.
|
||||
|
||||
--exiftool Use exiftool to write metadata directly to
|
||||
exported photos. To use this option, exiftool
|
||||
must be installed and in the path. exiftool
|
||||
@@ -871,8 +975,10 @@ Options:
|
||||
QuickTime:ModifyDate (see also --ignore-date-
|
||||
modified); QuickTime:GPSCoordinates;
|
||||
UserData:GPSCoordinates.
|
||||
|
||||
--exiftool-path EXIFTOOL_PATH Optionally specify path to exiftool; if not
|
||||
provided, will look for exiftool in $PATH.
|
||||
|
||||
--exiftool-option OPTION Optional flag/option to pass to exiftool when
|
||||
using --exiftool. For example, --exiftool-
|
||||
option '-m' to ignore minor warnings. Specify
|
||||
@@ -882,21 +988,27 @@ Options:
|
||||
full list of options. More than one option may
|
||||
be specified by repeating the option, e.g.
|
||||
--exiftool-option '-m' --exiftool-option '-F'.
|
||||
|
||||
--exiftool-merge-keywords Merge any keywords found in the original file
|
||||
with keywords used for '--exiftool' and '--
|
||||
sidecar'.
|
||||
|
||||
--exiftool-merge-persons Merge any persons found in the original file
|
||||
with persons used for '--exiftool' and '--
|
||||
sidecar'.
|
||||
|
||||
--ignore-date-modified If used with --exiftool or --sidecar, will
|
||||
ignore the photo modification date and set
|
||||
EXIF:ModifyDate to EXIF:DateTimeOriginal; this
|
||||
is consistent with how Photos handles the
|
||||
EXIF:ModifyDate tag.
|
||||
|
||||
--person-keyword Use person in image as keyword/tag when
|
||||
exporting metadata.
|
||||
|
||||
--album-keyword Use album name as keyword/tag when exporting
|
||||
metadata.
|
||||
|
||||
--keyword-template TEMPLATE For use with --exiftool, --sidecar; specify a
|
||||
template string to use as keyword in the form
|
||||
'{name,DEFAULT}' This is the same format as
|
||||
@@ -909,6 +1021,7 @@ Options:
|
||||
"{folder_album}" --keyword-template
|
||||
"{created.year}". See '--replace-keywords' and
|
||||
Templating System below.
|
||||
|
||||
--replace-keywords Replace keywords with any values specified
|
||||
with --keyword-template. By default,
|
||||
--keyword-template will add keywords to any
|
||||
@@ -917,6 +1030,7 @@ Options:
|
||||
from --keyword-template will replace any
|
||||
existing keywords instead of adding additional
|
||||
keywords.
|
||||
|
||||
--description-template TEMPLATE
|
||||
For use with --exiftool, --sidecar; specify a
|
||||
template string to use as description in the
|
||||
@@ -927,6 +1041,7 @@ Options:
|
||||
--description-template "{descr} exported with
|
||||
osxphotos on {today.date}" See Templating
|
||||
System below.
|
||||
|
||||
--finder-tag-template TEMPLATE Set MacOS Finder tags to TEMPLATE. These tags
|
||||
can be searched in the Finder or Spotlight
|
||||
with 'tag:tagname' format. For example, '--
|
||||
@@ -935,11 +1050,13 @@ Options:
|
||||
TEMPLATE values by using '--finder-tag-
|
||||
template' multiple times. See also '--finder-
|
||||
tag-keywords and Extended Attributes below.'.
|
||||
|
||||
--finder-tag-keywords Set MacOS Finder tags to keywords; any
|
||||
keywords specified via '--keyword-template', '
|
||||
--person-keyword', etc. will also be used as
|
||||
Finder tags. See also '--finder-tag-template
|
||||
and Extended Attributes below.'.
|
||||
|
||||
--xattr-template ATTRIBUTE TEMPLATE
|
||||
Set extended attribute ATTRIBUTE to TEMPLATE
|
||||
value. Valid attributes are: 'authors',
|
||||
@@ -950,16 +1067,19 @@ Options:
|
||||
findercomment "{title}; {descr}" See Extended
|
||||
Attributes below for additional details on
|
||||
this option.
|
||||
|
||||
--directory DIRECTORY Optional template for specifying name of
|
||||
output directory in the form '{name,DEFAULT}'.
|
||||
See below for additional details on templating
|
||||
system.
|
||||
|
||||
--filename FILENAME Optional template for specifying name of
|
||||
output file in the form '{name,DEFAULT}'. File
|
||||
extension will be added automatically--do not
|
||||
include an extension in the FILENAME template.
|
||||
See below for additional details on templating
|
||||
system.
|
||||
|
||||
--jpeg-ext EXTENSION Specify file extension for JPEG files. Photos
|
||||
uses .jpeg for edited images but many images
|
||||
are imported with .jpg or .JPG which can
|
||||
@@ -969,12 +1089,14 @@ Options:
|
||||
exported JPEG images. Valid values are jpeg,
|
||||
jpg, JPEG, JPG; e.g. '--jpeg-ext jpg' to use
|
||||
'.jpg' for all JPEGs.
|
||||
|
||||
--strip Optionally strip leading and trailing
|
||||
whitespace from any rendered templates. For
|
||||
example, if --filename template is "{title,}
|
||||
{original_name}" and image has no title,
|
||||
resulting file would have a leading space but
|
||||
if used with --strip, this will be removed.
|
||||
|
||||
--edited-suffix SUFFIX Optional suffix template for naming edited
|
||||
photos. Default name for edited photos is in
|
||||
form 'photoname_edited.ext'. For example, with
|
||||
@@ -984,6 +1106,7 @@ Options:
|
||||
suffix is '_edited'. Multi-value templates
|
||||
(see Templating System) are not permitted with
|
||||
--edited-suffix.
|
||||
|
||||
--original-suffix SUFFIX Optional suffix template for naming original
|
||||
photos. Default name for original photos is
|
||||
in form 'filename.ext'. For example, with '--
|
||||
@@ -992,9 +1115,11 @@ Options:
|
||||
default suffix is '' (no suffix). Multi-value
|
||||
templates (see Templating System) are not
|
||||
permitted with --original-suffix.
|
||||
|
||||
--use-photos-export Force the use of AppleScript or PhotoKit to
|
||||
export even if not missing (see also '--
|
||||
download-missing' and '--use-photokit').
|
||||
|
||||
--use-photokit Use with '--download-missing' or '--use-
|
||||
photos-export' to use direct Photos interface
|
||||
instead of AppleScript to export. Highly
|
||||
@@ -1002,9 +1127,11 @@ Options:
|
||||
iTerm2 (use with Terminal.app). This is faster
|
||||
and more reliable than the default AppleScript
|
||||
interface.
|
||||
|
||||
--report <path to export report>
|
||||
Write a CSV formatted report of all files that
|
||||
were exported.
|
||||
|
||||
--cleanup Cleanup export directory by deleting any files
|
||||
which were not included in this export set.
|
||||
For example, photos which had previously been
|
||||
@@ -1016,6 +1143,7 @@ Options:
|
||||
you intend before using --cleanup. Use --dry-
|
||||
run with --cleanup first if you're not
|
||||
certain.
|
||||
|
||||
--add-exported-to-album ALBUM Add all exported photos to album ALBUM in
|
||||
Photos. Album ALBUM will be created if it
|
||||
doesn't exist. All exported photos will be
|
||||
@@ -1025,6 +1153,7 @@ Options:
|
||||
feature is currently experimental. I don't
|
||||
know how well it will work on large export
|
||||
sets.
|
||||
|
||||
--add-skipped-to-album ALBUM Add all skipped photos to album ALBUM in
|
||||
Photos. Album ALBUM will be created if it
|
||||
doesn't exist. All skipped photos will be
|
||||
@@ -1034,6 +1163,7 @@ Options:
|
||||
feature is currently experimental. I don't
|
||||
know how well it will work on large export
|
||||
sets.
|
||||
|
||||
--add-missing-to-album ALBUM Add all missing photos to album ALBUM in
|
||||
Photos. Album ALBUM will be created if it
|
||||
doesn't exist. All missing photos will be
|
||||
@@ -1043,6 +1173,7 @@ Options:
|
||||
feature is currently experimental. I don't
|
||||
know how well it will work on large export
|
||||
sets.
|
||||
|
||||
--post-command CATEGORY COMMAND
|
||||
Run COMMAND on exported files of category
|
||||
CATEGORY. CATEGORY can be one of: exported,
|
||||
@@ -1061,6 +1192,20 @@ Options:
|
||||
command by repeating the '--post-command'
|
||||
option with different arguments. See Post
|
||||
Command below.
|
||||
|
||||
--post-function filename.py::function
|
||||
Run function on exported files. Use this in
|
||||
format: --post-function filename.py::function
|
||||
where filename.py is a python file you've
|
||||
created and function is the name of the
|
||||
function in the python file you want to call.
|
||||
The function will be passed information about
|
||||
the photo that's been exported and a list of
|
||||
all exported files associated with the photo.
|
||||
You can run more than one function by
|
||||
repeating the '--post-function' option with
|
||||
different arguments. See Post Function below.
|
||||
|
||||
--exportdb EXPORTDB_FILE Specify alternate name for database file which
|
||||
stores state information for export and
|
||||
--update. If --exportdb is not specified,
|
||||
@@ -1069,6 +1214,7 @@ Options:
|
||||
directory. Must be specified as filename
|
||||
only, not a path, as export database will be
|
||||
saved in export directory.
|
||||
|
||||
--load-config <config file path>
|
||||
Load options from file as written with --save-
|
||||
config. This allows you to save a complex
|
||||
@@ -1080,9 +1226,11 @@ Options:
|
||||
line options are used in conjunction with
|
||||
--load-config, they will override the
|
||||
corresponding values in the config file.
|
||||
|
||||
--save-config <config file path>
|
||||
Save options to file for use with --load-
|
||||
config. File format is TOML.
|
||||
|
||||
--help Show this message and exit.
|
||||
|
||||
** Export **
|
||||
@@ -1137,25 +1285,32 @@ The following attributes may be used with '--xattr-template':
|
||||
|
||||
authors The author, or authors, of the contents of the file. A list of
|
||||
strings. (com.apple.metadata:kMDItemAuthors)
|
||||
|
||||
comment A comment related to the file. This differs from the Finder
|
||||
comment, kMDItemFinderComment. A string.
|
||||
(com.apple.metadata:kMDItemComment)
|
||||
|
||||
copyright The copyright owner of the file contents. A string.
|
||||
(com.apple.metadata:kMDItemCopyright)
|
||||
|
||||
description A description of the content of the resource. The description
|
||||
may include an abstract, table of contents, reference to a
|
||||
graphical representation of content or a free-text account of
|
||||
the content. A string. (com.apple.metadata:kMDItemDescription)
|
||||
|
||||
findercomment Finder comments for this file. A string.
|
||||
(com.apple.metadata:kMDItemFinderComment)
|
||||
|
||||
headline A publishable entry providing a synopsis of the contents of the
|
||||
file. A string. (com.apple.metadata:kMDItemHeadline)
|
||||
|
||||
keywords Keywords associated with this file. For example, “Birthday”,
|
||||
“Important”, etc. This differs from Finder tags
|
||||
(_kMDItemUserTags) which are keywords/tags shown in the Finder
|
||||
and searchable in Spotlight using "tag:tag_name". A list of
|
||||
strings. (com.apple.metadata:kMDItemKeywords)
|
||||
|
||||
|
||||
For additional information on extended attributes see: https://developer.apple.c
|
||||
om/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_key
|
||||
s
|
||||
@@ -1381,6 +1536,7 @@ Substitution Description
|
||||
{name} Current filename of the photo
|
||||
{original_name} Photo's original filename when imported to
|
||||
Photos
|
||||
|
||||
{title} Title of the photo
|
||||
{descr} Description of the photo
|
||||
{media_type} Special media type resolved in this
|
||||
@@ -1390,35 +1546,48 @@ Substitution Description
|
||||
'video' if no special type. Customize one or
|
||||
more media types using format: '{media_type,vi
|
||||
deo=vidéo;time_lapse=vidéo_accélérée}'
|
||||
|
||||
{photo_or_video} 'photo' or 'video' depending on what type the
|
||||
image is. To customize, use default value as
|
||||
in '{photo_or_video,photo=fotos;video=videos}'
|
||||
|
||||
{hdr} Photo is HDR?; True/False value, use in format
|
||||
'{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'
|
||||
|
||||
{edited} True if photo has been edited (has
|
||||
adjustments), otherwise False; use in format
|
||||
'{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'
|
||||
|
||||
{edited_version} True if template is being rendered for the
|
||||
edited version of a photo, otherwise False.
|
||||
|
||||
{favorite} Photo has been marked as favorite?; True/False
|
||||
value, use in format
|
||||
'{favorite?VALUE_IF_TRUE,VALUE_IF_FALSE}'
|
||||
|
||||
{created.date} Photo's creation date in ISO format, e.g.
|
||||
'2020-03-22'
|
||||
|
||||
{created.year} 4-digit year of photo creation time
|
||||
{created.yy} 2-digit year of photo creation time
|
||||
{created.mm} 2-digit month of the photo creation time (zero
|
||||
padded)
|
||||
|
||||
{created.month} Month name in user's locale of the photo
|
||||
creation time
|
||||
|
||||
{created.mon} Month abbreviation in the user's locale of the
|
||||
photo creation time
|
||||
|
||||
{created.dd} 2-digit day of the month (zero padded) of
|
||||
photo creation time
|
||||
|
||||
{created.dow} Day of week in user's locale of the photo
|
||||
creation time
|
||||
|
||||
{created.doy} 3-digit day of year (e.g Julian day) of photo
|
||||
creation time, starting from 1 (zero padded)
|
||||
|
||||
{created.hour} 2-digit hour of the photo creation time
|
||||
{created.min} 2-digit minute of the photo creation time
|
||||
{created.sec} 2-digit second of the photo creation time
|
||||
@@ -1431,38 +1600,51 @@ Substitution Description
|
||||
no template will return null value. See
|
||||
https://strftime.org/ for help on strftime
|
||||
templates.
|
||||
|
||||
{modified.date} Photo's modification date in ISO format, e.g.
|
||||
'2020-03-22'; uses creation date if photo is
|
||||
not modified
|
||||
|
||||
{modified.year} 4-digit year of photo modification time; uses
|
||||
creation date if photo is not modified
|
||||
|
||||
{modified.yy} 2-digit year of photo modification time; uses
|
||||
creation date if photo is not modified
|
||||
|
||||
{modified.mm} 2-digit month of the photo modification time
|
||||
(zero padded); uses creation date if photo is
|
||||
not modified
|
||||
|
||||
{modified.month} Month name in user's locale of the photo
|
||||
modification time; uses creation date if photo
|
||||
is not modified
|
||||
|
||||
{modified.mon} Month abbreviation in the user's locale of the
|
||||
photo modification time; uses creation date if
|
||||
photo is not modified
|
||||
|
||||
{modified.dd} 2-digit day of the month (zero padded) of the
|
||||
photo modification time; uses creation date if
|
||||
photo is not modified
|
||||
|
||||
{modified.dow} Day of week in user's locale of the photo
|
||||
modification time; uses creation date if photo
|
||||
is not modified
|
||||
|
||||
{modified.doy} 3-digit day of year (e.g Julian day) of photo
|
||||
modification time, starting from 1 (zero
|
||||
padded); uses creation date if photo is not
|
||||
modified
|
||||
|
||||
{modified.hour} 2-digit hour of the photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
|
||||
{modified.min} 2-digit minute of the photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
|
||||
{modified.sec} 2-digit second of the photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
|
||||
{modified.strftime} Apply strftime template to file modification
|
||||
date/time. Should be used in form
|
||||
{modified.strftime,TEMPLATE} where TEMPLATE is
|
||||
@@ -1473,21 +1655,28 @@ Substitution Description
|
||||
creation date if photo is not modified. See
|
||||
https://strftime.org/ for help on strftime
|
||||
templates.
|
||||
|
||||
{today.date} Current date in iso format, e.g. '2020-03-22'
|
||||
{today.year} 4-digit year of current date
|
||||
{today.yy} 2-digit year of current date
|
||||
{today.mm} 2-digit month of the current date (zero
|
||||
padded)
|
||||
|
||||
{today.month} Month name in user's locale of the current
|
||||
date
|
||||
|
||||
{today.mon} Month abbreviation in the user's locale of the
|
||||
current date
|
||||
|
||||
{today.dd} 2-digit day of the month (zero padded) of
|
||||
current date
|
||||
|
||||
{today.dow} Day of week in user's locale of the current
|
||||
date
|
||||
|
||||
{today.doy} 3-digit day of year (e.g Julian day) of
|
||||
current date, starting from 1 (zero padded)
|
||||
|
||||
{today.hour} 2-digit hour of the current date
|
||||
{today.min} 2-digit minute of the current date
|
||||
{today.sec} 2-digit second of the current date
|
||||
@@ -1500,51 +1689,70 @@ Substitution Description
|
||||
no template will return null value. See
|
||||
https://strftime.org/ for help on strftime
|
||||
templates.
|
||||
|
||||
{place.name} Place name from the photo's reverse
|
||||
geolocation data, as displayed in Photos
|
||||
|
||||
{place.country_code} The ISO country code from the photo's reverse
|
||||
geolocation data
|
||||
|
||||
{place.name.country} Country name from the photo's reverse
|
||||
geolocation data
|
||||
|
||||
{place.name.state_province} State or province name from the photo's
|
||||
reverse geolocation data
|
||||
|
||||
{place.name.city} City or locality name from the photo's reverse
|
||||
geolocation data
|
||||
|
||||
{place.name.area_of_interest} Area of interest name (e.g. landmark or public
|
||||
place) from the photo's reverse geolocation
|
||||
data
|
||||
|
||||
{place.address} Postal address from the photo's reverse
|
||||
geolocation data, e.g. '2007 18th St NW,
|
||||
Washington, DC 20009, United States'
|
||||
|
||||
{place.address.street} Street part of the postal address, e.g. '2007
|
||||
18th St NW'
|
||||
|
||||
{place.address.city} City part of the postal address, e.g.
|
||||
'Washington'
|
||||
|
||||
{place.address.state_province} State/province part of the postal address,
|
||||
e.g. 'DC'
|
||||
|
||||
{place.address.postal_code} Postal code part of the postal address, e.g.
|
||||
'20009'
|
||||
|
||||
{place.address.country} Country name of the postal address, e.g.
|
||||
'United States'
|
||||
|
||||
{place.address.country_code} ISO country code of the postal address, e.g.
|
||||
'US'
|
||||
|
||||
{searchinfo.season} Season of the year associated with a photo,
|
||||
e.g. 'Summer'; (Photos 5+ only, applied
|
||||
automatically by Photos' image categorization
|
||||
algorithms).
|
||||
|
||||
{exif.camera_make} Camera make from original photo's EXIF
|
||||
information as imported by Photos, e.g.
|
||||
'Apple'
|
||||
|
||||
{exif.camera_model} Camera model from original photo's EXIF
|
||||
information as imported by Photos, e.g.
|
||||
'iPhone 6s'
|
||||
|
||||
{exif.lens_model} Lens model from original photo's EXIF
|
||||
information as imported by Photos, e.g.
|
||||
'iPhone 6s back camera 4.15mm f/2.2'
|
||||
|
||||
{uuid} Photo's internal universally unique identifier
|
||||
(UUID) for the photo, a 36-character string
|
||||
unique to the photo, e.g.
|
||||
'128FB4C6-0B16-4E7D-9108-FB2E90DA1546'
|
||||
|
||||
{comma} A comma: ','
|
||||
{semicolon} A semicolon: ';'
|
||||
{questionmark} A question mark: '?'
|
||||
@@ -1559,7 +1767,7 @@ Substitution Description
|
||||
{lf} A line feed: '\n', alias for {newline}
|
||||
{cr} A carriage return: '\r'
|
||||
{crlf} a carriage return + line feed: '\r\n'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.42.37'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.42.57'
|
||||
{osxphotos_cmd_line} The full command line used to run osxphotos
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified for
|
||||
@@ -1574,6 +1782,7 @@ Substitution Description
|
||||
{folder_album} Folder path + album photo is contained in. e.g.
|
||||
'Folder/Subfolder/Album' or just 'Album' if no
|
||||
enclosing folder
|
||||
|
||||
{keyword} Keyword(s) assigned to photo
|
||||
{person} Person(s) / face(s) in a photo
|
||||
{label} Image categorization label associated with a photo
|
||||
@@ -1582,9 +1791,11 @@ Substitution Description
|
||||
categorize images. These are not the same as
|
||||
{keyword} which refers to the user-defined
|
||||
keywords/tags applied in Photos.
|
||||
|
||||
{label_normalized} All lower case version of 'label' (Photos 5+ only)
|
||||
{comment} Comment(s) on shared Photos; format is 'Person name:
|
||||
comment text' (Photos 5+ only)
|
||||
|
||||
{exiftool} Format: '{exiftool:GROUP:TAGNAME}'; use exiftool
|
||||
(https://exiftool.org) to extract metadata, in form
|
||||
GROUP:TAGNAME, from image. E.g.
|
||||
@@ -1594,19 +1805,24 @@ Substitution Description
|
||||
names. You must specify group (e.g. EXIF, IPTC, etc)
|
||||
as used in `exiftool -G`. exiftool must be installed
|
||||
in the path to use this template.
|
||||
|
||||
{searchinfo.holiday} Holiday names associated with a photo, e.g.
|
||||
'Christmas Day'; (Photos 5+ only, applied
|
||||
automatically by Photos' image categorization
|
||||
algorithms).
|
||||
|
||||
{searchinfo.activity} Activities associated with a photo, e.g. 'Sporting
|
||||
Event'; (Photos 5+ only, applied automatically by
|
||||
Photos' image categorization algorithms).
|
||||
|
||||
{searchinfo.venue} Venues associated with a photo, e.g. name of
|
||||
restaurant; (Photos 5+ only, applied automatically by
|
||||
Photos' image categorization algorithms).
|
||||
|
||||
{searchinfo.venue_type} Venue types associated with a photo, e.g.
|
||||
'Restaurant'; (Photos 5+ only, applied automatically
|
||||
by Photos' image categorization algorithms).
|
||||
|
||||
{photo} Provides direct access to the PhotoInfo object for
|
||||
the photo. Must be used in format '{photo.property}'
|
||||
where 'property' represents a PhotoInfo property. For
|
||||
@@ -1618,10 +1834,12 @@ Substitution Description
|
||||
underlying PhotoInfo class. See
|
||||
https://rhettbull.github.io/osxphotos/ for additional
|
||||
documentation on the PhotoInfo class.
|
||||
|
||||
{shell_quote} Use in form '{shell_quote,TEMPLATE}'; quotes the
|
||||
rendered TEMPLATE value(s) for safe usage in the
|
||||
shell, e.g. My file.jpeg => 'My file.jpeg'; only adds
|
||||
quotes if needed.
|
||||
|
||||
{function} Execute a python function from an external file and
|
||||
use return value as template substitution. Use in
|
||||
format: {function:file.py::function_name} where
|
||||
@@ -1632,6 +1850,7 @@ Substitution Description
|
||||
/blob/master/examples/template_function.py for an
|
||||
example of how to implement a template function.
|
||||
|
||||
|
||||
The following substitutions are file or directory paths. You can access various
|
||||
parts of the path using the following modifiers:
|
||||
|
||||
@@ -1666,29 +1885,41 @@ exported All exported files
|
||||
new When used with '--update', all newly exported files
|
||||
updated When used with '--update', all files which were
|
||||
previously exported but updated this time
|
||||
|
||||
skipped When used with '--update', all files which were
|
||||
skipped (because they were previously exported and
|
||||
didn't change)
|
||||
|
||||
missing All files which were not exported because they were
|
||||
missing from the Photos library
|
||||
|
||||
exif_updated When used with '--exiftool', all files on which
|
||||
exiftool updated the metadata
|
||||
|
||||
touched When used with '--touch-file', all files where the
|
||||
date was touched
|
||||
|
||||
converted_to_jpeg When used with '--convert-to-jpeg', all files which
|
||||
were converted to jpeg
|
||||
|
||||
sidecar_json_written When used with '--sidecar json', all JSON sidecar
|
||||
files which were written
|
||||
|
||||
sidecar_json_skipped When used with '--sidecar json' and '--update', all
|
||||
JSON sidecar files which were skipped
|
||||
|
||||
sidecar_exiftool_written When used with '--sidecar exiftool', all exiftool
|
||||
sidecar files which were written
|
||||
|
||||
sidecar_exiftool_skipped When used with '--sidecar exiftool' and '--update,
|
||||
all exiftool sidecar files which were skipped
|
||||
|
||||
sidecar_xmp_written When used with '--sidecar xmp', all XMP sidecar
|
||||
files which were written
|
||||
|
||||
sidecar_xmp_skipped When used with '--sidecar xmp' and '--update', all
|
||||
XMP sidecar files which were skipped
|
||||
|
||||
error All files which produced an error during export
|
||||
|
||||
In addition to all normal template fields, the template fields '{filepath}' and
|
||||
@@ -1717,6 +1948,17 @@ to ensure your commands are as expected. This will not actually run the commands
|
||||
but will print out the exact command string which would be executed.
|
||||
|
||||
|
||||
** Post Function **
|
||||
You can run your own python functions on the exported photos for post-processing
|
||||
using the '--post-function' option. '--post-function' is passed the name a
|
||||
python file and the name of the function in the file to call using format
|
||||
'filename.py::function_name'. See the example function at
|
||||
https://github.com/RhetTbull/osxphotos/blob/master/examples/post_function.py You
|
||||
may specify multiple functions to run by repeating the --post-function option.
|
||||
All post functions will be called immediately after export of each photo and
|
||||
immediately before any --post-command commands. Post functions will not be
|
||||
called if the --dry-run flag is set.
|
||||
|
||||
|
||||
|
||||
```
|
||||
@@ -2439,6 +2681,9 @@ Returns the path to the live video component of a [live photo](#live_photo). If
|
||||
|
||||
**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud.
|
||||
|
||||
#### `path_edited_live_photo`
|
||||
Returns the path to the edited live video component of an edited [live photo](#live_photo). If photo is not a live photo or not edited, returns None.
|
||||
|
||||
#### `portrait`
|
||||
Returns True if photo was taken in iPhone portrait mode, otherwise False.
|
||||
|
||||
@@ -2550,11 +2795,11 @@ Returns a JSON representation of all photo info.
|
||||
Returns a dictionary representation of all photo info.
|
||||
|
||||
#### `export()`
|
||||
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||
`export(dest, filename=None, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||
|
||||
Export photo from the Photos library to another destination on disk.
|
||||
- dest: must be valid destination path as str (or exception raised).
|
||||
- *filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
|
||||
- filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
|
||||
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
|
||||
- export_as_hardlink: boolean; if True (default=False), will hardlink files instead of copying them
|
||||
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
|
||||
@@ -3347,7 +3592,7 @@ The following template field substitutions are availabe for use the templating s
|
||||
|{lf}|A line feed: '\n', alias for {newline}|
|
||||
|{cr}|A carriage return: '\r'|
|
||||
|{crlf}|a carriage return + line feed: '\r\n'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.42.37'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.42.57'|
|
||||
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|
||||
|{album}|Album(s) photo is contained in|
|
||||
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|
||||
@@ -3563,6 +3808,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<tr>
|
||||
<td align="center"><a href="http://blog.dewost.com/"><img src="https://avatars.githubusercontent.com/u/17090228?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Philippe Dewost</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=pdewost" title="Documentation">📖</a> <a href="#example-pdewost" title="Examples">💡</a> <a href="#ideas-pdewost" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/kaduskj"><img src="https://avatars.githubusercontent.com/u/983067?v=4?s=75" width="75px;" alt=""/><br /><sub><b>kaduskj</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Akaduskj" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/mkirkland4874"><img src="https://avatars.githubusercontent.com/u/36466711?v=4?s=75" width="75px;" alt=""/><br /><sub><b>mkirkland4874</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Amkirkland4874" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/jcommisso07"><img src="https://avatars.githubusercontent.com/u/3111054?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Joseph Commisso</b></sub></a><br /><a href="#data-jcommisso07" title="Data">🔣</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ Supported operating systems
|
||||
|
||||
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Big Sur (11.3).
|
||||
|
||||
If you have access to macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please contact me via GitHub.
|
||||
|
||||
This package will read Photos databases for any supported version on any supported macOS version.
|
||||
E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa.
|
||||
|
||||
@@ -108,6 +110,8 @@ Alternatively, you can also run the command line utility like this: ``python3 -m
|
||||
persons Print out persons (faces) found in the Photos library.
|
||||
places Print out places found in the Photos library.
|
||||
query Query the Photos database using 1 or more search options; if...
|
||||
repl Run interactive osxphotos shell
|
||||
tutorial Display osxphotos tutorial.
|
||||
|
||||
To get help on a specific command, use ``osxphotos help <command_name>``
|
||||
|
||||
|
||||
54
examples/post_function.py
Normal file
@@ -0,0 +1,54 @@
|
||||
""" Example function for use with osxphotos export --post-function option """
|
||||
|
||||
from osxphotos import PhotoInfo, ExportResults
|
||||
|
||||
|
||||
def post_function(
|
||||
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 filename in results.exported:
|
||||
# do your processing here
|
||||
verbose(f"post_function: {photo.original_filename} exported as {filename}")
|
||||
31
examples/query_function.py
Normal file
@@ -0,0 +1,31 @@
|
||||
""" example function for osxphotos --query-function """
|
||||
|
||||
from typing import List
|
||||
|
||||
from osxphotos import PhotoInfo
|
||||
|
||||
|
||||
# call this with --query-function examples/query_function.py::best_selfies
|
||||
def best_selfies(photos: List[PhotoInfo]) -> List[PhotoInfo]:
|
||||
"""your query function should take a list of PhotoInfo objects and return a list of PhotoInfo objects (or empty list)"""
|
||||
# this example finds your best selfie for every year
|
||||
|
||||
# get list of selfies sorted by date
|
||||
photos = sorted([p for p in photos if p.selfie], key=lambda p: p.date)
|
||||
if not photos:
|
||||
return []
|
||||
|
||||
start_year = photos[0].date.year
|
||||
stop_year = photos[-1].date.year
|
||||
best_selfies = []
|
||||
for year in range(start_year, stop_year + 1):
|
||||
# find best selfie each year as determined by overall aesthetic score
|
||||
selfies = sorted(
|
||||
[p for p in photos if p.date.year == year],
|
||||
key=lambda p: p.score.overall,
|
||||
reverse=True,
|
||||
)
|
||||
if selfies:
|
||||
best_selfies.append(selfies[0])
|
||||
|
||||
return best_selfies
|
||||
@@ -8,41 +8,50 @@ import importlib
|
||||
pathex = os.getcwd()
|
||||
|
||||
# include necessary data files
|
||||
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates'), ('osxphotos/templates/xmp_sidecar_beta.mako', 'osxphotos/templates'), ('osxphotos/phototemplate.tx', 'osxphotos'), ('osxphotos/phototemplate.md', 'osxphotos')]
|
||||
package_imports = [['photoscript', ['photoscript.applescript']]]
|
||||
datas = [
|
||||
("osxphotos/templates/xmp_sidecar.mako", "osxphotos/templates"),
|
||||
("osxphotos/templates/xmp_sidecar_beta.mako", "osxphotos/templates"),
|
||||
("osxphotos/phototemplate.tx", "osxphotos"),
|
||||
("osxphotos/phototemplate.md", "osxphotos"),
|
||||
("osxphotos/tutorial.md", "osxphotos"),
|
||||
]
|
||||
package_imports = [["photoscript", ["photoscript.applescript"]]]
|
||||
for package, files in package_imports:
|
||||
proot = os.path.dirname(importlib.import_module(package).__file__)
|
||||
datas.extend((os.path.join(proot, f), package) for f in files)
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(['cli.py'],
|
||||
pathex=[pathex],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=['pkg_resources.py2_warn'],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
a = Analysis(
|
||||
["cli.py"],
|
||||
pathex=[pathex],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=["pkg_resources.py2_warn"],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='osxphotos',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True )
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name="osxphotos",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
)
|
||||
|
||||
@@ -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'."""
|
||||
|
||||
@@ -34,11 +34,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 +50,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 +62,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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -71,6 +92,7 @@ _TESTED_OS_VERSIONS = [
|
||||
("11", "1"),
|
||||
("11", "2"),
|
||||
("11", "3"),
|
||||
("11", "4"),
|
||||
]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
@@ -188,6 +210,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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.37"
|
||||
__version__ = "0.42.57"
|
||||
|
||||
435
osxphotos/cli.py
@@ -1,5 +1,6 @@
|
||||
"""Command line interface for osxphotos """
|
||||
|
||||
import code
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
@@ -15,7 +16,10 @@ import time
|
||||
import bitmath
|
||||
import click
|
||||
import osxmetadata
|
||||
import photoscript
|
||||
import rich.traceback
|
||||
import yaml
|
||||
from rich import pretty
|
||||
|
||||
import osxphotos
|
||||
|
||||
@@ -29,6 +33,7 @@ from ._constants import (
|
||||
DEFAULT_EDITED_SUFFIX,
|
||||
DEFAULT_JPEG_QUALITY,
|
||||
DEFAULT_ORIGINAL_SUFFIX,
|
||||
DEFAULT_PREVIEW_SUFFIX,
|
||||
EXTENDED_ATTRIBUTE_NAMES,
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
@@ -39,7 +44,7 @@ from ._constants import (
|
||||
SIDECAR_XMP,
|
||||
)
|
||||
from ._version import __version__
|
||||
from .cli_help import ExportCommand
|
||||
from .cli_help import ExportCommand, tutorial_help
|
||||
from .configoptions import (
|
||||
ConfigOptions,
|
||||
ConfigOptionsInvalidError,
|
||||
@@ -55,12 +60,15 @@ from .photokit import check_photokit_authorization, request_photokit_authorizati
|
||||
from .photosalbum import PhotosAlbum
|
||||
from .phototemplate import PhotoTemplate, RenderOptions
|
||||
from .queryoptions import QueryOptions
|
||||
from .utils import get_preferred_uti_extension
|
||||
from .uti import get_preferred_uti_extension
|
||||
from .utils import expand_and_validate_filepath, load_function
|
||||
|
||||
# global variable to control verbose output
|
||||
# set via --verbose/-V
|
||||
VERBOSE = False
|
||||
|
||||
rich.traceback.install()
|
||||
|
||||
|
||||
def verbose_(*args, **kwargs):
|
||||
"""print output if verbose flag set"""
|
||||
@@ -118,7 +126,7 @@ class DateTimeISO8601(click.ParamType):
|
||||
return datetime.datetime.fromisoformat(value)
|
||||
except Exception:
|
||||
self.fail(
|
||||
f"Invalid value for --{param.name}: invalid datetime format {value}. "
|
||||
f"Invalid datetime format {value}. "
|
||||
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
|
||||
)
|
||||
|
||||
@@ -152,12 +160,36 @@ class TimeISO8601(click.ParamType):
|
||||
return datetime.time.fromisoformat(value).replace(tzinfo=None)
|
||||
except Exception:
|
||||
self.fail(
|
||||
f"Invalid value for --{param.name}: invalid time format {value}. "
|
||||
f"Invalid time format {value}. "
|
||||
"Valid format: HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] "
|
||||
"however, note that timezone will be ignored."
|
||||
)
|
||||
|
||||
|
||||
class FunctionCall(click.ParamType):
|
||||
name = "FUNCTION"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
if "::" not in value:
|
||||
self.fail(
|
||||
f"Could not parse function name from '{value}'. "
|
||||
"Valid format filename.py::function"
|
||||
)
|
||||
|
||||
filename, funcname = value.split("::")
|
||||
|
||||
filename_validated = expand_and_validate_filepath(filename)
|
||||
if not filename_validated:
|
||||
self.fail(f"'{filename}' does not appear to be a file")
|
||||
|
||||
try:
|
||||
function = load_function(filename_validated, funcname)
|
||||
except Exception as e:
|
||||
self.fail(f"Could not load function {funcname} from {filename_validated}")
|
||||
|
||||
return (function, value)
|
||||
|
||||
|
||||
# Click CLI object & context settings
|
||||
class CLI_Obj:
|
||||
def __init__(self, db=None, json=False, debug=False):
|
||||
@@ -307,6 +339,16 @@ def QUERY_OPTIONS(f):
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated place name info (no reverse geolocation info)",
|
||||
),
|
||||
o(
|
||||
"--location",
|
||||
is_flag=True,
|
||||
help="Search for photos with associated location info (e.g. GPS coordinates)",
|
||||
),
|
||||
o(
|
||||
"--no-location",
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated location info (e.g. no GPS coordinates)",
|
||||
),
|
||||
o(
|
||||
"--label",
|
||||
metavar="LABEL",
|
||||
@@ -508,6 +550,18 @@ def QUERY_OPTIONS(f):
|
||||
"CRITERIA must be a valid python expression. "
|
||||
"See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
|
||||
),
|
||||
o(
|
||||
"--query-function",
|
||||
metavar="filename.py::function",
|
||||
multiple=True,
|
||||
type=FunctionCall(),
|
||||
help="Run function to filter photos. Use this in format: --query-function filename.py::function where filename.py is a python "
|
||||
+ "file you've created and function is the name of the function in the python file you want to call. "
|
||||
+ "Your function will be passed a list of PhotoInfo objects and is expected to return a filtered list of PhotoInfo objects. "
|
||||
+ "You may use more than one function by repeating the --query-function option with a different value. "
|
||||
+ "Your query function will be called after all other query options have been evaluated. "
|
||||
+ "See https://github.com/RhetTbull/osxphotos/blob/master/examples/query_function.py for example of how to use this option.",
|
||||
),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
f = o(f)
|
||||
@@ -623,9 +677,9 @@ def cli(ctx, db, json_, debug):
|
||||
@click.option(
|
||||
"--skip-raw",
|
||||
is_flag=True,
|
||||
help="Do not export associated raw images of a RAW+JPEG pair. "
|
||||
"Note: this does not skip raw photos if the raw photo does not have an associated jpeg image "
|
||||
"(e.g. the raw file was imported to Photos without a jpeg preview).",
|
||||
help="Do not export associated RAW image of a RAW+JPEG pair. "
|
||||
"Note: this does not skip RAW photos if the RAW photo does not have an associated JPEG image "
|
||||
"(e.g. the RAW file was imported to Photos without a JPEG preview).",
|
||||
)
|
||||
@click.option(
|
||||
"--current-name",
|
||||
@@ -637,8 +691,11 @@ def cli(ctx, db, json_, debug):
|
||||
@click.option(
|
||||
"--convert-to-jpeg",
|
||||
is_flag=True,
|
||||
help="Convert all non-jpeg images (e.g. raw, HEIC, PNG, etc) "
|
||||
"to JPEG upon export. Only works if your Mac has a GPU.",
|
||||
help="Convert all non-JPEG images (e.g. RAW, HEIC, PNG, etc) to JPEG upon export. "
|
||||
"Note: does not convert the RAW component of a RAW+JPEG pair as the associated JPEG image "
|
||||
"will be exported. You can use --skip-raw to skip exporting the associated RAW image of "
|
||||
"a RAW+JPEG pair. See also --jpeg-quality and --jpeg-ext. "
|
||||
"Only works if your Mac has a GPU (thus may not work on virtual machines).",
|
||||
)
|
||||
@click.option(
|
||||
"--jpeg-quality",
|
||||
@@ -648,6 +705,20 @@ def cli(ctx, db, json_, debug):
|
||||
"a value of 0.0 specifies maximum compression. "
|
||||
f"Defaults to {DEFAULT_JPEG_QUALITY}",
|
||||
)
|
||||
@click.option(
|
||||
"--preview",
|
||||
is_flag=True,
|
||||
help="Export preview image generated by Photos. "
|
||||
"This is a lower-resolution image used by Photos to quickly preview the image.",
|
||||
)
|
||||
@click.option(
|
||||
"--preview-suffix",
|
||||
metavar="SUFFIX",
|
||||
help="Optional suffix template for naming preview photos. Default name for preview photos is in form "
|
||||
f"'photoname{DEFAULT_PREVIEW_SUFFIX}.ext'. For example, with '--preview-suffix _low_res', the preview photo "
|
||||
f"would be named 'photoname_low_res.ext'. The default suffix is '{DEFAULT_PREVIEW_SUFFIX}'. "
|
||||
"Multi-value templates (see Templating System) are not permitted with --preview-suffix.",
|
||||
)
|
||||
@click.option(
|
||||
"--download-missing",
|
||||
is_flag=True,
|
||||
@@ -926,6 +997,18 @@ def cli(ctx, db, json_, debug):
|
||||
"You can run more than one command by repeating the '--post-command' option with different arguments. "
|
||||
"See Post Command below.",
|
||||
)
|
||||
@click.option(
|
||||
"--post-function",
|
||||
metavar="filename.py::function",
|
||||
nargs=1,
|
||||
type=FunctionCall(),
|
||||
multiple=True,
|
||||
help="Run function on exported files. Use this in format: --post-function filename.py::function where filename.py is a python "
|
||||
"file you've created and function is the name of the function in the python file you want to call. The function will be "
|
||||
"passed information about the photo that's been exported and a list of all exported files associated with the photo. "
|
||||
"You can run more than one function by repeating the '--post-function' option with different arguments. "
|
||||
"See Post Function below.",
|
||||
)
|
||||
@click.option(
|
||||
"--exportdb",
|
||||
metavar="EXPORTDB_FILE",
|
||||
@@ -1064,6 +1147,8 @@ def export(
|
||||
original_suffix,
|
||||
place,
|
||||
no_place,
|
||||
location,
|
||||
no_location,
|
||||
has_comment,
|
||||
no_comment,
|
||||
has_likes,
|
||||
@@ -1089,8 +1174,12 @@ def export(
|
||||
max_size,
|
||||
regex,
|
||||
query_eval,
|
||||
query_function,
|
||||
duplicate,
|
||||
post_command,
|
||||
post_function,
|
||||
preview,
|
||||
preview_suffix,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1222,6 +1311,8 @@ def export(
|
||||
original_suffix = cfg.original_suffix
|
||||
place = cfg.place
|
||||
no_place = cfg.no_place
|
||||
location = cfg.location
|
||||
no_location = cfg.no_location
|
||||
has_comment = cfg.has_comment
|
||||
no_comment = cfg.no_comment
|
||||
has_likes = cfg.has_likes
|
||||
@@ -1245,8 +1336,12 @@ def export(
|
||||
max_size = cfg.max_size
|
||||
regex = cfg.regex
|
||||
query_eval = cfg.query_eval
|
||||
query_function = cfg.query_function
|
||||
duplicate = cfg.duplicate
|
||||
post_command = cfg.post_command
|
||||
post_function = cfg.post_function
|
||||
preview = cfg.preview
|
||||
preview_suffix = cfg.preview_suffix
|
||||
|
||||
# config file might have changed verbose
|
||||
VERBOSE = bool(verbose)
|
||||
@@ -1281,6 +1376,7 @@ def export(
|
||||
("has_comment", "no_comment"),
|
||||
("has_likes", "no_likes"),
|
||||
("in_album", "not_in_album"),
|
||||
("location", "no_location"),
|
||||
]
|
||||
dependent_options = [
|
||||
("missing", ("download_missing", "use_photos_export")),
|
||||
@@ -1335,6 +1431,9 @@ def export(
|
||||
original_suffix = (
|
||||
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
|
||||
)
|
||||
preview_suffix = (
|
||||
DEFAULT_PREVIEW_SUFFIX if preview_suffix is None else preview_suffix
|
||||
)
|
||||
retry = 0 if not retry else retry
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
@@ -1534,6 +1633,8 @@ def export(
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
location=location,
|
||||
no_location=no_location,
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
@@ -1552,6 +1653,7 @@ def export(
|
||||
max_size=max_size,
|
||||
regex=regex,
|
||||
query_eval=query_eval,
|
||||
function=query_function,
|
||||
duplicate=duplicate,
|
||||
)
|
||||
|
||||
@@ -1571,6 +1673,9 @@ def export(
|
||||
previous_uuids = {uuid: 1 for uuid in export_db.get_previous_uuids()}
|
||||
photos = [p for p in photos if p.uuid not in previous_uuids]
|
||||
|
||||
# store results of export
|
||||
results = ExportResults()
|
||||
|
||||
if photos:
|
||||
num_photos = len(photos)
|
||||
# TODO: photos or photo appears several times, pull into a separate function
|
||||
@@ -1582,8 +1687,6 @@ def export(
|
||||
# because the original code used --original-name as an option
|
||||
original_name = not current_name
|
||||
|
||||
results = ExportResults()
|
||||
|
||||
# set up for --add-export-to-album if needed
|
||||
album_export = (
|
||||
PhotosAlbum(add_exported_to_album, verbose=verbose_)
|
||||
@@ -1648,8 +1751,24 @@ def export(
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=dest,
|
||||
export_preview=preview,
|
||||
preview_suffix=preview_suffix,
|
||||
)
|
||||
|
||||
if post_function:
|
||||
for function in post_function:
|
||||
# post function is tuple of (function, filename.py::function_name)
|
||||
verbose_(f"Calling post-function {function[1]}")
|
||||
if not dry_run:
|
||||
try:
|
||||
function[0](p, export_results, verbose_)
|
||||
except Exception as e:
|
||||
click.secho(
|
||||
f"Error running post-function {function[1]}: {e}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
err=True,
|
||||
)
|
||||
|
||||
run_post_command(
|
||||
photo=p,
|
||||
post_command=post_command,
|
||||
@@ -1744,41 +1863,6 @@ def export(
|
||||
if fp is not None:
|
||||
fp.close()
|
||||
|
||||
if cleanup:
|
||||
all_files = (
|
||||
results.exported
|
||||
+ results.skipped
|
||||
+ results.exif_updated
|
||||
+ results.touched
|
||||
+ results.converted_to_jpeg
|
||||
+ results.sidecar_json_written
|
||||
+ results.sidecar_json_skipped
|
||||
+ results.sidecar_exiftool_written
|
||||
+ results.sidecar_exiftool_skipped
|
||||
+ results.sidecar_xmp_written
|
||||
+ results.sidecar_xmp_skipped
|
||||
# include missing so a file that was already in export directory
|
||||
# but was missing on --update doesn't get deleted
|
||||
# (better to have old version than none)
|
||||
+ results.missing
|
||||
# include files that have error in case they exist from previous export
|
||||
+ [r[0] for r in results.error]
|
||||
+ [str(pathlib.Path(export_db_path).resolve())]
|
||||
)
|
||||
click.echo(f"Cleaning up {dest}")
|
||||
cleaned_files, cleaned_dirs = cleanup_files(dest, all_files, fileutil)
|
||||
file_str = "files" if len(cleaned_files) != 1 else "file"
|
||||
dir_str = "directories" if len(cleaned_dirs) != 1 else "directory"
|
||||
click.echo(
|
||||
f"Deleted: {len(cleaned_files)} {file_str}, {len(cleaned_dirs)} {dir_str}"
|
||||
)
|
||||
results.deleted_files = cleaned_files
|
||||
results.deleted_directories = cleaned_dirs
|
||||
|
||||
if report:
|
||||
verbose_(f"Writing export report to {report}")
|
||||
write_export_report(report, results)
|
||||
|
||||
photo_str_total = "photos" if len(photos) != 1 else "photo"
|
||||
if update:
|
||||
summary = (
|
||||
@@ -1803,6 +1887,42 @@ def export(
|
||||
else:
|
||||
click.echo("Did not find any photos to export")
|
||||
|
||||
# cleanup files and do report if needed
|
||||
if cleanup:
|
||||
all_files = (
|
||||
results.exported
|
||||
+ results.skipped
|
||||
+ results.exif_updated
|
||||
+ results.touched
|
||||
+ results.converted_to_jpeg
|
||||
+ results.sidecar_json_written
|
||||
+ results.sidecar_json_skipped
|
||||
+ results.sidecar_exiftool_written
|
||||
+ results.sidecar_exiftool_skipped
|
||||
+ results.sidecar_xmp_written
|
||||
+ results.sidecar_xmp_skipped
|
||||
# include missing so a file that was already in export directory
|
||||
# but was missing on --update doesn't get deleted
|
||||
# (better to have old version than none)
|
||||
+ results.missing
|
||||
# include files that have error in case they exist from previous export
|
||||
+ [r[0] for r in results.error]
|
||||
+ [str(pathlib.Path(export_db_path).resolve())]
|
||||
)
|
||||
click.echo(f"Cleaning up {dest}")
|
||||
cleaned_files, cleaned_dirs = cleanup_files(dest, all_files, fileutil)
|
||||
file_str = "files" if len(cleaned_files) != 1 else "file"
|
||||
dir_str = "directories" if len(cleaned_dirs) != 1 else "directory"
|
||||
click.echo(
|
||||
f"Deleted: {len(cleaned_files)} {file_str}, {len(cleaned_dirs)} {dir_str}"
|
||||
)
|
||||
results.deleted_files = cleaned_files
|
||||
results.deleted_directories = cleaned_dirs
|
||||
|
||||
if report:
|
||||
verbose_(f"Writing export report to {report}")
|
||||
write_export_report(report, results)
|
||||
|
||||
export_db.close()
|
||||
|
||||
|
||||
@@ -1923,6 +2043,8 @@ def query(
|
||||
has_raw,
|
||||
place,
|
||||
no_place,
|
||||
location,
|
||||
no_location,
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
@@ -1938,6 +2060,7 @@ def query(
|
||||
max_size,
|
||||
regex,
|
||||
query_eval,
|
||||
query_function,
|
||||
add_to_album,
|
||||
):
|
||||
"""Query the Photos database using 1 or more search options;
|
||||
@@ -1966,6 +2089,7 @@ def query(
|
||||
label,
|
||||
is_reference,
|
||||
query_eval,
|
||||
query_function,
|
||||
min_size,
|
||||
max_size,
|
||||
regex,
|
||||
@@ -1995,6 +2119,7 @@ def query(
|
||||
(has_comment, no_comment),
|
||||
(has_likes, no_likes),
|
||||
(in_album, not_in_album),
|
||||
(location, no_location),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
if any(all(bb) for bb in exclusive) or not any(
|
||||
@@ -2080,6 +2205,8 @@ def query(
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
location=location,
|
||||
no_location=no_location,
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
@@ -2094,6 +2221,7 @@ def query(
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
query_eval=query_eval,
|
||||
function=query_function,
|
||||
regex=regex,
|
||||
duplicate=duplicate,
|
||||
)
|
||||
@@ -2279,6 +2407,8 @@ def export_photo(
|
||||
replace_keywords=False,
|
||||
retry=0,
|
||||
export_dir=None,
|
||||
export_preview=False,
|
||||
preview_suffix=None,
|
||||
):
|
||||
"""Helper function for export that does the actual export
|
||||
|
||||
@@ -2320,6 +2450,8 @@ def export_photo(
|
||||
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
|
||||
retry: retry up to retry # of times if there's an error
|
||||
export_dir: top-level export directory for {export_dir} template
|
||||
export_preview: export the preview image generated by Photos
|
||||
preview_suffix: str, template to use as suffix for preview images
|
||||
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
@@ -2380,27 +2512,12 @@ def export_photo(
|
||||
if "exiftool" in sidecar:
|
||||
sidecar_flags |= SIDECAR_EXIFTOOL
|
||||
|
||||
rendered_suffix = ""
|
||||
if original_suffix:
|
||||
try:
|
||||
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
|
||||
rendered_suffix, unmatched = photo.render_template(original_suffix, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix '{original_suffix}': {e}",
|
||||
)
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unknown field={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix: may not use multi-valued templates: '{original_suffix}': results={rendered_suffix}",
|
||||
)
|
||||
rendered_suffix = rendered_suffix[0]
|
||||
rendered_suffix = _render_suffix_template(
|
||||
original_suffix, "original_suffix", "--original-suffix", strip, dest, photo
|
||||
)
|
||||
rendered_preview_suffix = _render_suffix_template(
|
||||
preview_suffix, "preview_suffix", "--preview-suffix", strip, dest, photo
|
||||
)
|
||||
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
@@ -2479,6 +2596,8 @@ def export_photo(
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=export_dir,
|
||||
export_preview=export_preview,
|
||||
preview_suffix=rendered_preview_suffix,
|
||||
)
|
||||
|
||||
if export_edited and photo.hasadjustments:
|
||||
@@ -2510,35 +2629,12 @@ def export_photo(
|
||||
):
|
||||
edited_ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
|
||||
|
||||
if edited_suffix:
|
||||
try:
|
||||
options = RenderOptions(
|
||||
filename=True,
|
||||
strip=strip,
|
||||
export_dir=dest,
|
||||
)
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
edited_suffix, options
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix '{edited_suffix}': {e}",
|
||||
)
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix '{edited_suffix}': unknown field={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix: may not use multi-valued templates: '{edited_suffix}': results={rendered_suffix}",
|
||||
)
|
||||
rendered_suffix = rendered_suffix[0]
|
||||
edited_filename = f"{edited_filename.stem}{rendered_suffix}{edited_ext}"
|
||||
else:
|
||||
edited_filename = f"{edited_filename.stem}{edited_ext}"
|
||||
rendered_edited_suffix = _render_suffix_template(
|
||||
edited_suffix, "edited_suffix", "--edited-suffix", strip, dest, photo
|
||||
)
|
||||
edited_filename = (
|
||||
f"{edited_filename.stem}{rendered_edited_suffix}{edited_ext}"
|
||||
)
|
||||
|
||||
verbose_(
|
||||
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
||||
@@ -2554,13 +2650,13 @@ def export_photo(
|
||||
dest=dest,
|
||||
dry_run=dry_run,
|
||||
strip=strip,
|
||||
export_original=export_original,
|
||||
export_original=False,
|
||||
missing=missing_edited,
|
||||
verbose=verbose,
|
||||
sidecar_flags=sidecar_flags,
|
||||
sidecar_flags=sidecar_flags if not export_original else 0,
|
||||
sidecar_drop_ext=sidecar_drop_ext,
|
||||
export_live=export_live,
|
||||
export_raw=export_raw,
|
||||
export_raw=not export_original and export_raw,
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
overwrite=overwrite,
|
||||
exiftool=exiftool,
|
||||
@@ -2584,11 +2680,43 @@ def export_photo(
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=export_dir,
|
||||
export_preview=not export_original and export_preview,
|
||||
preview_suffix=rendered_preview_suffix,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _render_suffix_template(suffix_template, var_name, option_name, strip, dest, photo):
|
||||
"""render suffix template
|
||||
|
||||
Returns:
|
||||
rendered template
|
||||
"""
|
||||
if not suffix_template:
|
||||
return ""
|
||||
|
||||
try:
|
||||
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
|
||||
rendered_suffix, unmatched = photo.render_template(suffix_template, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
var_name,
|
||||
f"Invalid template for {option_name} '{suffix_template}': {e}",
|
||||
)
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
var_name,
|
||||
f"Invalid template for {option_name} '{suffix_template}': results={rendered_suffix} unknown field={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
var_name,
|
||||
f"Invalid template for {option_name}: may not use multi-valued templates: '{suffix_template}': results={rendered_suffix}",
|
||||
)
|
||||
return rendered_suffix[0]
|
||||
|
||||
|
||||
def export_photo_with_template(
|
||||
photo,
|
||||
filename,
|
||||
@@ -2629,6 +2757,8 @@ def export_photo_with_template(
|
||||
replace_keywords,
|
||||
retry,
|
||||
export_dir,
|
||||
export_preview,
|
||||
preview_suffix,
|
||||
):
|
||||
"""Evaluate directory template then export photo to each directory"""
|
||||
|
||||
@@ -2688,8 +2818,10 @@ def export_photo_with_template(
|
||||
try:
|
||||
export_results = photo.export2(
|
||||
dest_path,
|
||||
filename,
|
||||
original_filename=filename,
|
||||
edited=edited,
|
||||
original=export_original,
|
||||
edited_filename=filename,
|
||||
sidecar=sidecar_flags,
|
||||
sidecar_drop_ext=sidecar_drop_ext,
|
||||
live_photo=export_live,
|
||||
@@ -2719,6 +2851,8 @@ def export_photo_with_template(
|
||||
jpeg_ext=jpeg_ext,
|
||||
replace_keywords=replace_keywords,
|
||||
render_options=render_options,
|
||||
preview=export_preview,
|
||||
preview_suffix=preview_suffix,
|
||||
)
|
||||
for warning_ in export_results.exiftool_warning:
|
||||
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
|
||||
@@ -3756,3 +3890,104 @@ SOFTWARE.
|
||||
click.echo("")
|
||||
click.echo(f"Source code available at: {OSXPHOTOS_URL}")
|
||||
click.echo(license)
|
||||
|
||||
|
||||
@cli.command(name="tutorial")
|
||||
@click.argument(
|
||||
"WIDTH",
|
||||
nargs=-1,
|
||||
type=click.INT,
|
||||
)
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def tutorial(ctx, cli_obj, width):
|
||||
"""Display osxphotos tutorial."""
|
||||
width = width[0] if width else 100
|
||||
click.echo_via_pager(tutorial_help(width=width))
|
||||
|
||||
|
||||
def _show_photo(photo):
|
||||
"""open image with default image viewer
|
||||
|
||||
Note: This is for debugging only -- it will actually open any filetype which could
|
||||
be very, very bad.
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo object or a path to a photo on disk
|
||||
"""
|
||||
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
return f"'{photopath}' does not appear to be a valid photo path"
|
||||
|
||||
os.system(f"open '{photopath}'")
|
||||
|
||||
|
||||
def _load_photos_db(dbpath):
|
||||
print("Loading database")
|
||||
tic = time.perf_counter()
|
||||
photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print)
|
||||
toc = time.perf_counter()
|
||||
tictoc = toc - tic
|
||||
print(f"Done: took {tictoc:0.2f} seconds")
|
||||
return photosdb
|
||||
|
||||
|
||||
def _get_photos(photosdb):
|
||||
"""get list of all photos in photosdb"""
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
|
||||
return photos
|
||||
|
||||
|
||||
def _get_selected(photosdb):
|
||||
"""get list of PhotoInfo objects for photos selected in Photos"""
|
||||
|
||||
def get_selected():
|
||||
selected = photoscript.PhotosLibrary().selection
|
||||
if not selected:
|
||||
return []
|
||||
return photosdb.photos(uuid=[p.uuid for p in selected])
|
||||
|
||||
return get_selected
|
||||
|
||||
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def repl(ctx, cli_obj, db):
|
||||
"""Run interactive osxphotos shell"""
|
||||
pretty.install()
|
||||
print(f"python version: {sys.version}")
|
||||
print(f"osxphotos version: {osxphotos._version.__version__}")
|
||||
db = db or get_photos_db()
|
||||
photosdb = _load_photos_db(db)
|
||||
print("Getting photos")
|
||||
tic = time.perf_counter()
|
||||
photos = _get_photos(photosdb)
|
||||
toc = time.perf_counter()
|
||||
tictoc = toc - tic
|
||||
|
||||
# shortcut for helper functions
|
||||
get_photo = photosdb.get_photo
|
||||
show = _show_photo
|
||||
get_selected = _get_selected(photosdb)
|
||||
|
||||
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds")
|
||||
print("The following variables are defined:")
|
||||
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
|
||||
print(
|
||||
f"- photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash"
|
||||
)
|
||||
print(f"\nThe following functions may be helpful:")
|
||||
print(f"- get_photo(uuid): return a PhotoInfo object for photo with uuid")
|
||||
print(
|
||||
f"- get_selected(): return list of PhotoInfo objects for photos selected in Photos"
|
||||
)
|
||||
print(f"- show(photo): open a photo object in the default viewer")
|
||||
print(
|
||||
f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
|
||||
)
|
||||
print(f"- quit(): exit this interactive shell\n")
|
||||
code.interact(banner="", local=locals())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Help text helper class for osxphotos CLI """
|
||||
|
||||
import io
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import click
|
||||
@@ -241,6 +242,19 @@ The following attributes may be used with '--xattr-template':
|
||||
+ "print out the exact command string which would be executed."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Function **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write_text(
|
||||
"You can run your own python functions on the exported photos for post-processing "
|
||||
+ "using the '--post-function' option. '--post-function' is passed the name a python file "
|
||||
+ "and the name of the function in the file to call using format 'filename.py::function_name'. "
|
||||
+ "See the example function at https://github.com/RhetTbull/osxphotos/blob/master/examples/post_function.py "
|
||||
+ "You may specify multiple functions to run by repeating the --post-function option. "
|
||||
+ "All post functions will be called immediately after export of each photo and immediately before any --post-command commands. "
|
||||
+ "Post functions will not be called if the --dry-run flag is set."
|
||||
)
|
||||
formatter.write("\n")
|
||||
|
||||
help_text += formatter.getvalue()
|
||||
return help_text
|
||||
@@ -250,13 +264,26 @@ def template_help(width=78):
|
||||
"""Return formatted string for template system"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
template_help_md = strip_md_links(get_template_help())
|
||||
template_help_md = strip_md_header_and_links(get_template_help())
|
||||
console.print(Markdown(template_help_md))
|
||||
help_str = sio.getvalue()
|
||||
sio.close()
|
||||
return help_str
|
||||
|
||||
|
||||
def tutorial_help(width=78):
|
||||
"""Return formatted string for tutorial"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
help_md = get_tutorial_text()
|
||||
help_md = strip_html_comments(help_md)
|
||||
help_md = strip_md_links(help_md)
|
||||
console.print(Markdown(help_md))
|
||||
help_str = sio.getvalue()
|
||||
sio.close()
|
||||
return help_str
|
||||
|
||||
|
||||
def rich_text(text, width=78):
|
||||
"""Return rich formatted text"""
|
||||
sio = io.StringIO()
|
||||
@@ -267,6 +294,26 @@ def rich_text(text, width=78):
|
||||
return rich_text
|
||||
|
||||
|
||||
def strip_md_header_and_links(md):
|
||||
"""strip markdown headers and links from markdown text md
|
||||
|
||||
Args:
|
||||
md: str, markdown text
|
||||
|
||||
Returns:
|
||||
str with markdown headers and links removed
|
||||
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
but works for the links in the osxphotos docs
|
||||
"""
|
||||
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
|
||||
|
||||
def subfn(match):
|
||||
return match.group(1)
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
def strip_md_links(md):
|
||||
"""strip markdown links from markdown text md
|
||||
|
||||
@@ -279,9 +326,23 @@ def strip_md_links(md):
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
but works for the links in the osxphotos docs
|
||||
"""
|
||||
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
|
||||
links = r"\[(.*?)\]\(.+?\)"
|
||||
|
||||
def subfn(match):
|
||||
return match.group(1)
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
def strip_html_comments(text):
|
||||
"""Strip html comments from text (which doesn't need to be valid HTML)"""
|
||||
return re.sub(r"<!--(.|\s|\n)*?-->", "", text)
|
||||
|
||||
|
||||
def get_tutorial_text():
|
||||
"""Load tutorial text from file"""
|
||||
# TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
|
||||
help_file = pathlib.Path(__file__).parent / "tutorial.md"
|
||||
with open(help_file, "r") as fd:
|
||||
md = fd.read()
|
||||
return md
|
||||
|
||||
@@ -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
|
||||
@@ -221,6 +222,7 @@ def _export_photo_uuid_applescript(
|
||||
timeout=120,
|
||||
burst=False,
|
||||
dry_run=False,
|
||||
overwrite=False,
|
||||
):
|
||||
"""Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
@@ -300,6 +302,8 @@ def _export_photo_uuid_applescript(
|
||||
# use the name Photos provided
|
||||
dest_new = dest / path.name
|
||||
if not dry_run:
|
||||
if overwrite and dest_new.exists():
|
||||
FileUtil.unlink(dest_new)
|
||||
FileUtil.copy(str(path), str(dest_new))
|
||||
exported_paths.append(str(dest_new))
|
||||
return exported_paths
|
||||
@@ -376,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,
|
||||
@@ -407,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
|
||||
@@ -446,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,
|
||||
@@ -463,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
|
||||
@@ -472,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,
|
||||
@@ -506,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
|
||||
@@ -518,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
|
||||
@@ -562,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
|
||||
@@ -606,206 +631,280 @@ 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?
|
||||
export_src_dest = []
|
||||
if edited:
|
||||
if self.path_edited is not None:
|
||||
src = self.path_edited
|
||||
export_src_dest.append((self.path_edited, dest_edited))
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Cannot export edited photo if path_edited is None"
|
||||
)
|
||||
else:
|
||||
if self.path is not None:
|
||||
src = self.path
|
||||
export_src_dest.append((self.path, dest_original))
|
||||
else:
|
||||
raise FileNotFoundError("Cannot export photo if path is None")
|
||||
|
||||
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:
|
||||
@@ -828,26 +927,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 = []
|
||||
@@ -858,6 +961,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")
|
||||
@@ -1108,7 +1212,6 @@ def export2(
|
||||
def _export_photo_with_photos_export(
|
||||
self,
|
||||
dest,
|
||||
filename,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
@@ -1121,7 +1224,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,
|
||||
):
|
||||
@@ -1134,15 +1236,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()
|
||||
@@ -1187,20 +1284,20 @@ 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,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
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
|
||||
@@ -1237,13 +1334,14 @@ 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,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
@@ -1603,7 +1701,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
|
||||
@@ -1642,7 +1742,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:
|
||||
@@ -1920,7 +2022,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:
|
||||
@@ -1953,7 +2057,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
|
||||
"""
|
||||
|
||||
@@ -29,7 +29,12 @@ class PhotosAlbum:
|
||||
)
|
||||
|
||||
def add_list(self, photo_list: List[PhotoInfo]):
|
||||
photos = [photoscript.Photo(p.uuid) for p in photo_list]
|
||||
photos = []
|
||||
for p in photo_list:
|
||||
try:
|
||||
photos.append(photoscript.Photo(p.uuid))
|
||||
except Exception as e:
|
||||
self.verbose(f"Error creating Photo object for photo {p.uuid}: {e}")
|
||||
for photolist in chunked(photos, 10):
|
||||
self.album.add(photolist)
|
||||
photo_len = len(photos)
|
||||
|
||||
@@ -246,6 +246,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 +603,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
|
||||
@@ -1595,10 +1600,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 +1727,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}
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1886,7 +1895,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 +2259,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 +2345,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 +2383,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:
|
||||
@@ -3254,6 +3319,15 @@ class PhotosDB:
|
||||
if options.deleted_only:
|
||||
photos = [p for p in photos if p.intrash]
|
||||
|
||||
if options.location:
|
||||
photos = [p for p in photos if p.location != (None, None)]
|
||||
elif options.no_location:
|
||||
photos = [p for p in photos if p.location == (None, None)]
|
||||
|
||||
if options.function:
|
||||
for function in options.function:
|
||||
photos = function[0](photos)
|
||||
|
||||
return photos
|
||||
|
||||
def _duplicate_signature(self, uuid):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,8 +4,10 @@ import datetime
|
||||
import locale
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import shlex
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from textx import TextXSyntaxError, metamodel_from_file
|
||||
|
||||
@@ -14,9 +16,7 @@ from ._version import __version__
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from .exiftool import ExifToolCaching
|
||||
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
|
||||
from .utils import load_function
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from .utils import expand_and_validate_filepath, load_function
|
||||
|
||||
# TODO: a lot of values are passed from function to function like path_sep--make these all class properties
|
||||
|
||||
@@ -1177,10 +1177,11 @@ class PhotoTemplate:
|
||||
|
||||
filename, funcname = subfield.split("::")
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
filename_validated = expand_and_validate_filepath(filename)
|
||||
if not filename_validated:
|
||||
raise ValueError(f"'{filename}' does not appear to be a file")
|
||||
|
||||
template_func = load_function(filename, funcname)
|
||||
template_func = load_function(filename_validated, funcname)
|
||||
values = template_func(self.photo)
|
||||
|
||||
if not isinstance(values, (str, list)):
|
||||
@@ -1211,10 +1212,11 @@ class PhotoTemplate:
|
||||
|
||||
filename, funcname = filter_.split("::")
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
filename_validated = expand_and_validate_filepath(filename)
|
||||
if not filename_validated:
|
||||
raise ValueError(f"'{filename}' does not appear to be a file")
|
||||
|
||||
template_func = load_function(filename, funcname)
|
||||
template_func = load_function(filename_validated, funcname)
|
||||
|
||||
if not isinstance(values, (list, tuple)):
|
||||
values = [values]
|
||||
|
||||
@@ -63,7 +63,8 @@ SubField:
|
||||
;
|
||||
|
||||
SUBFIELD_WORD:
|
||||
/[\.\w:\/]+/
|
||||
/[\.\w:\/\-\~\'\"\%\@\#\^\’]+/
|
||||
/\\\s/?
|
||||
;
|
||||
|
||||
Filter:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
""" QueryOptions class for PhotosDB.query """
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional, Iterable, Tuple
|
||||
import datetime
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
import bitmath
|
||||
|
||||
|
||||
@@ -79,6 +80,9 @@ class QueryOptions:
|
||||
regex: Optional[Iterable[Tuple[str, str]]] = None
|
||||
query_eval: Optional[Iterable[str]] = None
|
||||
duplicate: Optional[bool] = None
|
||||
location: Optional[bool] = None
|
||||
no_location: Optional[bool] = None
|
||||
function: Optional[List[Tuple[callable, str]]] = None
|
||||
|
||||
def asdict(self):
|
||||
return asdict(self)
|
||||
|
||||
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
|
||||
|
||||
@@ -38,7 +36,7 @@ if not _DEBUG:
|
||||
|
||||
def _get_logger():
|
||||
"""Used only for testing
|
||||
|
||||
|
||||
Returns:
|
||||
logging.Logger object -- logging.Logger object for osxphotos
|
||||
"""
|
||||
@@ -46,7 +44,7 @@ def _get_logger():
|
||||
|
||||
|
||||
def _set_debug(debug):
|
||||
""" Enable or disable debug logging """
|
||||
"""Enable or disable debug logging"""
|
||||
global _DEBUG
|
||||
_DEBUG = debug
|
||||
if debug:
|
||||
@@ -56,18 +54,18 @@ def _set_debug(debug):
|
||||
|
||||
|
||||
def _debug():
|
||||
""" returns True if debugging turned on (via _set_debug), otherwise, false """
|
||||
"""returns True if debugging turned on (via _set_debug), otherwise, false"""
|
||||
return _DEBUG
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
""" do nothing (no operation) """
|
||||
"""do nothing (no operation)"""
|
||||
pass
|
||||
|
||||
|
||||
def lineno(filename):
|
||||
""" Returns string with filename and current line number in caller as '(filename): line_num'
|
||||
Will trim filename to just the name, dropping path, if any. """
|
||||
"""Returns string with filename and current line number in caller as '(filename): line_num'
|
||||
Will trim filename to just the name, dropping path, if any."""
|
||||
line = inspect.currentframe().f_back.f_lineno
|
||||
filename = pathlib.Path(filename).name
|
||||
return f"{filename}: {line}"
|
||||
@@ -92,14 +90,14 @@ def _get_os_version():
|
||||
|
||||
|
||||
def _check_file_exists(filename):
|
||||
""" returns true if file exists and is not a directory
|
||||
otherwise returns false """
|
||||
"""returns true if file exists and is not a directory
|
||||
otherwise returns false"""
|
||||
filename = os.path.abspath(filename)
|
||||
return os.path.exists(filename) and not os.path.isdir(filename)
|
||||
|
||||
|
||||
def _get_resource_loc(model_id):
|
||||
""" returns folder_id and file_id needed to find location of edited photo """
|
||||
"""returns folder_id and file_id needed to find location of edited photo"""
|
||||
""" and live photos for version <= Photos 4.0 """
|
||||
# determine folder where Photos stores edited version
|
||||
# edited images are stored in:
|
||||
@@ -117,7 +115,7 @@ def _get_resource_loc(model_id):
|
||||
|
||||
|
||||
def _dd_to_dms(dd):
|
||||
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
|
||||
"""convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds"""
|
||||
""" return tuple of int(deg), int(min), float(sec) """
|
||||
dd = float(dd)
|
||||
negative = dd < 0
|
||||
@@ -136,7 +134,7 @@ def _dd_to_dms(dd):
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
"""convert latitude, longitude in degrees to degrees, minutes, seconds as string"""
|
||||
""" lat: latitude in degrees """
|
||||
""" lon: longitude in degrees """
|
||||
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
|
||||
@@ -165,7 +163,7 @@ def dd_to_dms_str(lat, lon):
|
||||
|
||||
|
||||
def get_system_library_path():
|
||||
""" return the path to the system Photos library as string """
|
||||
"""return the path to the system Photos library as string"""
|
||||
""" only works on MacOS 10.15 """
|
||||
""" on earlier versions, returns None """
|
||||
_, major, _ = _get_os_version()
|
||||
@@ -190,8 +188,8 @@ def get_system_library_path():
|
||||
|
||||
|
||||
def get_last_library_path():
|
||||
""" returns the path to the last opened Photos library
|
||||
If a library has never been opened, returns None """
|
||||
"""returns the path to the last opened Photos library
|
||||
If a library has never been opened, returns None"""
|
||||
plist_file = pathlib.Path(
|
||||
str(pathlib.Path.home())
|
||||
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
|
||||
@@ -241,7 +239,7 @@ def get_last_library_path():
|
||||
|
||||
|
||||
def list_photo_libraries():
|
||||
""" returns list of Photos libraries found on the system """
|
||||
"""returns list of Photos libraries found on the system"""
|
||||
""" on MacOS < 10.15, this may omit some libraries """
|
||||
|
||||
# On 10.15, mdfind appears to find all libraries
|
||||
@@ -265,31 +263,10 @@ 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.
|
||||
If 'path_' is invalid/doesn't exist, returns []."""
|
||||
shell pattern. Matching is case-insensitive.
|
||||
If 'path_' is invalid/doesn't exist, returns []."""
|
||||
if not os.path.isdir(path_):
|
||||
return []
|
||||
# See: https://gist.github.com/techtonik/5694830
|
||||
@@ -298,26 +275,9 @@ 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) """
|
||||
"""opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor)"""
|
||||
try:
|
||||
dbpath = pathlib.Path(dbname).resolve()
|
||||
conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True)
|
||||
@@ -328,9 +288,9 @@ def _open_sql_file(dbname):
|
||||
|
||||
|
||||
def _db_is_locked(dbname):
|
||||
""" check to see if a sqlite3 db is locked
|
||||
returns True if database is locked, otherwise False
|
||||
dbname: name of database to test """
|
||||
"""check to see if a sqlite3 db is locked
|
||||
returns True if database is locked, otherwise False
|
||||
dbname: name of database to test"""
|
||||
|
||||
# first, check to see if lock file exists, if so, assume the file is locked
|
||||
lock_name = f"{dbname}.lock"
|
||||
@@ -381,7 +341,7 @@ def _db_is_locked(dbname):
|
||||
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
"""normalize unicode data"""
|
||||
if value is not None:
|
||||
if isinstance(value, (tuple, list)):
|
||||
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
|
||||
@@ -394,9 +354,9 @@ def normalize_unicode(value):
|
||||
|
||||
|
||||
def increment_filename(filepath):
|
||||
""" Return filename (1).ext, etc if filename.ext exists
|
||||
"""Return filename (1).ext, etc if filename.ext exists
|
||||
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
add (1), (2), etc. until a non-existing filename is found.
|
||||
|
||||
Args:
|
||||
@@ -419,8 +379,22 @@ def increment_filename(filepath):
|
||||
return str(dest)
|
||||
|
||||
|
||||
def expand_and_validate_filepath(path: str) -> str:
|
||||
"""validate and expand ~ in filepath, also un-escapes spaces
|
||||
|
||||
Returns:
|
||||
expanded path if path is valid file, else None
|
||||
"""
|
||||
|
||||
path = re.sub(r"\\ ", " ", path)
|
||||
path = pathlib.Path(path).expanduser()
|
||||
if path.is_file():
|
||||
return str(path)
|
||||
return None
|
||||
|
||||
|
||||
def load_function(pyfile: str, function_name: str) -> Callable:
|
||||
""" Load function_name from python file pyfile """
|
||||
"""Load function_name from python file pyfile"""
|
||||
module_file = pathlib.Path(pyfile)
|
||||
if not module_file.is_file():
|
||||
raise FileNotFoundError(f"module {pyfile} does not appear to exist")
|
||||
|
||||
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-shm
Executable file
BIN
tests/Test-12.0.0.dev-beta.photoslibrary/database/Photos.sqlite-wal
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/photos.db
Executable file
0
tests/Test-12.0.0.dev-beta.photoslibrary/database/protection
Executable file
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-shm
Executable file
@@ -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
|
After Width: | Height: | Size: 577 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 550 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 541 KiB |
@@ -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>MigrationService</key>
|
||||
<dict>
|
||||
<key>State</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>MigrationService.LastCompletedTask</key>
|
||||
<integer>12</integer>
|
||||
<key>MigrationService.ValidationCounts</key>
|
||||
<dict>
|
||||
<key>MigrationDetectedFaceprint</key>
|
||||
<integer>6</integer>
|
||||
<key>MigrationManagedAsset</key>
|
||||
<integer>0</integer>
|
||||
<key>MigrationSceneClassification</key>
|
||||
<integer>44</integer>
|
||||
<key>MigrationUnmanagedAdjustment</key>
|
||||
<integer>0</integer>
|
||||
<key>RDVersion.cloudLocalState.CPLIsNotPushed</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>92D68107-B6C7-453B-96D2-97B0F26D5B8B/L0/020</string>
|
||||
<string>88A5F8B8-5B9A-43C7-BB85-3952B81580EB/L0/020</string>
|
||||
<string>29EF7A97-7E76-4D5F-A5E0-CC0A93E8524C/L0/020</string>
|
||||
<string>2C2AF115-BD1D-4434-A747-D1C8BD8E2045/L0/020</string>
|
||||
<string>CB051A4C-2CB7-4B90-B59B-08CC4D0C2823/L0/020</string>
|
||||
</array>
|
||||
<key>Photos</key>
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
</array>
|
||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||
<dict>
|
||||
<key>kZoomLevelIdentifierAlbums</key>
|
||||
<integer>7</integer>
|
||||
<key>kZoomLevelIdentifierVersions</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
<key>lastAddToDestination</key>
|
||||
<dict>
|
||||
<key>key</key>
|
||||
<integer>1</integer>
|
||||
<key>lastKnownDisplayName</key>
|
||||
<string>September 28, 2018</string>
|
||||
<key>type</key>
|
||||
<string>album</string>
|
||||
<key>uuid</key>
|
||||
<string>DFFKmHt3Tk+AGzZLe2Xq+g</string>
|
||||
</dict>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
<key>other</key>
|
||||
<integer>0</integer>
|
||||
<key>photos</key>
|
||||
<integer>7</integer>
|
||||
<key>videos</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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>BackgroundHighlightCollection</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2021-03-13T16:38:24Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2021-03-13T16:38:23Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-10-17T23:45:33Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-10-17T23:45:24Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2021-03-13T16:38:25Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||