Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
64bb07a026 | ||
|
|
f1902b7fd4 | ||
|
|
8e3f8fc7d0 | ||
|
|
c588dcf0ba | ||
|
|
fa29f51aeb | ||
|
|
ee0b369086 | ||
|
|
2fc45c2468 | ||
|
|
15d2f45f0c | ||
|
|
df7b73212f | ||
|
|
5143b165b5 | ||
|
|
10097323e5 | ||
|
|
c0bd0ffc9f | ||
|
|
2cdec3fc78 | ||
|
|
1a46cdf63c | ||
|
|
83892e096a | ||
|
|
6a0b8b4a3f | ||
|
|
5957fde809 | ||
|
|
5711545b81 | ||
|
|
0758f84dc4 |
4
.github/workflows/tests.yml
vendored
@@ -4,12 +4,12 @@ on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: macOS-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [macos-10.15]
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
|
||||
steps:
|
||||
|
||||
3
.isort.cfg
Normal file
@@ -0,0 +1,3 @@
|
||||
[settings]
|
||||
profile=black
|
||||
multi_line_output=3
|
||||
165
CHANGELOG.md
@@ -4,6 +4,114 @@ 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.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
|
||||
|
||||
- Added --post-command, implements #443 [`fa29f51`](https://github.com/RhetTbull/osxphotos/commit/fa29f51aeb89b3f14176693a9d0a5ff8c3565b71)
|
||||
- Added matrix for GitHub action OS [`ee0b369`](https://github.com/RhetTbull/osxphotos/commit/ee0b3690869e9dbf48e733353540c19d44da51e3)
|
||||
- Added macos 10.15 and 11 [`2fc45c2`](https://github.com/RhetTbull/osxphotos/commit/2fc45c2468ecf09bb9370f1c2057d63157501839)
|
||||
|
||||
#### [v0.42.34](https://github.com/RhetTbull/osxphotos/compare/v0.42.31...v0.42.34)
|
||||
|
||||
> 14 June 2021
|
||||
|
||||
- Refactored PhotoTemplate to support pathlib templates [`2cdec3f`](https://github.com/RhetTbull/osxphotos/commit/2cdec3fc78155a10362e6c65c2ec0e7ebf61ee38)
|
||||
- Added {filepath} template field in prep for --post-command and other goodies [`c0bd0ff`](https://github.com/RhetTbull/osxphotos/commit/c0bd0ffc9fa3c8aeefd1452cbb9b82511393004f)
|
||||
- Fixed missing more-itertools, #466 [`1009732`](https://github.com/RhetTbull/osxphotos/commit/10097323e5372939e1af69849dc1d4ddaf3c6667)
|
||||
|
||||
#### [v0.42.31](https://github.com/RhetTbull/osxphotos/compare/v0.42.30...v0.42.31)
|
||||
|
||||
> 12 June 2021
|
||||
|
||||
- Cleaned up tests, fixed bug in PhotosDB.query [`0758f84`](https://github.com/RhetTbull/osxphotos/commit/0758f84dc4bae74854c2321bc71c033d71acd4e2)
|
||||
- Added --duplicate flag to find possible duplicates [`83892e0`](https://github.com/RhetTbull/osxphotos/commit/83892e096a2987a99c2bb2dc08e7bb8ab569a289)
|
||||
- Updated README.md [skip ci] [`1a46cdf`](https://github.com/RhetTbull/osxphotos/commit/1a46cdf63ce6defbd8cd6cbacc65fa5779102582)
|
||||
|
||||
#### [v0.42.30](https://github.com/RhetTbull/osxphotos/compare/v0.42.28...v0.42.30)
|
||||
|
||||
> 9 June 2021
|
||||
|
||||
- Refactored PhotoInfo.export2 [`d7a9ad1`](https://github.com/RhetTbull/osxphotos/commit/d7a9ad1d0a6d1c4327e9d43b7719d860abd34836)
|
||||
- Updated dependencies to minimize pyobjc requirements [`61943d0`](https://github.com/RhetTbull/osxphotos/commit/61943d051b8e37397eb009c8ae0b0ba86c0ab3a3)
|
||||
- Fix for --convert-to-jpeg with use_photos_export, #460 [`4b6c35b`](https://github.com/RhetTbull/osxphotos/commit/4b6c35b5f939f18c0147fb034ab619f7c4f9b124)
|
||||
|
||||
#### [v0.42.28](https://github.com/RhetTbull/osxphotos/compare/v0.42.27...v0.42.28)
|
||||
|
||||
> 1 June 2021
|
||||
|
||||
- Added PhotoInfo.duplicates [`7accfdb`](https://github.com/RhetTbull/osxphotos/commit/7accfdb06654184e74517033749787ed049d8b7f)
|
||||
- Added CONTRIBUTING.md [`99f4394`](https://github.com/RhetTbull/osxphotos/commit/99f4394f8e71f636f6e090ecb508672f672205e8)
|
||||
|
||||
#### [v0.42.27](https://github.com/RhetTbull/osxphotos/compare/v0.42.26...v0.42.27)
|
||||
|
||||
> 29 May 2021
|
||||
@@ -22,6 +130,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Fixes for #454 [`2d68594`](https://github.com/RhetTbull/osxphotos/commit/2d68594b7811a60fedf002e712c48b1a0ca87361)
|
||||
- Updated tested versions to 11.3 [`a298772`](https://github.com/RhetTbull/osxphotos/commit/a2987725151a0e4b6e399ccfeaedceac33afd5c6)
|
||||
- Updated README.md [skip ci] [`24ccf79`](https://github.com/RhetTbull/osxphotos/commit/24ccf798c2aefd8cafa8645c1bff4c0a5776f0b1)
|
||||
- Updated README.md [skip ci] [`b026147`](https://github.com/RhetTbull/osxphotos/commit/b026147c9ad4ba01129a243a1d2d60044b0181d3)
|
||||
|
||||
#### [v0.42.24](https://github.com/RhetTbull/osxphotos/compare/v0.42.23...v0.42.24)
|
||||
|
||||
@@ -106,6 +215,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added tutorial to README [`f54205f`](https://github.com/RhetTbull/osxphotos/commit/f54205ff49a37bbef4dfca435602a50fbb4ebd02)
|
||||
- Refactored export_photo to enable work on #420 [`48c229b`](https://github.com/RhetTbull/osxphotos/commit/48c229b52c9a1881832d61434fcf38284ade918c)
|
||||
- Refactored README.md to improve Template System section [`1d14fc8`](https://github.com/RhetTbull/osxphotos/commit/1d14fc8041ae0a2b7db3b95bb08a5986176de649)
|
||||
- Updated tutorial [`aad435d`](https://github.com/RhetTbull/osxphotos/commit/aad435da3683834e17cb18b87c2aa7d1306e068e)
|
||||
- Fixed typo in tutorial [`131105d`](https://github.com/RhetTbull/osxphotos/commit/131105d82cf74bdf2dbf67077fd317d775c5b74e)
|
||||
|
||||
#### [v0.42.9](https://github.com/RhetTbull/osxphotos/compare/v0.42.8...v0.42.9)
|
||||
|
||||
@@ -121,6 +232,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Updated docs [skip ci] [`3f57514`](https://github.com/RhetTbull/osxphotos/commit/3f57514fa37bdaf372f52e02dbf76f1bc2b66b9b)
|
||||
- Updated docs [`50fa851`](https://github.com/RhetTbull/osxphotos/commit/50fa851f23f5a40f116d520fc70b1f523636b9a3)
|
||||
- Added template_filter.py to examples [`9371db0`](https://github.com/RhetTbull/osxphotos/commit/9371db094e40c3d64745b705b8b3ebdcbd04267d)
|
||||
- Fixed docs for function: filter [`1cdf4ad`](https://github.com/RhetTbull/osxphotos/commit/1cdf4addade706b5bf3105441a70fc9d529608a9)
|
||||
- Version bump [`a483b8a`](https://github.com/RhetTbull/osxphotos/commit/a483b8a900de66b6124e91d53c44260e3c3dfea8)
|
||||
|
||||
#### [v0.42.6](https://github.com/RhetTbull/osxphotos/compare/v0.42.4...v0.42.6)
|
||||
|
||||
@@ -256,6 +369,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added AdjustmentsInfo, #150, #379 [`5ee6aff`](https://github.com/RhetTbull/osxphotos/commit/5ee6affc0525db1975cb5095f62494ef10d92f7e)
|
||||
- docs: update .all-contributorsrc [skip ci] [`ebac9d0`](https://github.com/RhetTbull/osxphotos/commit/ebac9d0bfb43f59f046aacdd0290d1fcd29a3b5e)
|
||||
- docs: update README.md [skip ci] [`29716c5`](https://github.com/RhetTbull/osxphotos/commit/29716c52726a4e699c03d43ecc67db57f55b36f8)
|
||||
- Version bump [`fbe8229`](https://github.com/RhetTbull/osxphotos/commit/fbe822910370652975ab83b82344169df4c3027c)
|
||||
|
||||
#### [v0.40.17](https://github.com/RhetTbull/osxphotos/compare/v0.40.16...v0.40.17)
|
||||
|
||||
@@ -325,6 +439,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Fixed XMP template for issue #361 [`43af4d2`](https://github.com/RhetTbull/osxphotos/commit/43af4d205a7264e530bc2b2789d297be633391e1)
|
||||
- Updated sidecar test data [`591f9bc`](https://github.com/RhetTbull/osxphotos/commit/591f9bcc62720f7eddebba3b3dcff265907550dd)
|
||||
- Added tests for --only-new, #358 [`adc4b05`](https://github.com/RhetTbull/osxphotos/commit/adc4b056029794faddd464d22022a2a17298a924)
|
||||
- Updated tests for ExportDB, #358 [`48d2223`](https://github.com/RhetTbull/osxphotos/commit/48d2223edde4850830cc6a3f9776ce08f81a6636)
|
||||
- Added 11.2 to tested versions, #360 [`2284598`](https://github.com/RhetTbull/osxphotos/commit/2284598a24f63232c01dcf27b9982002123834ca)
|
||||
|
||||
#### [v0.40.6](https://github.com/RhetTbull/osxphotos/compare/v0.40.5...v0.40.6)
|
||||
|
||||
@@ -450,6 +566,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Create terminalizer-demo.yml [`5dc2eea`](https://github.com/RhetTbull/osxphotos/commit/5dc2eeaf9a7265873c81db23bbc86d3023189a26)
|
||||
- Force cleanup of objects with autorelease pool [`b67f11a`](https://github.com/RhetTbull/osxphotos/commit/b67f11a3bb95c08a39a185b6d884092870e949f2)
|
||||
- doc: Recorded screencast and updated of readme [`658e8ac`](https://github.com/RhetTbull/osxphotos/commit/658e8ac096d141fce48483dbfc1426bea317d806)
|
||||
- doc: fixed toc in readme [`aba50c5`](https://github.com/RhetTbull/osxphotos/commit/aba50c5c733420dc30f861d866a2c0bdc8933714)
|
||||
- Add @Rott-Apple as a contributor [`71cb015`](https://github.com/RhetTbull/osxphotos/commit/71cb01572d2d946df18dd7b36f95b2f2e5b48f86)
|
||||
|
||||
#### [v0.39.11](https://github.com/RhetTbull/osxphotos/compare/v0.39.10...v0.39.11)
|
||||
|
||||
@@ -461,6 +579,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Ensure merge_exif_keywords are str not int [`123ebb2`](https://github.com/RhetTbull/osxphotos/commit/123ebb2cb752bb94291ac2b77e4a327cee996df1)
|
||||
- docs: update .all-contributorsrc [skip ci] [`5e676d3`](https://github.com/RhetTbull/osxphotos/commit/5e676d3507c3e2e1f1cd9da7d8843005865c0d4c)
|
||||
- docs: update README.md [skip ci] [`935865d`](https://github.com/RhetTbull/osxphotos/commit/935865dc6572bc8e80a8eb1ab8f000342ded0a2b)
|
||||
- Updated tests workflow badge link [`a7678df`](https://github.com/RhetTbull/osxphotos/commit/a7678df3974ff539050f5acb4c94817f525dcd56)
|
||||
- Ensure keyword list only contains string [`7b6a0af`](https://github.com/RhetTbull/osxphotos/commit/7b6a0af3146202030069ed5823061ee221ab41bc)
|
||||
|
||||
#### [v0.39.10](https://github.com/RhetTbull/osxphotos/compare/v0.39.9...v0.39.10)
|
||||
|
||||
@@ -492,6 +612,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added tag_groups arg to ExifTool.asdict(), issue #324 [`2480f2a`](https://github.com/RhetTbull/osxphotos/commit/2480f2a325dbb09689f8c417618b7b9e976bfcb9)
|
||||
- doc: start with examples before the export reference [`7c7bf1b`](https://github.com/RhetTbull/osxphotos/commit/7c7bf1be6b6382a995a4e17906adfd8720d0a1c3)
|
||||
- Updated dependencies in README.md [`b1cab32`](https://github.com/RhetTbull/osxphotos/commit/b1cab32ff4c7b65ae4c9a5a9a11c175dbd487c0a)
|
||||
- remove extra spaces [`a59bb5b`](https://github.com/RhetTbull/osxphotos/commit/a59bb5b02f10fa554dae346a7271be37f50d8bcc)
|
||||
- Adding back dependency https://github.com/RhetTbull/PhotoScript) [`7c8bfc8`](https://github.com/RhetTbull/osxphotos/commit/7c8bfc811ab3a93dabadf1655f7d0e217d6c7b01)
|
||||
|
||||
#### [v0.39.6](https://github.com/RhetTbull/osxphotos/compare/v0.39.5...v0.39.6)
|
||||
|
||||
@@ -501,6 +623,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- doc simplify readme [`02ef0f9`](https://github.com/RhetTbull/osxphotos/commit/02ef0f9a254e83a3729a09cea1ae523407074896)
|
||||
- Added exception handling/capture for convert-to-jpeg, issue #322 [`05f111a`](https://github.com/RhetTbull/osxphotos/commit/05f111a287e882ed6b451a550a87753501316aba)
|
||||
- Cleanup up the readme [`38842ff`](https://github.com/RhetTbull/osxphotos/commit/38842ff9249e6f5b3069a88a759c8df97ddce51c)
|
||||
- Add @synox as a contributor [`83915c6`](https://github.com/RhetTbull/osxphotos/commit/83915c65abb880036f80ebd830eb1e34292f9599)
|
||||
|
||||
#### [v0.39.5](https://github.com/RhetTbull/osxphotos/compare/v0.39.4...v0.39.5)
|
||||
|
||||
@@ -538,6 +661,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added tests for Finder tags [`29e4245`](https://github.com/RhetTbull/osxphotos/commit/29e424575a522ae03efe5a140be46bfd0a1346c5)
|
||||
- Initial implementation for Finder tags [`5885b23`](https://github.com/RhetTbull/osxphotos/commit/5885b23d3249cf91953092a6b1ce967da2667e29)
|
||||
- Updated README for finder tags [`f25a299`](https://github.com/RhetTbull/osxphotos/commit/f25a2993097ad7b2b8ab2d1c787db58c0d799a41)
|
||||
- Updated requirements.txt [`ea373c4`](https://github.com/RhetTbull/osxphotos/commit/ea373c4197ce1cce00e89157fe560d1366f7e764)
|
||||
|
||||
#### [v0.38.22](https://github.com/RhetTbull/osxphotos/compare/v0.38.21...v0.38.22)
|
||||
|
||||
@@ -647,6 +771,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added additional test cases for #286, --ignore-signature [`880a9b6`](https://github.com/RhetTbull/osxphotos/commit/880a9b67a14787ef23ae68ad3164d7eda1af16ec)
|
||||
- Add @finestream as a contributor [`ad860b1`](https://github.com/RhetTbull/osxphotos/commit/ad860b1500dffd846322e05562ba4f2019cd1017)
|
||||
- Fixed issue #296 [`a7c688c`](https://github.com/RhetTbull/osxphotos/commit/a7c688cfc2221833e0252d71bbe596eee5f9a6e8)
|
||||
- Updated README.md [`d40b16a`](https://github.com/RhetTbull/osxphotos/commit/d40b16a456c64014674505b7c715c80b977da76a)
|
||||
- Update __main__.py [`e097f3a`](https://github.com/RhetTbull/osxphotos/commit/e097f3aad546b5be5eabab529bd2c35ce3056876)
|
||||
|
||||
#### [v0.38.5](https://github.com/RhetTbull/osxphotos/compare/v0.38.4...v0.38.5)
|
||||
|
||||
@@ -674,6 +800,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- removed extended_attributes reference [`6559c4d`](https://github.com/RhetTbull/osxphotos/commit/6559c4d8f64ad41df925182f9f24f6f67eecd1df)
|
||||
- This is why I never use branches [`baf45cc`](https://github.com/RhetTbull/osxphotos/commit/baf45ccd2aa24858bb1a8f95ef798121ee80af30)
|
||||
- Initial implementation of configoptions for --save-config, --load-config [`22355fd`](https://github.com/RhetTbull/osxphotos/commit/22355fd44609f42e412c580dfc9e5e0b7cf6c464)
|
||||
- Refactoring of save-config/load-config code [`37b1e5c`](https://github.com/RhetTbull/osxphotos/commit/37b1e5ca472e9679301fa96d2b7fdd8c4ad438b2)
|
||||
- Added tests for configoptions.py [`0262e0d`](https://github.com/RhetTbull/osxphotos/commit/0262e0d97e06ee36786b4491efa178608afb5de5)
|
||||
|
||||
#### [v0.38.0](https://github.com/RhetTbull/osxphotos/compare/v0.37.7...v0.38.0)
|
||||
|
||||
@@ -765,6 +893,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added test for missing original_filename [`116cb66`](https://github.com/RhetTbull/osxphotos/commit/116cb662fbddf9153f6858c6ea97dc7f65c77705)
|
||||
- Add @jstrine as a contributor [`7460bc8`](https://github.com/RhetTbull/osxphotos/commit/7460bc88fcc5e1e7435c9b9bcdf7ec9c7c5e39ea)
|
||||
- Escape characters which cause XML parsing issues [`c42050a`](https://github.com/RhetTbull/osxphotos/commit/c42050a10cac40b0b5ac70c587e07f257a9b50dd)
|
||||
- Fix tests for apostrophe [`d0d2e80`](https://github.com/RhetTbull/osxphotos/commit/d0d2e8080096bf66f93a830386800ce713680c51)
|
||||
- Fix test for XMP sidecar with GPS info [`c27cfb1`](https://github.com/RhetTbull/osxphotos/commit/c27cfb1223fa82b9e5549b93c283e9444693270a)
|
||||
|
||||
#### [v0.36.21](https://github.com/RhetTbull/osxphotos/compare/v0.36.20...v0.36.21)
|
||||
|
||||
@@ -948,6 +1078,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- --convert-to-jpeg initial version working [`38f201d`](https://github.com/RhetTbull/osxphotos/commit/38f201d0fb70bf299a828c1dd0d034a119e380c4)
|
||||
- Added tests, fixed bug in export_db [`5a13605`](https://github.com/RhetTbull/osxphotos/commit/5a13605f850bb947c8888246f06a5ca4e6aa5f10)
|
||||
- Updated tests [`b2b39aa`](https://github.com/RhetTbull/osxphotos/commit/b2b39aa6075df11861cf5d8945b657204f120e87)
|
||||
- Fixed path_edited for Big Sur [`c389207`](https://github.com/RhetTbull/osxphotos/commit/c389207daa4fec555fbf9d2aee8347997f9a8412)
|
||||
- Added HEIC test image [`ddc1e69`](https://github.com/RhetTbull/osxphotos/commit/ddc1e69b4a4ac712e1af312b865c4216f9ad350c)
|
||||
|
||||
#### [v0.34.5](https://github.com/RhetTbull/osxphotos/compare/v0.34.3...v0.34.5)
|
||||
|
||||
@@ -964,6 +1096,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added tests for 10.15.6 [`432da7f`](https://github.com/RhetTbull/osxphotos/commit/432da7f139a5e4b37eeb358f4ede45314407f8e5)
|
||||
- Fixed bug related to issue #222 [`c939df7`](https://github.com/RhetTbull/osxphotos/commit/c939df717159e8b97955c0b267327cd56a9ed56c)
|
||||
- Version bump for bug fix [`62d54cc`](https://github.com/RhetTbull/osxphotos/commit/62d54cc0beabd0141545608184d4b2c658eedf0f)
|
||||
- Update README.md [`6883fec`](https://github.com/RhetTbull/osxphotos/commit/6883fec2b2236d892b88327e1b4e9da1237f7dea)
|
||||
- Update exiftool.py [`3d21dad`](https://github.com/RhetTbull/osxphotos/commit/3d21dadf4102e9101e48a0c6f739a544f7f9d9de)
|
||||
|
||||
#### [v0.34.2](https://github.com/RhetTbull/osxphotos/compare/v0.34.1...v0.34.2)
|
||||
|
||||
@@ -999,6 +1133,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Normalize unicode for issue #208 [`a36eb41`](https://github.com/RhetTbull/osxphotos/commit/a36eb416b19284477922b6a5f837f4040327138b)
|
||||
- Added force_download.py to examples [`b611d34`](https://github.com/RhetTbull/osxphotos/commit/b611d34d19db480af72f57ef55eacd0a32c8d1e8)
|
||||
- Added photoshop:SidecarForExtension to XMP, partial fix for #210 [`60d96a8`](https://github.com/RhetTbull/osxphotos/commit/60d96a8f563882fba2365a6ab58c1276725eedaa)
|
||||
- Updated README.md [`c9b1518`](https://github.com/RhetTbull/osxphotos/commit/c9b15186a022d91248451279e5f973e3f2dca4b4)
|
||||
- Update README.md [`42e8fba`](https://github.com/RhetTbull/osxphotos/commit/42e8fba125a3c6b1bd0d538f2af511aabfbeb478)
|
||||
|
||||
#### [v0.33.5](https://github.com/RhetTbull/osxphotos/compare/v0.33.3...v0.33.5)
|
||||
|
||||
@@ -1025,6 +1161,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- --touch-file now working with --update [`6c11e3f`](https://github.com/RhetTbull/osxphotos/commit/6c11e3fa5b5b05b98b9fdbb0e59e3a78c7dff980)
|
||||
- Refactor/cleanup _export_photo [`eefa1f1`](https://github.com/RhetTbull/osxphotos/commit/eefa1f181f4fd7b027ae69abd2b764afb590c081)
|
||||
- Fixed touch tests [`1bf7105`](https://github.com/RhetTbull/osxphotos/commit/1bf7105737fbd756064a2f9ef4d4bbd0b067978c)
|
||||
- Working on issue 206 [`ebd878a`](https://github.com/RhetTbull/osxphotos/commit/ebd878a075983ef3df0b1ead1a725e01508721f8)
|
||||
- Working on issue #206 [`c9c9202`](https://github.com/RhetTbull/osxphotos/commit/c9c920220545dc27c8cb1379d7bde15987cce72c)
|
||||
|
||||
#### [v0.33.0](https://github.com/RhetTbull/osxphotos/compare/v0.32.0...v0.33.0)
|
||||
|
||||
@@ -1035,6 +1173,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added tests for 10.15.6 [`d2deeff`](https://github.com/RhetTbull/osxphotos/commit/d2deefff834e46e1a26adc01b1b025ac839dbc78)
|
||||
- Added ImportInfo for Photos 5+ [`98e4170`](https://github.com/RhetTbull/osxphotos/commit/98e417023ec5bd8292b25040d0844f3706645950)
|
||||
- Update README.md [`360c8d8`](https://github.com/RhetTbull/osxphotos/commit/360c8d8e1b4760e95a8b71b3a0bf0df4fb5adaf5)
|
||||
- Update README.md [`868cda8`](https://github.com/RhetTbull/osxphotos/commit/868cda8482ce6b29dd00e04a209d40550e6b128b)
|
||||
|
||||
#### [v0.32.0](https://github.com/RhetTbull/osxphotos/compare/v0.31.2...v0.32.0)
|
||||
|
||||
@@ -1051,6 +1190,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Dropped py36 due to datetime.fromisoformat [`a714ae0`](https://github.com/RhetTbull/osxphotos/commit/a714ae0af089b13acf70c4f29934393aa48ed222)
|
||||
- Added --uuid-from-file to CLI [`840e993`](https://github.com/RhetTbull/osxphotos/commit/840e9937bede407ef55972a361618683245e086b)
|
||||
- Added write_uuid_to_file.applescript to utils [`bea770b`](https://github.com/RhetTbull/osxphotos/commit/bea770b322d21cf3f8245d20e182006247cb71d6)
|
||||
- Updated README.md [`002fce8`](https://github.com/RhetTbull/osxphotos/commit/002fce8e93edd936d4b866118ae6d4c94e5d6744)
|
||||
- Added py37 [`d0ec862`](https://github.com/RhetTbull/osxphotos/commit/d0ec8620c721fe7576ab7d519a5eaac4d17a317e)
|
||||
|
||||
#### [v0.31.0](https://github.com/RhetTbull/osxphotos/compare/v0.30.13...v0.31.0)
|
||||
|
||||
@@ -1262,6 +1403,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added test for Photos 5 on 10.15.5 [`2243395`](https://github.com/RhetTbull/osxphotos/commit/2243395bff9e1cc379626cc5007e44e6e63b95e0)
|
||||
- Refactored template code out of PhotoInfo into PhotoTemplate [`16f802b`](https://github.com/RhetTbull/osxphotos/commit/16f802bf717610e13712b8aa477d05d94b14d294)
|
||||
- Added test for SearchInfo on 10.15.5 [`3a8bef1`](https://github.com/RhetTbull/osxphotos/commit/3a8bef1572e4d83b1e0a4b85c8f06e329cc7e8de)
|
||||
- performance improvements for update and export_db [`42b89d3`](https://github.com/RhetTbull/osxphotos/commit/42b89d34f3d14818daefbd3bfabc1be9344d2e1a)
|
||||
- More refactoring in PhotoTemplate [`f35ea70`](https://github.com/RhetTbull/osxphotos/commit/f35ea70b72e8c6743b1f6009466d2a15d40338ac)
|
||||
|
||||
#### [v0.29.5](https://github.com/RhetTbull/osxphotos/compare/v0.29.2...v0.29.5)
|
||||
|
||||
@@ -1293,6 +1436,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added --update to CLI export; reference issue #100 [`b1171e9`](https://github.com/RhetTbull/osxphotos/commit/b1171e96cc06362555725995bb311317eb163e49)
|
||||
- Added as_dict to PlaceInfo [`8c4fe40`](https://github.com/RhetTbull/osxphotos/commit/8c4fe40aa6850f166e526cffaa088550884399af)
|
||||
- Updated README.md [`11d368a`](https://github.com/RhetTbull/osxphotos/commit/11d368a69cbe67e909e64b020f0334fc09dd3ac4)
|
||||
- version bump [`c06c230`](https://github.com/RhetTbull/osxphotos/commit/c06c230a469754691d11fff1034fb02daeeba649)
|
||||
- Test library update [`f416418`](https://github.com/RhetTbull/osxphotos/commit/f416418546a12bc6c1bda13f6b712758584d06dc)
|
||||
|
||||
#### [v0.28.19](https://github.com/RhetTbull/osxphotos/compare/v0.28.18...v0.28.19)
|
||||
|
||||
@@ -1302,6 +1447,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Revert "test library updates" [`48e9c32`](https://github.com/RhetTbull/osxphotos/commit/48e9c32add549e66c3ef8c65f8821f5033b55b11)
|
||||
- test library updates [`d125854`](https://github.com/RhetTbull/osxphotos/commit/d125854f2a04e37747af3e0796370a565c1c9bd0)
|
||||
- version bump [`bd9d5a2`](https://github.com/RhetTbull/osxphotos/commit/bd9d5a26f3bfcbb33896a139fa86cdab46768103)
|
||||
- Update README.md [`85760dc`](https://github.com/RhetTbull/osxphotos/commit/85760dc4fe2274d826ed80494fd4e66866398609)
|
||||
- Update README.md [`be07f90`](https://github.com/RhetTbull/osxphotos/commit/be07f90e5a8179e452730ea654e4c9627b1f6ebc)
|
||||
|
||||
#### [v0.28.18](https://github.com/RhetTbull/osxphotos/compare/v0.28.17...v0.28.18)
|
||||
|
||||
@@ -1326,6 +1473,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Refactored photosdb and photoinfo to add SearchInfo and labels [`98b3f63`](https://github.com/RhetTbull/osxphotos/commit/98b3f63a92aa2105f8fa97af992fc6fe2d78b973)
|
||||
- Added additional test for --export-as-hardlink [`57315d4`](https://github.com/RhetTbull/osxphotos/commit/57315d44497fde977956f76f667470208f11aa2d)
|
||||
- Updated a couple of tests to use pytest-mock [`397db0d`](https://github.com/RhetTbull/osxphotos/commit/397db0d72fb218669a9ecbff134fa9b392a14661)
|
||||
- added test for export using hardlinks, fixed a test that failed if users locale settings were different to en_US [`b0ec6c6`](https://github.com/RhetTbull/osxphotos/commit/b0ec6c6b36d8cfe05723d47b210d9d7c5aabdfe5)
|
||||
- Added link to original work by @simonw [`ca8f2b8`](https://github.com/RhetTbull/osxphotos/commit/ca8f2b8d5c55b5a554fd1337b1070c97ec381916)
|
||||
|
||||
#### [v0.28.13](https://github.com/RhetTbull/osxphotos/compare/v0.28.10...v0.28.13)
|
||||
|
||||
@@ -1376,6 +1525,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Updated tests and test library with RAW images [`9b9b54e`](https://github.com/RhetTbull/osxphotos/commit/9b9b54e590e43ae49fb3ae41d493a1f8faec4181)
|
||||
- Updated setup.py to resolve issue with bpylist2 on python < 3.8 [`8e4b88a`](https://github.com/RhetTbull/osxphotos/commit/8e4b88ad1fc18438f941e045bfc8aeac878914f9)
|
||||
- Added cli.py for use with pyinstaller [`cf28cb6`](https://github.com/RhetTbull/osxphotos/commit/cf28cb6452de17f2ef8d80435386e8d5a1aabd34)
|
||||
- added raw_is_original handling [`a337e79`](https://github.com/RhetTbull/osxphotos/commit/a337e79e13802b4824c2f088ce9db1c027d6f3c5)
|
||||
- Updated setup.py and README with install instructions [`85d2baa`](https://github.com/RhetTbull/osxphotos/commit/85d2baac104fbd0db5cccc0888a55805a2385b9a)
|
||||
|
||||
#### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2)
|
||||
|
||||
@@ -1454,6 +1605,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Updated render_filepath_template to support multiple values [`6a89888`](https://github.com/RhetTbull/osxphotos/commit/6a898886ddadc9d5bc9dbad6ee7365270dd0a26d)
|
||||
- Added {album}, {keyword}, and {person} to template system [`507c4a3`](https://github.com/RhetTbull/osxphotos/commit/507c4a374014f999ca19789bce0df0c14332e021)
|
||||
- Added places command to CLI [`fd5e748`](https://github.com/RhetTbull/osxphotos/commit/fd5e748dca759ea1c3a7329d447f363afe8418b7)
|
||||
- Updated export example [`01cd7fe`](https://github.com/RhetTbull/osxphotos/commit/01cd7fed6d7fc0c61c171a05319c211eb0a9f7c1)
|
||||
- Fixed typo in help text [`c02953e`](https://github.com/RhetTbull/osxphotos/commit/c02953ef5fe1aee219e0557bfd8c3322f1900a81)
|
||||
|
||||
#### [v0.24.2](https://github.com/RhetTbull/osxphotos/compare/v0.24.1...v0.24.2)
|
||||
|
||||
@@ -1532,6 +1685,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added query/export options for special media types [`2b7d84a`](https://github.com/RhetTbull/osxphotos/commit/2b7d84a4d103982ad874d875bafbc34d654d539a)
|
||||
- README.md update [`a27ce33`](https://github.com/RhetTbull/osxphotos/commit/a27ce33473df3260dfb7ed26e28295cbf87d1e78)
|
||||
- Test library updates [`2d7d0b8`](https://github.com/RhetTbull/osxphotos/commit/2d7d0b86e0008cae043e314937504f36ad882990)
|
||||
- Fixed bug in --download-missing related to burst images [`1f13ba8`](https://github.com/RhetTbull/osxphotos/commit/1f13ba837fe36ff4eeb48cca02f5312a88a0a765)
|
||||
- test library update [`acb6b9e`](https://github.com/RhetTbull/osxphotos/commit/acb6b9e72f7f6b8f4f1d64b46f270a4d3e984fef)
|
||||
|
||||
#### [v0.22.13](https://github.com/RhetTbull/osxphotos/compare/v0.22.12...v0.22.13)
|
||||
|
||||
@@ -1565,6 +1720,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Slight refactor to PhotosDB.photos() [`91d5729`](https://github.com/RhetTbull/osxphotos/commit/91d5729beaa0f0c2583e6320b18d958429e66075)
|
||||
- Test library updates [`6e563e2`](https://github.com/RhetTbull/osxphotos/commit/6e563e214c569ba7838f7464de9258c3bba5db23)
|
||||
- Removed _tmp_file code that's no longer needed [`27994c9`](https://github.com/RhetTbull/osxphotos/commit/27994c9fd372303833a5794f1de9815f425c762e)
|
||||
- Updated photos_repl.py [`fdf636a`](https://github.com/RhetTbull/osxphotos/commit/fdf636ac8864ebb2cc324b1f9d3c6c82ee3910f9)
|
||||
- Added PhotosDB() behavior to open last library if no args passed but also added cautionary note to README [`46d3c7d`](https://github.com/RhetTbull/osxphotos/commit/46d3c7dbdaf848d5c340ce8a362ff296a36c552d)
|
||||
|
||||
#### [v0.22.7](https://github.com/RhetTbull/osxphotos/compare/v0.22.4...v0.22.7)
|
||||
|
||||
@@ -1577,6 +1734,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added XMP sidecar to export [`4dfb131`](https://github.com/RhetTbull/osxphotos/commit/4dfb131a21b1b1efefe3b918ecb06fc6fcb03f2c)
|
||||
- Added date_modified to PhotoInfo [`67b0ae0`](https://github.com/RhetTbull/osxphotos/commit/67b0ae0bf679815372d415c3064e21d46a5b8718)
|
||||
- Added date_modified to PhotoInfo [`4d36b3b`](https://github.com/RhetTbull/osxphotos/commit/4d36b3b31f3e0e74d9d111b6b691771e19f94086)
|
||||
- Updated CLI options with more descriptive metavar names [`e79cb92`](https://github.com/RhetTbull/osxphotos/commit/e79cb92693758c984dc789d5fa5d2e87e381e921)
|
||||
- CLI now looks for photos library to use if non specified by user [`50b7e69`](https://github.com/RhetTbull/osxphotos/commit/50b7e6920a694aa45f478d1131868525c9147919)
|
||||
|
||||
#### [v0.22.4](https://github.com/RhetTbull/osxphotos/compare/v0.22.0...v0.22.4)
|
||||
|
||||
@@ -1587,6 +1746,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Refactor cli: singular --db, --json and query options. [`e214746`](https://github.com/RhetTbull/osxphotos/commit/e214746063271e6f9f586286103ed051ada49d85)
|
||||
- Implement from_date and to_date in PhotosDB as well as query and export command. Some refactoring of CLI as well. [`cfa2b4a`](https://github.com/RhetTbull/osxphotos/commit/cfa2b4a828facf0aff5bc19f777457ad776c4a05)
|
||||
- Refactored _query. Still hairy, but less so. [`b9dee49`](https://github.com/RhetTbull/osxphotos/commit/b9dee4995c6d89fadb3d2482374b7098f2ab5ed9)
|
||||
- Updated README.md [`0aff83f`](https://github.com/RhetTbull/osxphotos/commit/0aff83ff21c20e293c0b75bacf2863090a0fb725)
|
||||
- Started adding tests for CLI [`f0b18c3`](https://github.com/RhetTbull/osxphotos/commit/f0b18c3d29b2141d348be0495013c51c072c6251)
|
||||
|
||||
#### [v0.22.0](https://github.com/RhetTbull/osxphotos/compare/v0.21.5...v0.22.0)
|
||||
|
||||
@@ -1637,6 +1798,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- removed old applescript code and files [`1839593`](https://github.com/RhetTbull/osxphotos/commit/18395933a583314d5d992492713752003852e75c)
|
||||
- Added test cases and documentation for shared photos and shared albums [`6d20e9e`](https://github.com/RhetTbull/osxphotos/commit/6d20e9e36185aa027d82237cadfe3b55614ba96f)
|
||||
- Refactored PhotoInfo to use properties instead of methods--major update [`1ddd90c`](https://github.com/RhetTbull/osxphotos/commit/1ddd90cbdc824afc5df9d2347e730bd9f86350ee)
|
||||
- Moved PhotosDB attributes to properties instead of methods [`d95acdf`](https://github.com/RhetTbull/osxphotos/commit/d95acdf9f8764a1720bcba71a6dad29bf668eaf9)
|
||||
- changed interface for export, prepped for exiftool_json_sidecar [`1fe8859`](https://github.com/RhetTbull/osxphotos/commit/1fe885962e8a9a420e776bdd3dc640ca143224b2)
|
||||
|
||||
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.15.0...v0.15.1)
|
||||
|
||||
@@ -1660,6 +1823,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- Added get_db_path and get_library_path to PhotosDB [`1d006a4`](https://github.com/RhetTbull/osxphotos/commit/1d006a4b50ed58b01c6116734bef5f740655a063)
|
||||
- Updated PhotosDB.__init__() to accept positional or named arg for dbfile and added associated tests [`9118043`](https://github.com/RhetTbull/osxphotos/commit/911804317b98bf485a39b8588c772be14314aa51)
|
||||
- Updated album code in process_database4 and process_database5 to use album uuid [`1cf3e4b`](https://github.com/RhetTbull/osxphotos/commit/1cf3e4b9540c15f8bda2545deb183912bcda40a7)
|
||||
- Updated get_db_version and associated tests [`eb563ad`](https://github.com/RhetTbull/osxphotos/commit/eb563ad29738f29f3514ebfb4747baa2dc5356be)
|
||||
- Added external_edit for Photos 5 [`42baa29`](https://github.com/RhetTbull/osxphotos/commit/42baa29c18fe2ff16e4d684f87ef7a85993898c1)
|
||||
|
||||
#### [v0.14.8](https://github.com/RhetTbull/osxphotos/compare/v0.14.6...v0.14.8)
|
||||
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
from ._version import __version__
|
||||
from .photoinfo import PhotoInfo
|
||||
from .exiftool import ExifTool
|
||||
from .photoinfo import ExportResults, PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
|
||||
from .phototemplate import PhotoTemplate
|
||||
@@ -7,5 +8,4 @@ from .queryoptions import QueryOptions
|
||||
from .utils import _debug, _get_logger, _set_debug
|
||||
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
# TODO: Add test for __str__ and to_json
|
||||
# TODO: Add special albums and magic albums
|
||||
|
||||
@@ -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
|
||||
@@ -220,3 +242,24 @@ BURST_KEY = 0b10000 # 16: burst image is the key photo (top of burst stack)
|
||||
BURST_UNKNOWN = 0b100000 # 32: this is almost always set with BURST_DEFAULT_PICK and never if BURST_DEFAULT_PICK is not set. I think this has something to do with what algorithm Photos used to pick the default image
|
||||
|
||||
LIVE_VIDEO_EXTENSIONS = [".mov"]
|
||||
|
||||
# categories that --post-command can be used with; these map to ExportResults fields
|
||||
POST_COMMAND_CATEGORIES = {
|
||||
"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",
|
||||
# "deleted_files": "When used with '--cleanup', all files deleted during the export",
|
||||
# "deleted_directories": "When used with '--cleanup', all directories deleted during the export",
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.30"
|
||||
__version__ = "0.42.51"
|
||||
|
||||
469
osxphotos/cli.py
@@ -1,5 +1,6 @@
|
||||
"""Command line interface for osxphotos """
|
||||
|
||||
import code
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
@@ -7,15 +8,16 @@ import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import pprint
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
import bitmath
|
||||
import click
|
||||
import osxmetadata
|
||||
import photoscript
|
||||
import yaml
|
||||
from rich import pretty
|
||||
|
||||
import osxphotos
|
||||
|
||||
@@ -33,13 +35,13 @@ from ._constants import (
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
OSXPHOTOS_URL,
|
||||
POST_COMMAND_CATEGORIES,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
SIDECAR_XMP,
|
||||
UNICODE_FORMAT,
|
||||
)
|
||||
from ._version import __version__
|
||||
from .cli_help import ExportCommand
|
||||
from .cli_help import ExportCommand, tutorial_help
|
||||
from .configoptions import (
|
||||
ConfigOptions,
|
||||
ConfigOptionsInvalidError,
|
||||
@@ -52,9 +54,11 @@ from .fileutil import FileUtil, FileUtilNoOp
|
||||
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||
from .photoinfo import ExportResults
|
||||
from .photokit import check_photokit_authorization, request_photokit_authorization
|
||||
from .queryoptions import QueryOptions
|
||||
from .utils import get_preferred_uti_extension
|
||||
from .photosalbum import PhotosAlbum
|
||||
from .phototemplate import PhotoTemplate, RenderOptions
|
||||
from .queryoptions import QueryOptions
|
||||
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
|
||||
@@ -62,7 +66,7 @@ VERBOSE = False
|
||||
|
||||
|
||||
def verbose_(*args, **kwargs):
|
||||
""" print output if verbose flag set """
|
||||
"""print output if verbose flag set"""
|
||||
if VERBOSE:
|
||||
styled_args = []
|
||||
for arg in args:
|
||||
@@ -117,7 +121,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]]]]"
|
||||
)
|
||||
|
||||
@@ -151,12 +155,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):
|
||||
@@ -306,6 +334,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",
|
||||
@@ -458,6 +496,14 @@ def QUERY_OPTIONS(f):
|
||||
is_flag=True,
|
||||
help="Search for photos that are not in any albums.",
|
||||
),
|
||||
o(
|
||||
"--duplicate",
|
||||
is_flag=True,
|
||||
help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, "
|
||||
"evaluating date created, size, height, width, and edited status to find *possible* duplicates. "
|
||||
"This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple "
|
||||
"times or duplicated within Photos.",
|
||||
),
|
||||
o(
|
||||
"--min-size",
|
||||
metavar="SIZE",
|
||||
@@ -499,6 +545,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)
|
||||
@@ -614,9 +672,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",
|
||||
@@ -628,8 +686,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",
|
||||
@@ -904,6 +965,31 @@ def cli(ctx, db, json_, debug):
|
||||
"This only works if the Photos library being exported is the last-opened (default) library in Photos. "
|
||||
"This feature is currently experimental. I don't know how well it will work on large export sets.",
|
||||
)
|
||||
@click.option(
|
||||
"--post-command",
|
||||
metavar="CATEGORY COMMAND",
|
||||
nargs=2,
|
||||
type=(click.Choice(POST_COMMAND_CATEGORIES, case_sensitive=False), str),
|
||||
multiple=True,
|
||||
help="Run COMMAND on exported files of category CATEGORY. CATEGORY can be one of: "
|
||||
f"{', '.join(list(POST_COMMAND_CATEGORIES.keys()))}. "
|
||||
"COMMAND is an osxphotos template string, for example: '--post-command exported \"echo {filepath|shell_quote} >> {export_dir}/exported.txt\"', "
|
||||
"which appends the full path of all exported files to the file 'exported.txt'. "
|
||||
"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",
|
||||
@@ -1042,6 +1128,8 @@ def export(
|
||||
original_suffix,
|
||||
place,
|
||||
no_place,
|
||||
location,
|
||||
no_location,
|
||||
has_comment,
|
||||
no_comment,
|
||||
has_likes,
|
||||
@@ -1067,6 +1155,10 @@ def export(
|
||||
max_size,
|
||||
regex,
|
||||
query_eval,
|
||||
query_function,
|
||||
duplicate,
|
||||
post_command,
|
||||
post_function,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1198,6 +1290,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
|
||||
@@ -1221,6 +1315,10 @@ 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
|
||||
|
||||
# config file might have changed verbose
|
||||
VERBOSE = bool(verbose)
|
||||
@@ -1255,6 +1353,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")),
|
||||
@@ -1508,6 +1607,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,
|
||||
@@ -1526,6 +1627,8 @@ def export(
|
||||
max_size=max_size,
|
||||
regex=regex,
|
||||
query_eval=query_eval,
|
||||
function=query_function,
|
||||
duplicate=duplicate,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1539,12 +1642,15 @@ def export(
|
||||
else:
|
||||
raise ValueError(e)
|
||||
|
||||
if photos:
|
||||
if only_new:
|
||||
# ignore previously exported files
|
||||
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]
|
||||
if photos and only_new:
|
||||
# ignore previously exported files
|
||||
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
|
||||
photo_str = "photos" if num_photos > 1 else "photo"
|
||||
@@ -1555,8 +1661,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_)
|
||||
@@ -1620,6 +1724,30 @@ def export(
|
||||
jpeg_ext=jpeg_ext,
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=dest,
|
||||
)
|
||||
|
||||
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,
|
||||
export_results=export_results,
|
||||
export_dir=dest,
|
||||
dry_run=dry_run,
|
||||
exiftool_path=exiftool_path,
|
||||
)
|
||||
|
||||
if album_export and export_results.exported:
|
||||
@@ -1688,13 +1816,18 @@ def export(
|
||||
exiftool_merge_keywords=exiftool_merge_keywords,
|
||||
finder_tag_template=finder_tag_template,
|
||||
strip=strip,
|
||||
export_dir=dest,
|
||||
)
|
||||
results.xattr_written.extend(tags_written)
|
||||
results.xattr_skipped.extend(tags_skipped)
|
||||
|
||||
if xattr_template:
|
||||
xattr_written, xattr_skipped = write_extended_attributes(
|
||||
p, photo_files, xattr_template, strip=strip
|
||||
p,
|
||||
photo_files,
|
||||
xattr_template,
|
||||
strip=strip,
|
||||
export_dir=dest,
|
||||
)
|
||||
results.xattr_written.extend(xattr_written)
|
||||
results.xattr_skipped.extend(xattr_skipped)
|
||||
@@ -1702,41 +1835,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 = (
|
||||
@@ -1761,6 +1859,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()
|
||||
|
||||
|
||||
@@ -1768,7 +1902,7 @@ def export(
|
||||
@click.argument("topic", default=None, required=False, nargs=1)
|
||||
@click.pass_context
|
||||
def help(ctx, topic, **kw):
|
||||
""" Print help; for help on commands: help <command>. """
|
||||
"""Print help; for help on commands: help <command>."""
|
||||
if topic is None:
|
||||
click.echo(ctx.parent.get_help())
|
||||
elif topic in cli.commands:
|
||||
@@ -1881,6 +2015,8 @@ def query(
|
||||
has_raw,
|
||||
place,
|
||||
no_place,
|
||||
location,
|
||||
no_location,
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
@@ -1891,10 +2027,12 @@ def query(
|
||||
is_reference,
|
||||
in_album,
|
||||
not_in_album,
|
||||
duplicate,
|
||||
min_size,
|
||||
max_size,
|
||||
regex,
|
||||
query_eval,
|
||||
query_function,
|
||||
add_to_album,
|
||||
):
|
||||
"""Query the Photos database using 1 or more search options;
|
||||
@@ -1923,9 +2061,11 @@ def query(
|
||||
label,
|
||||
is_reference,
|
||||
query_eval,
|
||||
query_function,
|
||||
min_size,
|
||||
max_size,
|
||||
regex,
|
||||
duplicate,
|
||||
]
|
||||
exclusive = [
|
||||
(favorite, not_favorite),
|
||||
@@ -1951,6 +2091,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(
|
||||
@@ -2036,6 +2177,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,
|
||||
@@ -2050,7 +2193,9 @@ def query(
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
query_eval=query_eval,
|
||||
function=query_function,
|
||||
regex=regex,
|
||||
duplicate=duplicate,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -2079,7 +2224,7 @@ def query(
|
||||
album_query.add_list(photos)
|
||||
except Exception as e:
|
||||
click.secho(
|
||||
f"Error adding photos to album {add_to_album}",
|
||||
f"Error adding photos to album {add_to_album}: {e}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
err=True,
|
||||
)
|
||||
@@ -2233,6 +2378,7 @@ def export_photo(
|
||||
jpeg_ext=None,
|
||||
replace_keywords=False,
|
||||
retry=0,
|
||||
export_dir=None,
|
||||
):
|
||||
"""Helper function for export that does the actual export
|
||||
|
||||
@@ -2273,6 +2419,7 @@ def export_photo(
|
||||
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
|
||||
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
|
||||
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
@@ -2336,9 +2483,8 @@ def export_photo(
|
||||
rendered_suffix = ""
|
||||
if original_suffix:
|
||||
try:
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
original_suffix, filename=True, strip=strip
|
||||
)
|
||||
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",
|
||||
@@ -2432,6 +2578,7 @@ def export_photo(
|
||||
jpeg_ext=jpeg_ext,
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=export_dir,
|
||||
)
|
||||
|
||||
if export_edited and photo.hasadjustments:
|
||||
@@ -2465,8 +2612,13 @@ def export_photo(
|
||||
|
||||
if edited_suffix:
|
||||
try:
|
||||
options = RenderOptions(
|
||||
filename=True,
|
||||
strip=strip,
|
||||
export_dir=dest,
|
||||
)
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
edited_suffix, filename=True, strip=strip
|
||||
edited_suffix, options
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
@@ -2531,6 +2683,7 @@ def export_photo(
|
||||
jpeg_ext=jpeg_ext,
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=export_dir,
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -2575,8 +2728,9 @@ def export_photo_with_template(
|
||||
jpeg_ext,
|
||||
replace_keywords,
|
||||
retry,
|
||||
export_dir,
|
||||
):
|
||||
""" Evaluate directory template then export photo to each directory """
|
||||
"""Evaluate directory template then export photo to each directory"""
|
||||
|
||||
results = ExportResults()
|
||||
|
||||
@@ -2625,6 +2779,8 @@ def export_photo_with_template(
|
||||
results.missing.append(str(pathlib.Path(dest_path) / filename))
|
||||
continue
|
||||
|
||||
render_options = RenderOptions(export_dir=export_dir)
|
||||
|
||||
tries = 0
|
||||
while tries <= retry:
|
||||
tries += 1
|
||||
@@ -2662,6 +2818,7 @@ def export_photo_with_template(
|
||||
exiftool_flags=exiftool_option,
|
||||
jpeg_ext=jpeg_ext,
|
||||
replace_keywords=replace_keywords,
|
||||
render_options=render_options,
|
||||
)
|
||||
for warning_ in export_results.exiftool_warning:
|
||||
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
|
||||
@@ -2723,7 +2880,11 @@ def export_photo_with_template(
|
||||
|
||||
|
||||
def get_filenames_from_template(
|
||||
photo, filename_template, original_name, strip=False, edited=False
|
||||
photo,
|
||||
filename_template,
|
||||
original_name,
|
||||
strip=False,
|
||||
edited=False,
|
||||
):
|
||||
"""get list of export filenames for a photo
|
||||
|
||||
@@ -2743,13 +2904,13 @@ def get_filenames_from_template(
|
||||
if filename_template:
|
||||
photo_ext = pathlib.Path(photo.original_filename).suffix
|
||||
try:
|
||||
filenames, unmatched = photo.render_template(
|
||||
filename_template,
|
||||
options = RenderOptions(
|
||||
path_sep="_",
|
||||
filename=True,
|
||||
strip=strip,
|
||||
edited=edited,
|
||||
edited_version=edited,
|
||||
)
|
||||
filenames, unmatched = photo.render_template(filename_template, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"filename_template", f"Invalid template '{filename_template}': {e}"
|
||||
@@ -2803,9 +2964,8 @@ def get_dirnames_from_template(
|
||||
elif directory:
|
||||
# got a directory template, render it and check results are valid
|
||||
try:
|
||||
dirnames, unmatched = photo.render_template(
|
||||
directory, dirname=True, strip=strip, edited=edited
|
||||
)
|
||||
options = RenderOptions(dirname=True, strip=strip, edited_version=edited)
|
||||
dirnames, unmatched = photo.render_template(directory, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"directory", f"Invalid template '{directory}': {e}"
|
||||
@@ -3086,6 +3246,7 @@ def write_finder_tags(
|
||||
exiftool_merge_keywords=None,
|
||||
finder_tag_template=None,
|
||||
strip=False,
|
||||
export_dir=None,
|
||||
):
|
||||
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
|
||||
|
||||
@@ -3098,6 +3259,7 @@ def write_finder_tags(
|
||||
person_keyword: if True, use person in image as keywords
|
||||
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
|
||||
finder_tag_template: list of templates to evaluate for determining Finder tags
|
||||
export_dir: value to use for {export_dir} template
|
||||
|
||||
Returns:
|
||||
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
|
||||
@@ -3124,12 +3286,13 @@ def write_finder_tags(
|
||||
rendered_tags = []
|
||||
for template_str in finder_tag_template:
|
||||
try:
|
||||
rendered, unmatched = photo.render_template(
|
||||
template_str,
|
||||
options = RenderOptions(
|
||||
none_str=_OSXPHOTOS_NONE_SENTINEL,
|
||||
path_sep="/",
|
||||
strip=strip,
|
||||
export_dir=export_dir,
|
||||
)
|
||||
rendered, unmatched = photo.render_template(template_str, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"finder_tag_template",
|
||||
@@ -3166,13 +3329,16 @@ def write_finder_tags(
|
||||
return (written, skipped)
|
||||
|
||||
|
||||
def write_extended_attributes(photo, files, xattr_template, strip=False):
|
||||
""" Writes extended attributes to exported files
|
||||
def write_extended_attributes(
|
||||
photo, files, xattr_template, strip=False, export_dir=None
|
||||
):
|
||||
"""Writes extended attributes to exported files
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo object
|
||||
xattr_template: list of tuples: (attribute name, attribute template)
|
||||
|
||||
strip: xattr_template: list of tuples: (attribute name, attribute template)
|
||||
export_dir: value to use for {export_dir} template
|
||||
|
||||
Returns:
|
||||
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
|
||||
"""
|
||||
@@ -3180,12 +3346,13 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
|
||||
attributes = {}
|
||||
for xattr, template_str in xattr_template:
|
||||
try:
|
||||
rendered, unmatched = photo.render_template(
|
||||
template_str,
|
||||
options = RenderOptions(
|
||||
none_str=_OSXPHOTOS_NONE_SENTINEL,
|
||||
path_sep="/",
|
||||
strip=strip,
|
||||
export_dir=export_dir,
|
||||
)
|
||||
rendered, unmatched = photo.render_template(template_str, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"xattr_template",
|
||||
@@ -3234,6 +3401,46 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
|
||||
return list(written), [f for f in skipped if f not in written]
|
||||
|
||||
|
||||
def run_post_command(
|
||||
photo, post_command, export_results, export_dir, dry_run, exiftool_path
|
||||
):
|
||||
# todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?)
|
||||
# todo: need a shell_quote template type:
|
||||
# {shell_quote,{filepath}/foo/bar}
|
||||
# that quotes everything in the default value
|
||||
for category, command_template in post_command:
|
||||
files = getattr(export_results, category)
|
||||
for f in files:
|
||||
# some categories, like error, return a tuple of (file, error str)
|
||||
if isinstance(f, tuple):
|
||||
f = f[0]
|
||||
render_options = RenderOptions(export_dir=export_dir, filepath=f)
|
||||
template = PhotoTemplate(photo, exiftool_path=exiftool_path)
|
||||
command, _ = template.render(command_template, options=render_options)
|
||||
command = command[0] if command else None
|
||||
if command:
|
||||
verbose_(f'Running command: "{command}"')
|
||||
if not dry_run:
|
||||
args = shlex.split(command)
|
||||
cwd = pathlib.Path(f).parent
|
||||
run_error = None
|
||||
run_results = None
|
||||
try:
|
||||
run_results = subprocess.run(command, shell=True, cwd=cwd)
|
||||
except Exception as e:
|
||||
run_error = e
|
||||
finally:
|
||||
run_error = run_error or run_results.returncode
|
||||
if run_error:
|
||||
click.echo(
|
||||
click.style(
|
||||
f'Error running command "{command}": {run_error}',
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
@cli.command(hidden=True)
|
||||
@DB_OPTION
|
||||
@DB_ARGUMENT
|
||||
@@ -3254,7 +3461,7 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
|
||||
""" Print out debug info """
|
||||
"""Print out debug info"""
|
||||
|
||||
global VERBOSE
|
||||
VERBOSE = bool(verbose)
|
||||
@@ -3325,7 +3532,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def keywords(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out keywords found in the Photos library. """
|
||||
"""Print out keywords found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
@@ -3351,7 +3558,7 @@ def keywords(ctx, cli_obj, db, json_, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def albums(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out albums found in the Photos library. """
|
||||
"""Print out albums found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
@@ -3380,7 +3587,7 @@ def albums(ctx, cli_obj, db, json_, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def persons(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out persons (faces) found in the Photos library. """
|
||||
"""Print out persons (faces) found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
@@ -3406,7 +3613,7 @@ def persons(ctx, cli_obj, db, json_, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def labels(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out image classification labels found in the Photos library. """
|
||||
"""Print out image classification labels found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
@@ -3432,7 +3639,7 @@ def labels(ctx, cli_obj, db, json_, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def info(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out descriptive info of the Photos library database. """
|
||||
"""Print out descriptive info of the Photos library database."""
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
if db is None:
|
||||
@@ -3492,7 +3699,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def places(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out places found in the Photos library. """
|
||||
"""Print out places found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
@@ -3543,7 +3750,7 @@ def places(ctx, cli_obj, db, json_, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
|
||||
""" Print list of all photos & associated info from the Photos library. """
|
||||
"""Print list of all photos & associated info from the Photos library."""
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
if db is None:
|
||||
@@ -3574,7 +3781,7 @@ def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def list_libraries(ctx, cli_obj, json_):
|
||||
""" Print list of Photos libraries found on the system. """
|
||||
"""Print list of Photos libraries found on the system."""
|
||||
|
||||
# implemented in _list_libraries so it can be called by other CLI functions
|
||||
# without errors due to passing ctx and cli_obj
|
||||
@@ -3621,7 +3828,7 @@ def _list_libraries(json_=False, error=True):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def about(ctx, cli_obj):
|
||||
""" Print information about osxphotos including license. """
|
||||
"""Print information about osxphotos including license."""
|
||||
license = """
|
||||
MIT License
|
||||
|
||||
@@ -3649,3 +3856,87 @@ 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):
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
|
||||
return photos
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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"- 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
|
||||
@@ -12,16 +13,19 @@ from ._constants import (
|
||||
EXTENDED_ATTRIBUTE_NAMES,
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
POST_COMMAND_CATEGORIES,
|
||||
)
|
||||
from .phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
TEMPLATE_SUBSTITUTIONS_PATHLIB,
|
||||
get_template_help,
|
||||
)
|
||||
|
||||
|
||||
# TODO: The following help text could probably be done as mako template
|
||||
class ExportCommand(click.Command):
|
||||
""" Custom click.Command that overrides get_help() to show additional help info for export """
|
||||
"""Custom click.Command that overrides get_help() to show additional help info for export"""
|
||||
|
||||
def get_help(self, ctx):
|
||||
help_text = super().get_help(ctx)
|
||||
@@ -65,7 +69,9 @@ class ExportCommand(click.Command):
|
||||
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write(rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width))
|
||||
formatter.write(
|
||||
rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"""
|
||||
@@ -99,7 +105,9 @@ The following attributes may be used with '--xattr-template':
|
||||
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write(rich_text("[bold]** Templating System **[/bold]", width=formatter.width))
|
||||
formatter.write(
|
||||
rich_text("[bold]** Templating System **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(template_help(width=formatter.width))
|
||||
formatter.write("\n")
|
||||
@@ -128,7 +136,11 @@ The following attributes may be used with '--xattr-template':
|
||||
+ "an error and the script will abort."
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(rich_text("[bold]** Template Substitutions **[/bold]", width=formatter.width))
|
||||
formatter.write(
|
||||
rich_text(
|
||||
"[bold]** Template Substitutions **[/bold]", width=formatter.width
|
||||
)
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
|
||||
@@ -151,21 +163,127 @@ The following attributes may be used with '--xattr-template':
|
||||
)
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"The following substitutions are file or directory paths. "
|
||||
+ "You can access various parts of the path using the following modifiers:"
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write("{path.parent}: the parent directory\n")
|
||||
formatter.write("{path.name}: the name of the file or final sub-directory\n")
|
||||
formatter.write("{path.stem}: the name of the file without the extension\n")
|
||||
formatter.write(
|
||||
"{path.suffix}: the suffix of the file including the leading '.'\n"
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
"For example, if the field {export_dir} is '/Shared/Backup/Photos':\n"
|
||||
)
|
||||
formatter.write("{export_dir.parent} is '/Shared/Backup'\n")
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
"If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG':\n"
|
||||
)
|
||||
formatter.write("{filepath.parent} is '/Shared/Backup/Photos'\n")
|
||||
formatter.write("{filepath.name} is 'IMG_1234.JPG'\n")
|
||||
formatter.write("{filepath.stem} is 'IMG_1234'\n")
|
||||
formatter.write("{filepath.suffix} is '.JPG'\n")
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS_PATHLIB.items())
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
formatter.write("\n\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Command **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write_text(
|
||||
"You can run commands on the exported photos for post-processing "
|
||||
+ "using the '--post-command' option. '--post-command' is passed a CATEGORY and a COMMAND. "
|
||||
+ "COMMAND is an osxphotos template string which will be rendered and passed to the shell "
|
||||
+ "for execution. CATEGORY is the category of file to pass to COMMAND. "
|
||||
+ "The following categories are available: "
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Catgory", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in POST_COMMAND_CATEGORIES.items())
|
||||
formatter.write_dl(templ_tuples)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"In addition to all normal template fields, the template fields "
|
||||
+ "'{filepath}' and '{export_dir}' will be available to your command template. "
|
||||
+ "Both of these are path-type templates which means their various parts can be accessed using "
|
||||
+ "the available properties, e.g. '{filepath.name}' provides just the file name without path "
|
||||
+ "and '{filepath.suffix}' is the file extension (suffix) of the file. "
|
||||
+ "When using paths in your command template, it is important to properly quote the paths "
|
||||
+ "as they will be passed to the shell and path names may contain spaces. "
|
||||
+ "Both the '{shell_quote}' template and the '|shell_quote' template filter are available for "
|
||||
+ "this purpose. For example, the following command outputs the full path of newly exported files to file 'new.txt': "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
'--post-command new "echo {filepath.name|shell_quote} >> {shell_quote,{export_dir}/exported.txt}"'
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write_text(
|
||||
"In the above command, the 'shell_quote' filter is used to ensure '{filepath.name}' is properly quoted "
|
||||
+ "and the '{shell_quote}' template ensures the constructed path of '{exported_dir}/exported.txt' is properly quoted. "
|
||||
"If '{filepath.name}' is 'IMG 1234.jpeg' and '{export_dir}' is '/Volumes/Photo Export', the command "
|
||||
"thus renders to: "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write("echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt'")
|
||||
formatter.write("\n\n")
|
||||
formatter.write_text(
|
||||
"It is highly recommended that you run osxphotos with '--dry-run --verbose' "
|
||||
+ "first 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."
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def template_help(width=78):
|
||||
"""Return formatted string for template system """
|
||||
"""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()
|
||||
@@ -176,16 +294,16 @@ def rich_text(text, width=78):
|
||||
return rich_text
|
||||
|
||||
|
||||
def strip_md_links(md):
|
||||
"""strip markdown links from markdown text md
|
||||
|
||||
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 links removed
|
||||
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
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"(?:[*#])|\[(.*?)\]\(.+?\)"
|
||||
@@ -195,3 +313,36 @@ def strip_md_links(md):
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
def strip_md_links(md):
|
||||
"""strip markdown links from markdown text md
|
||||
|
||||
Args:
|
||||
md: str, markdown text
|
||||
|
||||
Returns:
|
||||
str with markdown 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_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
|
||||
|
||||
@@ -6,22 +6,22 @@ from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
|
||||
|
||||
|
||||
def sanitize_filepath(filepath):
|
||||
""" sanitize a filepath """
|
||||
"""sanitize a filepath"""
|
||||
return pathvalidate.sanitize_filepath(filepath, platform="macos")
|
||||
|
||||
|
||||
def is_valid_filepath(filepath):
|
||||
""" returns True if a filepath is valid otherwise False """
|
||||
"""returns True if a filepath is valid otherwise False"""
|
||||
return pathvalidate.is_valid_filepath(filepath, platform="macos")
|
||||
|
||||
|
||||
def sanitize_filename(filename, replacement=":"):
|
||||
""" replace any illegal characters in a filename and truncate filename if needed
|
||||
"""replace any illegal characters in a filename and truncate filename if needed
|
||||
|
||||
Args:
|
||||
filename: str, filename to sanitze
|
||||
replacement: str, value to replace any illegal characters with; default = ":"
|
||||
|
||||
|
||||
Returns:
|
||||
filename with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
@@ -46,12 +46,12 @@ def sanitize_filename(filename, replacement=":"):
|
||||
|
||||
|
||||
def sanitize_dirname(dirname, replacement=":"):
|
||||
""" replace any illegal characters in a directory name and truncate directory name if needed
|
||||
"""replace any illegal characters in a directory name and truncate directory name if needed
|
||||
|
||||
Args:
|
||||
dirname: str, directory name to sanitze
|
||||
replacement: str, value to replace any illegal characters with; default = ":"
|
||||
|
||||
dirname: str, directory name to sanitize
|
||||
replacement: str, value to replace any illegal characters with; default = ":"; if None, no replacement occurs
|
||||
|
||||
Returns:
|
||||
dirname with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
@@ -61,19 +61,20 @@ def sanitize_dirname(dirname, replacement=":"):
|
||||
|
||||
|
||||
def sanitize_pathpart(pathpart, replacement=":"):
|
||||
""" replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
|
||||
"""replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
|
||||
|
||||
Args:
|
||||
pathpart: str, path part to sanitze
|
||||
replacement: str, value to replace any illegal characters with; default = ":"
|
||||
|
||||
pathpart: str, path part to sanitize
|
||||
replacement: str, value to replace any illegal characters with; default = ":"; if None, no replacement occurs
|
||||
|
||||
Returns:
|
||||
pathpart with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
if pathpart:
|
||||
pathpart = pathpart.replace("/", replacement)
|
||||
pathpart = (
|
||||
pathpart.replace("/", replacement) if replacement is not None else pathpart
|
||||
)
|
||||
if len(pathpart) > MAX_DIRNAME_LEN:
|
||||
drop = len(pathpart) - MAX_DIRNAME_LEN
|
||||
pathpart = pathpart[:-drop]
|
||||
return pathpart
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
from ._photoinfo_exifinfo import ExifInfo
|
||||
from ._photoinfo_export import ExportResults
|
||||
from ._photoinfo_scoreinfo import ScoreInfo
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photoinfo import PhotoInfo, PhotoInfoNone
|
||||
@@ -15,6 +15,7 @@
|
||||
# TODO: should this be its own PhotoExporter class?
|
||||
# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
|
||||
|
||||
import dataclasses
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
@@ -24,6 +25,7 @@ import pathlib
|
||||
import re
|
||||
import tempfile
|
||||
from collections import namedtuple # pylint: disable=syntax-error
|
||||
from typing import Optional
|
||||
|
||||
import photoscript
|
||||
from mako.template import Template
|
||||
@@ -36,10 +38,10 @@ from .._constants import (
|
||||
_UNKNOWN_PERSON,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
_XMP_TEMPLATE_NAME_BETA,
|
||||
LIVE_VIDEO_EXTENSIONS,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
SIDECAR_XMP,
|
||||
LIVE_VIDEO_EXTENSIONS,
|
||||
)
|
||||
from .._version import __version__
|
||||
from ..datetime_utils import datetime_tz_to_utc
|
||||
@@ -52,7 +54,9 @@ from ..photokit import (
|
||||
PhotoKitFetchFailed,
|
||||
PhotoLibrary,
|
||||
)
|
||||
from ..utils import findfiles, get_preferred_uti_extension, lineno, noop
|
||||
from ..phototemplate import RenderOptions
|
||||
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
|
||||
@@ -218,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
|
||||
@@ -297,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
|
||||
@@ -390,6 +397,7 @@ def export(
|
||||
use_persons_as_keywords=False,
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
render_options: Optional[RenderOptions] = None,
|
||||
):
|
||||
"""export photo
|
||||
dest: must be valid destination path (or exception raised)
|
||||
@@ -427,6 +435,7 @@ def export(
|
||||
when exporting metadata with exiftool or sidecar
|
||||
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
|
||||
description_template: string; optional template string that will be rendered for use as photo description
|
||||
render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer
|
||||
|
||||
Returns: list of photos exported
|
||||
"""
|
||||
@@ -458,6 +467,7 @@ def export(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
render_options = render_options,
|
||||
)
|
||||
|
||||
return results.exported
|
||||
@@ -500,6 +510,7 @@ def export2(
|
||||
persons=True,
|
||||
location=True,
|
||||
replace_keywords=False,
|
||||
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
|
||||
@@ -555,6 +566,7 @@ 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
|
||||
render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
|
||||
|
||||
Returns: ExportResults class
|
||||
ExportResults has attributes:
|
||||
@@ -596,6 +608,8 @@ def export2(
|
||||
if verbose is None:
|
||||
verbose = self._verbose
|
||||
|
||||
self._render_options = render_options or RenderOptions()
|
||||
|
||||
# suffix to add to edited files
|
||||
# e.g. name will be filename_edited.jpg
|
||||
edited_identifier = "_edited"
|
||||
@@ -679,6 +693,7 @@ def export2(
|
||||
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
self._render_options.filepath = str(dest)
|
||||
all_results = ExportResults()
|
||||
if not use_photos_export:
|
||||
# find the source file on disk and export
|
||||
@@ -1161,7 +1176,10 @@ def _export_photo_with_photos_export(
|
||||
else:
|
||||
try:
|
||||
exported = photo.export(
|
||||
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT, overwrite=overwrite
|
||||
dest.parent,
|
||||
dest.name,
|
||||
version=PHOTOS_VERSION_CURRENT,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except Exception as e:
|
||||
@@ -1180,6 +1198,7 @@ def _export_photo_with_photos_export(
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
@@ -1205,7 +1224,10 @@ def _export_photo_with_photos_export(
|
||||
if not dry_run:
|
||||
try:
|
||||
exported = photo.export(
|
||||
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL, overwrite=overwrite
|
||||
dest.parent,
|
||||
dest.name,
|
||||
version=PHOTOS_VERSION_ORIGINAL,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except Exception as e:
|
||||
@@ -1227,6 +1249,7 @@ def _export_photo_with_photos_export(
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
@@ -1586,9 +1609,8 @@ def _exiftool_dict(
|
||||
)
|
||||
|
||||
if description_template is not None:
|
||||
rendered = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
)[0]
|
||||
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
|
||||
exif["XMP:Description"] = description
|
||||
@@ -1626,10 +1648,9 @@ def _exiftool_dict(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
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, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
logging.warning(
|
||||
f"Unmatched template substitution for template: {template_str} {unmatched}"
|
||||
@@ -1905,9 +1926,8 @@ def _xmp_sidecar(
|
||||
extension = extension.suffix[1:] if extension.suffix else None
|
||||
|
||||
if description_template is not None:
|
||||
rendered = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
)[0]
|
||||
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:
|
||||
description = self.description if self.description is not None else ""
|
||||
@@ -1939,10 +1959,9 @@ def _xmp_sidecar(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
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, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
logging.warning(
|
||||
f"Unmatched template substitution for template: {template_str} {unmatched}"
|
||||
|
||||
@@ -3,7 +3,6 @@ PhotoInfo class
|
||||
Represents a single photo in the Photos library and provides access to the photo's attributes
|
||||
PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
@@ -12,6 +11,7 @@ import os
|
||||
import os.path
|
||||
import pathlib
|
||||
from datetime import timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -34,9 +34,10 @@ from .._constants import (
|
||||
from ..adjustmentsinfo import AdjustmentsInfo
|
||||
from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate
|
||||
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:
|
||||
@@ -46,31 +47,31 @@ class PhotoInfo:
|
||||
"""
|
||||
|
||||
# import additional methods
|
||||
from ._photoinfo_searchinfo import (
|
||||
search_info,
|
||||
search_info_normalized,
|
||||
labels,
|
||||
labels_normalized,
|
||||
SearchInfo,
|
||||
)
|
||||
from ._photoinfo_exifinfo import exif_info, ExifInfo
|
||||
from ._photoinfo_comments import comments, likes
|
||||
from ._photoinfo_exifinfo import ExifInfo, exif_info
|
||||
from ._photoinfo_exiftool import exiftool
|
||||
from ._photoinfo_export import (
|
||||
export,
|
||||
export2,
|
||||
_export_photo,
|
||||
ExportResults,
|
||||
_exiftool_dict,
|
||||
_exiftool_json_sidecar,
|
||||
_export_photo,
|
||||
_export_photo_with_photos_export,
|
||||
_get_exif_keywords,
|
||||
_get_exif_persons,
|
||||
_write_exif_data,
|
||||
_write_sidecar,
|
||||
_xmp_sidecar,
|
||||
ExportResults,
|
||||
export,
|
||||
export2,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import ScoreInfo, score
|
||||
from ._photoinfo_searchinfo import (
|
||||
SearchInfo,
|
||||
labels,
|
||||
labels_normalized,
|
||||
search_info,
|
||||
search_info_normalized,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import score, ScoreInfo
|
||||
from ._photoinfo_comments import comments, likes
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
@@ -78,9 +79,12 @@ class PhotoInfo:
|
||||
self._db = db
|
||||
self._verbose = self._db._verbose
|
||||
|
||||
# TODO: remove this once refactor of PhotoExporter is done
|
||||
self._render_options = RenderOptions()
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
"""filename of the picture"""
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
@@ -108,7 +112,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
""" image creation date as timezone aware datetime object """
|
||||
"""image creation date as timezone aware datetime object"""
|
||||
return self._info["imageDate"]
|
||||
|
||||
@property
|
||||
@@ -134,12 +138,12 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def tzoffset(self):
|
||||
""" timezone offset from UTC in seconds """
|
||||
"""timezone offset from UTC in seconds"""
|
||||
return self._info["imageTimeZoneOffsetSeconds"]
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
""" absolute path on disk of the original picture """
|
||||
"""absolute path on disk of the original picture"""
|
||||
try:
|
||||
return self._path
|
||||
except AttributeError:
|
||||
@@ -211,7 +215,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def path_edited(self):
|
||||
""" absolute path on disk of the edited picture """
|
||||
"""absolute path on disk of the edited picture"""
|
||||
""" None if photo has not been edited """
|
||||
|
||||
try:
|
||||
@@ -225,7 +229,7 @@ class PhotoInfo:
|
||||
return self._path_edited
|
||||
|
||||
def _path_edited_5(self):
|
||||
""" return path_edited for Photos >= 5 """
|
||||
"""return path_edited for Photos >= 5"""
|
||||
# In Photos 5.0 / Catalina / MacOS 10.15:
|
||||
# edited photos appear to always be converted to .jpeg and stored in
|
||||
# library_name/resources/renders/X/UUID_1_201_a.jpeg
|
||||
@@ -283,7 +287,7 @@ class PhotoInfo:
|
||||
return photopath
|
||||
|
||||
def _path_edited_4(self):
|
||||
""" return path_edited for Photos <= 4 """
|
||||
"""return path_edited for Photos <= 4"""
|
||||
|
||||
if self._db._db_version > _PHOTOS_4_VERSION:
|
||||
raise RuntimeError("Wrong database format!")
|
||||
@@ -343,7 +347,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def path_raw(self):
|
||||
""" absolute path of associated RAW image or None if there is not one """
|
||||
"""absolute path of associated RAW image or None if there is not one"""
|
||||
|
||||
# In Photos 5, raw is in same folder as original but with _4.ext
|
||||
# Unless "Copy Items to the Photos Library" is not checked
|
||||
@@ -386,44 +390,40 @@ class PhotoInfo:
|
||||
photopath = None
|
||||
else:
|
||||
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
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
""" long / extended description of picture """
|
||||
"""long / extended description of picture"""
|
||||
return self._info["extendedDescription"]
|
||||
|
||||
@property
|
||||
def persons(self):
|
||||
""" list of persons in picture """
|
||||
"""list of persons in picture"""
|
||||
return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]
|
||||
|
||||
@property
|
||||
def person_info(self):
|
||||
""" list of PersonInfo objects for person in picture """
|
||||
"""list of PersonInfo objects for person in picture"""
|
||||
try:
|
||||
return self._personinfo
|
||||
except AttributeError:
|
||||
@@ -434,7 +434,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def face_info(self):
|
||||
""" list of FaceInfo objects for faces in picture """
|
||||
"""list of FaceInfo objects for faces in picture"""
|
||||
try:
|
||||
return self._faceinfo
|
||||
except AttributeError:
|
||||
@@ -448,7 +448,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def albums(self):
|
||||
""" list of albums picture is contained in """
|
||||
"""list of albums picture is contained in"""
|
||||
try:
|
||||
return self._albums
|
||||
except AttributeError:
|
||||
@@ -460,7 +460,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def burst_albums(self):
|
||||
"""If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums """
|
||||
"""If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums"""
|
||||
try:
|
||||
return self._burst_albums
|
||||
except AttributeError:
|
||||
@@ -473,7 +473,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def album_info(self):
|
||||
""" list of AlbumInfo objects representing albums the photo is contained in """
|
||||
"""list of AlbumInfo objects representing albums the photo is contained in"""
|
||||
try:
|
||||
return self._album_info
|
||||
except AttributeError:
|
||||
@@ -485,7 +485,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def burst_album_info(self):
|
||||
""" If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info. """
|
||||
"""If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info."""
|
||||
try:
|
||||
return self._burst_album_info
|
||||
except AttributeError:
|
||||
@@ -498,7 +498,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def import_info(self):
|
||||
""" ImportInfo object representing import session for the photo or None if no import session """
|
||||
"""ImportInfo object representing import session for the photo or None if no import session"""
|
||||
try:
|
||||
return self._import_info
|
||||
except AttributeError:
|
||||
@@ -511,17 +511,17 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def keywords(self):
|
||||
""" list of keywords for picture """
|
||||
"""list of keywords for picture"""
|
||||
return self._info["keywords"]
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" name / title of picture """
|
||||
"""name / title of picture"""
|
||||
return self._info["name"]
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" UUID of picture """
|
||||
"""UUID of picture"""
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
@@ -539,12 +539,12 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def hasadjustments(self):
|
||||
""" True if picture has adjustments / edits """
|
||||
"""True if picture has adjustments / edits"""
|
||||
return self._info["hasAdjustments"] == 1
|
||||
|
||||
@property
|
||||
def adjustments(self):
|
||||
""" Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only """
|
||||
"""Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
@@ -568,32 +568,32 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def external_edit(self):
|
||||
""" Returns True if picture was edited outside of Photos using external editor """
|
||||
"""Returns True if picture was edited outside of Photos using external editor"""
|
||||
return self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
|
||||
|
||||
@property
|
||||
def favorite(self):
|
||||
""" True if picture is marked as favorite """
|
||||
"""True if picture is marked as favorite"""
|
||||
return self._info["favorite"] == 1
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
""" True if picture is hidden """
|
||||
"""True if picture is hidden"""
|
||||
return self._info["hidden"] == 1
|
||||
|
||||
@property
|
||||
def visible(self):
|
||||
""" True if picture is visble """
|
||||
"""True if picture is visble"""
|
||||
return self._info["visible"]
|
||||
|
||||
@property
|
||||
def intrash(self):
|
||||
""" True if picture is in trash ('Recently Deleted' folder)"""
|
||||
"""True if picture is in trash ('Recently Deleted' folder)"""
|
||||
return self._info["intrash"]
|
||||
|
||||
@property
|
||||
def date_trashed(self):
|
||||
""" Date asset was placed in the trash or None """
|
||||
"""Date asset was placed in the trash or None"""
|
||||
# TODO: add add_timezone(dt, offset_seconds) to datetime_utils
|
||||
# also update date_modified
|
||||
trasheddate = self._info["trasheddate"]
|
||||
@@ -607,7 +607,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def date_added(self):
|
||||
""" Date photo was added to the database """
|
||||
"""Date photo was added to the database"""
|
||||
try:
|
||||
return self._date_added
|
||||
except AttributeError:
|
||||
@@ -624,7 +624,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
""" returns (latitude, longitude) as float in degrees or None """
|
||||
"""returns (latitude, longitude) as float in degrees or None"""
|
||||
return (self._latitude, self._longitude)
|
||||
|
||||
@property
|
||||
@@ -658,13 +658,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):
|
||||
@@ -683,7 +693,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):
|
||||
@@ -720,27 +737,27 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def isreference(self):
|
||||
""" Returns True if photo is a reference (not copied to the Photos library), otherwise False """
|
||||
"""Returns True if photo is a reference (not copied to the Photos library), otherwise False"""
|
||||
return self._info["isreference"]
|
||||
|
||||
@property
|
||||
def burst(self):
|
||||
""" Returns True if photo is part of a Burst photo set, otherwise False """
|
||||
"""Returns True if photo is part of a Burst photo set, otherwise False"""
|
||||
return self._info["burst"]
|
||||
|
||||
@property
|
||||
def burst_selected(self):
|
||||
""" Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False """
|
||||
"""Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False"""
|
||||
return bool(self._info["burstPickType"] & BURST_SELECTED)
|
||||
|
||||
@property
|
||||
def burst_key(self):
|
||||
""" Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False """
|
||||
"""Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False"""
|
||||
return bool(self._info["burstPickType"] & BURST_KEY)
|
||||
|
||||
@property
|
||||
def burst_default_pick(self):
|
||||
""" Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False """
|
||||
"""Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False"""
|
||||
return bool(self._info["burstPickType"] & BURST_DEFAULT_PICK)
|
||||
|
||||
@property
|
||||
@@ -760,7 +777,7 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def live_photo(self):
|
||||
""" Returns True if photo is a live photo, otherwise False """
|
||||
"""Returns True if photo is a live photo, otherwise False"""
|
||||
return self._info["live_photo"]
|
||||
|
||||
@property
|
||||
@@ -821,7 +838,7 @@ 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) """
|
||||
"""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()
|
||||
|
||||
@@ -838,7 +855,7 @@ class PhotoInfo:
|
||||
return [str(filename) for filename in files if filename.suffix != ".THM"]
|
||||
|
||||
def _path_derivatives_4(self):
|
||||
""" Return paths to all derivative (preview) files for Photos <= 4"""
|
||||
"""Return paths to all derivative (preview) files for Photos <= 4"""
|
||||
modelid = self._info["modelID"]
|
||||
if modelid is None:
|
||||
return []
|
||||
@@ -875,42 +892,42 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def panorama(self):
|
||||
""" Returns True if photo is a panorama, otherwise False """
|
||||
"""Returns True if photo is a panorama, otherwise False"""
|
||||
return self._info["panorama"]
|
||||
|
||||
@property
|
||||
def slow_mo(self):
|
||||
""" Returns True if photo is a slow motion video, otherwise False """
|
||||
"""Returns True if photo is a slow motion video, otherwise False"""
|
||||
return self._info["slow_mo"]
|
||||
|
||||
@property
|
||||
def time_lapse(self):
|
||||
""" Returns True if photo is a time lapse video, otherwise False """
|
||||
"""Returns True if photo is a time lapse video, otherwise False"""
|
||||
return self._info["time_lapse"]
|
||||
|
||||
@property
|
||||
def hdr(self):
|
||||
""" Returns True if photo is an HDR photo, otherwise False """
|
||||
"""Returns True if photo is an HDR photo, otherwise False"""
|
||||
return self._info["hdr"]
|
||||
|
||||
@property
|
||||
def screenshot(self):
|
||||
""" Returns True if photo is an HDR photo, otherwise False """
|
||||
"""Returns True if photo is an HDR photo, otherwise False"""
|
||||
return self._info["screenshot"]
|
||||
|
||||
@property
|
||||
def portrait(self):
|
||||
""" Returns True if photo is a portrait, otherwise False """
|
||||
"""Returns True if photo is a portrait, otherwise False"""
|
||||
return self._info["portrait"]
|
||||
|
||||
@property
|
||||
def selfie(self):
|
||||
""" Returns True if photo is a selfie (front facing camera), otherwise False """
|
||||
"""Returns True if photo is a selfie (front facing camera), otherwise False"""
|
||||
return self._info["selfie"]
|
||||
|
||||
@property
|
||||
def place(self):
|
||||
""" Returns PlaceInfo object containing reverse geolocation info """
|
||||
"""Returns PlaceInfo object containing reverse geolocation info"""
|
||||
|
||||
# implementation note: doesn't create the PlaceInfo object until requested
|
||||
# then memoizes the object in self._place to avoid recreating the object
|
||||
@@ -938,12 +955,12 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def has_raw(self):
|
||||
""" returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False """
|
||||
"""returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False"""
|
||||
return self._info["has_raw"]
|
||||
|
||||
@property
|
||||
def israw(self):
|
||||
""" returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw """
|
||||
"""returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw"""
|
||||
return "raw-image" in self.uti_original
|
||||
|
||||
@property
|
||||
@@ -955,17 +972,17 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
""" returns height of the current photo version in pixels """
|
||||
"""returns height of the current photo version in pixels"""
|
||||
return self._info["height"]
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
""" returns width of the current photo version in pixels """
|
||||
"""returns width of the current photo version in pixels"""
|
||||
return self._info["width"]
|
||||
|
||||
@property
|
||||
def orientation(self):
|
||||
""" returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined """
|
||||
"""returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return self._info["orientation"]
|
||||
|
||||
@@ -981,27 +998,27 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def original_height(self):
|
||||
""" returns height of the original photo version in pixels """
|
||||
"""returns height of the original photo version in pixels"""
|
||||
return self._info["original_height"]
|
||||
|
||||
@property
|
||||
def original_width(self):
|
||||
""" returns width of the original photo version in pixels """
|
||||
"""returns width of the original photo version in pixels"""
|
||||
return self._info["original_width"]
|
||||
|
||||
@property
|
||||
def original_orientation(self):
|
||||
""" returns EXIF orientation of the original photo version as int """
|
||||
"""returns EXIF orientation of the original photo version as int"""
|
||||
return self._info["original_orientation"]
|
||||
|
||||
@property
|
||||
def original_filesize(self):
|
||||
""" returns filesize of original photo in bytes as int """
|
||||
"""returns filesize of original photo in bytes as int"""
|
||||
return self._info["original_filesize"]
|
||||
|
||||
@property
|
||||
def duplicates(self):
|
||||
""" return list of PhotoInfo objects for possible duplicates (matching signature of original size, date, height, width) or empty list if no matching duplicates """
|
||||
"""return list of PhotoInfo objects for possible duplicates (matching signature of original size, date, height, width) or empty list if no matching duplicates"""
|
||||
signature = self._db._duplicate_signature(self.uuid)
|
||||
duplicates = []
|
||||
try:
|
||||
@@ -1015,57 +1032,29 @@ class PhotoInfo:
|
||||
return duplicates
|
||||
|
||||
def render_template(
|
||||
self,
|
||||
template_str,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
edited=False,
|
||||
self, template_str: str, options: Optional[RenderOptions] = None
|
||||
):
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
none_str: a str to use if template field renders to None, default is "_".
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
fields like folder_album; if not provided, defaults to os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
strip: if True, strips leading/trailing white space from resulting template
|
||||
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
|
||||
options: a RenderOptions instance
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
options = options or RenderOptions()
|
||||
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
|
||||
return template.render(
|
||||
template_str,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
strip=strip,
|
||||
edited_version=edited,
|
||||
)
|
||||
return template.render(template_str, options)
|
||||
|
||||
@property
|
||||
def _longitude(self):
|
||||
""" Returns longitude, in degrees """
|
||||
"""Returns longitude, in degrees"""
|
||||
return self._info["longitude"]
|
||||
|
||||
@property
|
||||
def _latitude(self):
|
||||
""" Returns latitude, in degrees """
|
||||
"""Returns latitude, in degrees"""
|
||||
return self._info["latitude"]
|
||||
|
||||
def _get_album_uuids(self):
|
||||
@@ -1103,7 +1092,7 @@ class PhotoInfo:
|
||||
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
|
||||
|
||||
def __str__(self):
|
||||
""" string representation of PhotoInfo object """
|
||||
"""string representation of PhotoInfo object"""
|
||||
|
||||
date_iso = self.date.isoformat()
|
||||
date_modified_iso = (
|
||||
@@ -1166,7 +1155,7 @@ class PhotoInfo:
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
def asdict(self):
|
||||
""" return dict representation """
|
||||
"""return dict representation"""
|
||||
|
||||
folders = {album.title: album.folder_names for album in self.album_info}
|
||||
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
|
||||
@@ -1242,7 +1231,7 @@ class PhotoInfo:
|
||||
}
|
||||
|
||||
def json(self):
|
||||
""" Return JSON representation """
|
||||
"""Return JSON representation"""
|
||||
|
||||
def default(o):
|
||||
if isinstance(o, (datetime.date, datetime.datetime)):
|
||||
@@ -1251,7 +1240,7 @@ class PhotoInfo:
|
||||
return json.dumps(self.asdict(), sort_keys=True, default=default)
|
||||
|
||||
def __eq__(self, other):
|
||||
""" Compare two PhotoInfo objects for equality """
|
||||
"""Compare two PhotoInfo objects for equality"""
|
||||
# Can't just compare the two __dicts__ because some methods (like albums)
|
||||
# memoize their value once called in an instance variable (e.g. self._albums)
|
||||
if isinstance(other, self.__class__):
|
||||
@@ -1263,5 +1252,19 @@ class PhotoInfo:
|
||||
return False
|
||||
|
||||
def __ne__(self, other):
|
||||
""" Compare two PhotoInfo objects for inequality """
|
||||
"""Compare two PhotoInfo objects for inequality"""
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __hash__(self):
|
||||
"""Make PhotoInfo hashable"""
|
||||
return hash(self.uuid)
|
||||
|
||||
|
||||
class PhotoInfoNone:
|
||||
"""mock class that returns None for all attributes"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def __getattribute__(self, name):
|
||||
return None
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
""" PhotosAlbum class to create an album in default Photos library and add photos to it """
|
||||
|
||||
from typing import Optional, List
|
||||
from typing import List, Optional
|
||||
|
||||
import photoscript
|
||||
from more_itertools import chunked
|
||||
|
||||
from .photoinfo import PhotoInfo
|
||||
from .utils import noop
|
||||
|
||||
@@ -26,8 +29,14 @@ class PhotosAlbum:
|
||||
)
|
||||
|
||||
def add_list(self, photo_list: List[PhotoInfo]):
|
||||
photos = [photoscript.Photo(p.uuid) for p in photo_list]
|
||||
self.album.add(photos)
|
||||
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)
|
||||
photo_word = "photos" if photo_len > 1 else "photo"
|
||||
self.verbose(f"Added {photo_len} {photo_word} to album {self.name}")
|
||||
|
||||
@@ -11,6 +11,7 @@ import platform
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pprint import pformat
|
||||
from typing import List
|
||||
@@ -43,6 +44,7 @@ from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
|
||||
from ..fileutil import FileUtil
|
||||
from ..personinfo import PersonInfo
|
||||
from ..photoinfo import PhotoInfo
|
||||
from ..phototemplate import RenderOptions
|
||||
from ..queryoptions import QueryOptions
|
||||
from ..utils import (
|
||||
_check_file_exists,
|
||||
@@ -62,29 +64,29 @@ from .photosdb_utils import get_db_model_version, get_db_version
|
||||
|
||||
|
||||
class PhotosDB:
|
||||
""" Processes a Photos.app library database to extract information about photos """
|
||||
"""Processes a Photos.app library database to extract information about photos"""
|
||||
|
||||
# import additional methods
|
||||
from ._photosdb_process_comments import _process_comments
|
||||
from ._photosdb_process_exif import _process_exifinfo
|
||||
from ._photosdb_process_faceinfo import _process_faceinfo
|
||||
from ._photosdb_process_scoreinfo import _process_scoreinfo
|
||||
from ._photosdb_process_searchinfo import (
|
||||
_process_searchinfo,
|
||||
labels,
|
||||
labels_normalized,
|
||||
labels_as_dict,
|
||||
labels_normalized,
|
||||
labels_normalized_as_dict,
|
||||
)
|
||||
from ._photosdb_process_scoreinfo import _process_scoreinfo
|
||||
from ._photosdb_process_comments import _process_comments
|
||||
|
||||
def __init__(self, dbfile=None, verbose=None, exiftool=None):
|
||||
""" Create a new PhotosDB object.
|
||||
"""Create a new PhotosDB object.
|
||||
|
||||
Args:
|
||||
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
|
||||
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
|
||||
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if dbfile is not a valid Photos library.
|
||||
TypeError if verbose is not None and not callable.
|
||||
@@ -326,7 +328,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def keywords_as_dict(self):
|
||||
""" return keywords as dict of keyword, count in reverse sorted order (descending) """
|
||||
"""return keywords as dict of keyword, count in reverse sorted order (descending)"""
|
||||
keywords = {
|
||||
k: len(self._dbkeywords_keyword[k]) for k in self._dbkeywords_keyword.keys()
|
||||
}
|
||||
@@ -336,7 +338,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def persons_as_dict(self):
|
||||
""" return persons as dict of person, count in reverse sorted order (descending) """
|
||||
"""return persons as dict of person, count in reverse sorted order (descending)"""
|
||||
persons = {}
|
||||
for pk in self._dbfaces_pk:
|
||||
fullname = self._dbpersons_pk[pk]["fullname"]
|
||||
@@ -349,7 +351,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def albums_as_dict(self):
|
||||
""" return albums as dict of albums, count in reverse sorted order (descending) """
|
||||
"""return albums as dict of albums, count in reverse sorted order (descending)"""
|
||||
albums = {}
|
||||
album_keys = self._get_album_uuids(shared=False)
|
||||
for album in album_keys:
|
||||
@@ -366,8 +368,8 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def albums_shared_as_dict(self):
|
||||
""" returns shared albums as dict of albums, count in reverse sorted order (descending)
|
||||
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict """
|
||||
"""returns shared albums as dict of albums, count in reverse sorted order (descending)
|
||||
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict"""
|
||||
|
||||
albums = {}
|
||||
album_keys = self._get_album_uuids(shared=True)
|
||||
@@ -385,19 +387,19 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def keywords(self):
|
||||
""" return list of keywords found in photos database """
|
||||
"""return list of keywords found in photos database"""
|
||||
keywords = self._dbkeywords_keyword.keys()
|
||||
return list(keywords)
|
||||
|
||||
@property
|
||||
def persons(self):
|
||||
""" return list of persons found in photos database """
|
||||
"""return list of persons found in photos database"""
|
||||
persons = {self._dbpersons_pk[k]["fullname"] for k in self._dbfaces_pk}
|
||||
return list(persons)
|
||||
|
||||
@property
|
||||
def person_info(self):
|
||||
""" return list of PersonInfo objects for each person in the photos database """
|
||||
"""return list of PersonInfo objects for each person in the photos database"""
|
||||
try:
|
||||
return self._person_info
|
||||
except AttributeError:
|
||||
@@ -408,7 +410,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def folder_info(self):
|
||||
""" return list FolderInfo objects representing top-level folders in the photos database """
|
||||
"""return list FolderInfo objects representing top-level folders in the photos database"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
folders = [
|
||||
FolderInfo(db=self, uuid=folder)
|
||||
@@ -429,7 +431,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def folders(self):
|
||||
""" return list of top-level folder names in the photos database """
|
||||
"""return list of top-level folder names in the photos database"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
folder_names = [
|
||||
folder["name"]
|
||||
@@ -450,7 +452,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def album_info(self):
|
||||
""" return list of AlbumInfo objects for each album in the photos database """
|
||||
"""return list of AlbumInfo objects for each album in the photos database"""
|
||||
try:
|
||||
return self._album_info
|
||||
except AttributeError:
|
||||
@@ -462,8 +464,8 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def album_info_shared(self):
|
||||
""" return list of AlbumInfo objects for each shared album in the photos database
|
||||
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
|
||||
"""return list of AlbumInfo objects for each shared album in the photos database
|
||||
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list"""
|
||||
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
|
||||
try:
|
||||
return self._album_info_shared
|
||||
@@ -476,7 +478,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def albums(self):
|
||||
""" return list of albums found in photos database """
|
||||
"""return list of albums found in photos database"""
|
||||
|
||||
# Could be more than one album with same name
|
||||
# Right now, they are treated as same album and photos are combined from albums with same name
|
||||
@@ -489,8 +491,8 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def albums_shared(self):
|
||||
""" return list of shared albums found in photos database
|
||||
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
|
||||
"""return list of shared albums found in photos database
|
||||
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list"""
|
||||
|
||||
# Could be more than one album with same name
|
||||
# Right now, they are treated as same album and photos are combined from albums with same name
|
||||
@@ -505,7 +507,7 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def import_info(self):
|
||||
""" return list of ImportInfo objects for each import session in the database """
|
||||
"""return list of ImportInfo objects for each import session in the database"""
|
||||
try:
|
||||
return self._import_info
|
||||
except AttributeError:
|
||||
@@ -517,21 +519,21 @@ class PhotosDB:
|
||||
|
||||
@property
|
||||
def db_version(self):
|
||||
""" return the database version as stored in LiGlobals table """
|
||||
"""return the database version as stored in LiGlobals table"""
|
||||
return self._db_version
|
||||
|
||||
@property
|
||||
def db_path(self):
|
||||
""" returns path to the Photos library database PhotosDB was initialized with """
|
||||
"""returns path to the Photos library database PhotosDB was initialized with"""
|
||||
return os.path.abspath(self._dbfile)
|
||||
|
||||
@property
|
||||
def library_path(self):
|
||||
""" returns path to the Photos library PhotosDB was initialized with """
|
||||
"""returns path to the Photos library PhotosDB was initialized with"""
|
||||
return self._library_path
|
||||
|
||||
def get_db_connection(self):
|
||||
""" Get connection to the working copy of the Photos database
|
||||
"""Get connection to the working copy of the Photos database
|
||||
|
||||
Returns:
|
||||
tuple of (connection, cursor) to sqlite3 database
|
||||
@@ -539,7 +541,7 @@ class PhotosDB:
|
||||
return _open_sql_file(self._tmp_db)
|
||||
|
||||
def _copy_db_file(self, fname):
|
||||
""" copies the sqlite database file to a temp file """
|
||||
"""copies the sqlite database file to a temp file"""
|
||||
""" returns the name of the temp file """
|
||||
""" If sqlite shared memory and write-ahead log files exist, those are copied too """
|
||||
# required because python's sqlite3 implementation can't read a locked file
|
||||
@@ -591,13 +593,15 @@ class PhotosDB:
|
||||
# return dest_path
|
||||
|
||||
def _process_database4(self):
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version <= 4.0 """
|
||||
"""process the Photos database to extract info
|
||||
works on Photos version <= 4.0"""
|
||||
|
||||
verbose = self._verbose
|
||||
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
|
||||
@@ -1543,15 +1547,15 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbphotos_burst))
|
||||
|
||||
def _build_album_folder_hierarchy_4(self, uuid, folders=None):
|
||||
""" recursively build folder/album hierarchy
|
||||
uuid: parent uuid of the album being processed
|
||||
(parent uuid is a folder in RKFolders)
|
||||
folders: dict holding the folder hierarchy
|
||||
NOTE: This implementation is different than _build_album_folder_hierarchy_5
|
||||
which takes the uuid of the album being processed. Here uuid is the parent uuid
|
||||
of the parent folder album because in Photos <=4, folders are in RKFolders and
|
||||
albums in RKAlbums. In Photos 5, folders are just special albums
|
||||
with kind = _PHOTOS_5_FOLDER_KIND """
|
||||
"""recursively build folder/album hierarchy
|
||||
uuid: parent uuid of the album being processed
|
||||
(parent uuid is a folder in RKFolders)
|
||||
folders: dict holding the folder hierarchy
|
||||
NOTE: This implementation is different than _build_album_folder_hierarchy_5
|
||||
which takes the uuid of the album being processed. Here uuid is the parent uuid
|
||||
of the parent folder album because in Photos <=4, folders are in RKFolders and
|
||||
albums in RKAlbums. In Photos 5, folders are just special albums
|
||||
with kind = _PHOTOS_5_FOLDER_KIND"""
|
||||
|
||||
parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"]
|
||||
|
||||
@@ -1574,11 +1578,11 @@ class PhotosDB:
|
||||
return folders
|
||||
|
||||
def _process_database5(self):
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version 5 and version 6
|
||||
"""process the Photos database to extract info
|
||||
works on Photos version 5 and version 6
|
||||
|
||||
This is a big hairy 700 line function that should probably be refactored
|
||||
but it works so don't touch it.
|
||||
This is a big hairy 700 line function that should probably be refactored
|
||||
but it works so don't touch it.
|
||||
"""
|
||||
|
||||
if _debug():
|
||||
@@ -1593,10 +1597,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():
|
||||
@@ -1716,8 +1724,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}
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1884,7 +1892,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,
|
||||
@@ -2248,20 +2256,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,
|
||||
@@ -2321,8 +2342,20 @@ 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
|
||||
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,
|
||||
@@ -2333,8 +2366,10 @@ class PhotosDB:
|
||||
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:
|
||||
@@ -2448,9 +2483,9 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbphotos_burst))
|
||||
|
||||
def _build_album_folder_hierarchy_5(self, uuid, folders=None):
|
||||
""" recursively build folder/album hierarchy
|
||||
uuid: uuid of the album/folder being processed
|
||||
folders: dict holding the folder hierarchy """
|
||||
"""recursively build folder/album hierarchy
|
||||
uuid: uuid of the album/folder being processed
|
||||
folders: dict holding the folder hierarchy"""
|
||||
|
||||
# get parent uuid
|
||||
parent = self._dbalbum_details[uuid]["parentfolder"]
|
||||
@@ -2471,17 +2506,17 @@ class PhotosDB:
|
||||
return folders
|
||||
|
||||
def _album_folder_hierarchy_list(self, album_uuid):
|
||||
""" return appropriate album_folder_hierarchy_list for the _db_version """
|
||||
"""return appropriate album_folder_hierarchy_list for the _db_version"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
return self._album_folder_hierarchy_list_4(album_uuid)
|
||||
else:
|
||||
return self._album_folder_hierarchy_list_5(album_uuid)
|
||||
|
||||
def _album_folder_hierarchy_list_4(self, album_uuid):
|
||||
""" return hierarchical list of folder names album_uuid is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders """
|
||||
"""return hierarchical list of folder names album_uuid is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders"""
|
||||
try:
|
||||
folders = self._dbalbum_folders[album_uuid]
|
||||
except KeyError:
|
||||
@@ -2489,7 +2524,7 @@ class PhotosDB:
|
||||
return []
|
||||
|
||||
def _recurse_folder_hierarchy(folders, hierarchy=[]):
|
||||
""" recursively walk the folders dict to build list of folder hierarchy """
|
||||
"""recursively walk the folders dict to build list of folder hierarchy"""
|
||||
if not folders:
|
||||
# empty folder dict (album has no folder hierarchy)
|
||||
return []
|
||||
@@ -2515,10 +2550,10 @@ class PhotosDB:
|
||||
return hierarchy
|
||||
|
||||
def _album_folder_hierarchy_list_5(self, album_uuid):
|
||||
""" return hierarchical list of folder names album_uuid is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders """
|
||||
"""return hierarchical list of folder names album_uuid is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders"""
|
||||
try:
|
||||
folders = self._dbalbum_folders[album_uuid]
|
||||
except KeyError:
|
||||
@@ -2526,7 +2561,7 @@ class PhotosDB:
|
||||
return []
|
||||
|
||||
def _recurse_folder_hierarchy(folders, hierarchy=[]):
|
||||
""" recursively walk the folders dict to build list of folder hierarchy """
|
||||
"""recursively walk the folders dict to build list of folder hierarchy"""
|
||||
|
||||
if not folders:
|
||||
# empty folder dict (album has no folder hierarchy)
|
||||
@@ -2558,15 +2593,15 @@ class PhotosDB:
|
||||
return self._album_folder_hierarchy_folderinfo_5(album_uuid)
|
||||
|
||||
def _album_folder_hierarchy_folderinfo_4(self, album_uuid):
|
||||
""" return hierarchical list of FolderInfo objects album_uuid is contained in
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders """
|
||||
"""return hierarchical list of FolderInfo objects album_uuid is contained in
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders"""
|
||||
# title = photosdb._dbalbum_details[album_uuid]["title"]
|
||||
folders = self._dbalbum_folders[album_uuid]
|
||||
# logging.warning(f"uuid = {album_uuid}, folder = {folders}")
|
||||
|
||||
def _recurse_folder_hierarchy(folders, hierarchy=[]):
|
||||
""" recursively walk the folders dict to build list of folder hierarchy """
|
||||
"""recursively walk the folders dict to build list of folder hierarchy"""
|
||||
# logging.warning(f"folders={folders},hierarchy = {hierarchy}")
|
||||
if not folders:
|
||||
# empty folder dict (album has no folder hierarchy)
|
||||
@@ -2592,14 +2627,14 @@ class PhotosDB:
|
||||
return hierarchy
|
||||
|
||||
def _album_folder_hierarchy_folderinfo_5(self, album_uuid):
|
||||
""" return hierarchical list of FolderInfo objects album_uuid is contained in
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders """
|
||||
"""return hierarchical list of FolderInfo objects album_uuid is contained in
|
||||
["Top level folder", "sub folder 1", "sub folder 2"]
|
||||
returns empty list of album is not in any folders"""
|
||||
# title = photosdb._dbalbum_details[album_uuid]["title"]
|
||||
folders = self._dbalbum_folders[album_uuid]
|
||||
|
||||
def _recurse_folder_hierarchy(folders, hierarchy=[]):
|
||||
""" recursively walk the folders dict to build list of folder hierarchy """
|
||||
"""recursively walk the folders dict to build list of folder hierarchy"""
|
||||
|
||||
if not folders:
|
||||
# empty folder dict (album has no folder hierarchy)
|
||||
@@ -2624,19 +2659,19 @@ class PhotosDB:
|
||||
return hierarchy
|
||||
|
||||
def _get_album_uuids(self, shared=False, import_session=False):
|
||||
""" Return list of album UUIDs found in photos database
|
||||
|
||||
"""Return list of album UUIDs found in photos database
|
||||
|
||||
Filters out albums in the trash and any special album types
|
||||
|
||||
|
||||
Args:
|
||||
shared: boolean; if True, returns shared albums, else normal albums
|
||||
import_session: boolean, if True, returns import session albums, else normal or shared albums
|
||||
Note: flags (shared, import_session) are mutually exclusive
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: raised if mutually exclusive flags passed
|
||||
|
||||
Returns: list of album UUIDs
|
||||
Returns: list of album UUIDs
|
||||
"""
|
||||
if shared and import_session:
|
||||
raise ValueError(
|
||||
@@ -2688,14 +2723,14 @@ class PhotosDB:
|
||||
return album_list
|
||||
|
||||
def _get_albums(self, shared=False):
|
||||
""" Return list of album titles found in photos database
|
||||
"""Return list of album titles found in photos database
|
||||
Albums may have duplicate titles -- these will be treated as a single album.
|
||||
|
||||
|
||||
Filters out albums in the trash and any special album types
|
||||
|
||||
Args:
|
||||
shared: boolean; if True, returns shared albums, else normal albums
|
||||
|
||||
|
||||
Returns: list of album names
|
||||
"""
|
||||
|
||||
@@ -2714,7 +2749,7 @@ class PhotosDB:
|
||||
to_date=None,
|
||||
intrash=False,
|
||||
):
|
||||
""" Return a list of PhotoInfo objects
|
||||
"""Return a list of PhotoInfo objects
|
||||
If called with no args, returns the entire database of photos
|
||||
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
|
||||
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
|
||||
@@ -2729,10 +2764,10 @@ class PhotosDB:
|
||||
persons: list of persons to search for
|
||||
albums: list of album names to search for
|
||||
images: if True, returns image files, if False, does not return images; default is True
|
||||
movies: if True, returns movie files, if False, does not return movies; default is True
|
||||
movies: if True, returns movie files, if False, does not return movies; default is True
|
||||
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
|
||||
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
|
||||
intrash: if True, returns only images in "Recently deleted items" folder,
|
||||
intrash: if True, returns only images in "Recently deleted items" folder,
|
||||
if False returns only photos that aren't deleted; default is False
|
||||
|
||||
Returns:
|
||||
@@ -2839,7 +2874,7 @@ class PhotosDB:
|
||||
return photoinfo
|
||||
|
||||
def get_photo(self, uuid):
|
||||
""" Returns a single photo matching uuid
|
||||
"""Returns a single photo matching uuid
|
||||
|
||||
Arguments:
|
||||
uuid: the UUID of photo to get
|
||||
@@ -2854,7 +2889,7 @@ class PhotosDB:
|
||||
|
||||
# TODO: add to docs and test
|
||||
def photos_by_uuid(self, uuids):
|
||||
""" Returns a list of photos with UUID in uuids.
|
||||
"""Returns a list of photos with UUID in uuids.
|
||||
Does not generate error if invalid or missing UUID passed.
|
||||
This is faster than using PhotosDB.photos if you have list of UUIDs.
|
||||
Returns photos regardless of intrash state.
|
||||
@@ -3206,11 +3241,12 @@ class PhotosDB:
|
||||
|
||||
if options.regex:
|
||||
flags = re.IGNORECASE if options.ignore_case else 0
|
||||
render_options = RenderOptions(none_str="")
|
||||
for regex, template in options.regex:
|
||||
regex = re.compile(regex, flags)
|
||||
photo_list = []
|
||||
for p in photos:
|
||||
rendered, _ = p.render_template(template, none_str="")
|
||||
rendered, _ = p.render_template(template, render_options)
|
||||
for value in rendered:
|
||||
if regex.search(value):
|
||||
photo_list.append(p)
|
||||
@@ -3225,10 +3261,45 @@ class PhotosDB:
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid query_eval CRITERIA: {e}")
|
||||
|
||||
if options.duplicate:
|
||||
no_date = datetime(1970, 1, 1)
|
||||
tz = timezone(timedelta(0))
|
||||
no_date = no_date.astimezone(tz=tz)
|
||||
photos = sorted(
|
||||
[p for p in photos if p.duplicates],
|
||||
key=lambda x: x.date_added or no_date,
|
||||
)
|
||||
# gather all duplicates but ensure each uuid is only represented once
|
||||
photodict = OrderedDict()
|
||||
for p in photos:
|
||||
if p.uuid not in photodict:
|
||||
photodict[p.uuid] = p
|
||||
for d in sorted(
|
||||
p.duplicates, key=lambda x: x.date_added or no_date
|
||||
):
|
||||
if d.uuid not in photodict:
|
||||
photodict[d.uuid] = d
|
||||
photos = list(photodict.values())
|
||||
|
||||
# filter for deleted as photo.duplicates will include photos in the trash
|
||||
if not (options.deleted or options.deleted_only):
|
||||
photos = [p for p in photos if not p.intrash]
|
||||
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):
|
||||
""" Compute a signature for finding possible duplicates """
|
||||
"""Compute a signature for finding possible duplicates"""
|
||||
return (
|
||||
self._dbphotos[uuid]["original_filesize"],
|
||||
self._dbphotos[uuid]["imageDate"],
|
||||
@@ -3249,8 +3320,8 @@ class PhotosDB:
|
||||
return False
|
||||
|
||||
def __len__(self):
|
||||
""" Returns number of photos in the database
|
||||
Includes recently deleted photos and non-selected burst images
|
||||
"""Returns number of photos in the database
|
||||
Includes recently deleted photos and non-selected burst images
|
||||
"""
|
||||
return len(self._dbphotos)
|
||||
|
||||
@@ -3280,4 +3351,4 @@ def _get_photos_by_attribute(photos, attribute, values, ignore_case):
|
||||
else:
|
||||
for x in values:
|
||||
photos_search.extend(p for p in photos if x in getattr(p, attribute))
|
||||
return photos_search
|
||||
return list(set(photos_search))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ Valid filters are:
|
||||
- braces: Enclose value in curly braces, e.g. 'value => '{value}'.
|
||||
- parens: Enclose value in parentheses, e.g. 'value' => '(value')
|
||||
- brackets: Enclose value in brackets, e.g. 'value' => '[value]'
|
||||
- shell_quote: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.
|
||||
- function: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py
|
||||
<!-- OSXPHOTOS-FILTER-TABLE:END -->
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import datetime
|
||||
import locale
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from textx import TextXSyntaxError, metamodel_from_file
|
||||
|
||||
@@ -13,7 +16,9 @@ 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 .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
|
||||
|
||||
# ensure locale set to user's locale
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
@@ -137,7 +142,12 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{cr}": r"A carriage return: '\r'",
|
||||
"{crlf}": r"a carriage return + line feed: '\r\n'",
|
||||
"{osxphotos_version}": f"The osxphotos version, e.g. '{__version__}'",
|
||||
"{osxphotos_cmd_line}": "The full command line used to run osxphotos"
|
||||
"{osxphotos_cmd_line}": "The full command line used to run osxphotos",
|
||||
}
|
||||
|
||||
TEMPLATE_SUBSTITUTIONS_PATHLIB = {
|
||||
"{export_dir}": "The full path to the export directory",
|
||||
"{filepath}": "The full path to the exported file",
|
||||
}
|
||||
|
||||
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
|
||||
@@ -164,6 +174,7 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
+ "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. "
|
||||
+ "'{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of "
|
||||
+ "the 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 'file.py' is the name of the python file and 'function_name' is the name of the function to call. "
|
||||
+ "The function will be passed the PhotoInfo object for the photo. "
|
||||
@@ -179,7 +190,8 @@ FILTER_VALUES = {
|
||||
"braces": "Enclose value in curly braces, e.g. 'value => '{value}'.",
|
||||
"parens": "Enclose value in parentheses, e.g. 'value' => '(value')",
|
||||
"brackets": "Enclose value in brackets, e.g. 'value' => '[value]'",
|
||||
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py"
|
||||
"shell_quote": "Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
|
||||
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py",
|
||||
}
|
||||
|
||||
# Just the substitutions without the braces
|
||||
@@ -187,13 +199,18 @@ SINGLE_VALUE_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS
|
||||
]
|
||||
|
||||
# Just the multi-valued substitution names without the braces
|
||||
PATHLIB_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS_PATHLIB
|
||||
]
|
||||
|
||||
MULTI_VALUE_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "")
|
||||
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||
]
|
||||
|
||||
FIELD_NAMES = SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS
|
||||
FIELD_NAMES = (
|
||||
SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS + PATHLIB_SUBSTITUTIONS
|
||||
)
|
||||
|
||||
# default values for string manipulation template options
|
||||
INPLACE_DEFAULT = ","
|
||||
@@ -217,20 +234,53 @@ PUNCTUATION = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderOptions:
|
||||
"""Options for PhotoTemplate.render
|
||||
|
||||
template: str template
|
||||
none_str: str to use default for None values, default is '_'
|
||||
path_sep: optional string to use as path separator, default is os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
strip: if True, strips leading/trailing whitespace from rendered templates
|
||||
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
|
||||
export_dir: set to the export directory if you want to evalute {export_dir} template
|
||||
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
|
||||
quote: quote path templates for execution in the shell
|
||||
"""
|
||||
|
||||
none_str: str = "_"
|
||||
path_sep: Optional[str] = PATH_SEP_DEFAULT
|
||||
expand_inplace: bool = False
|
||||
inplace_sep: Optional[str] = INPLACE_DEFAULT
|
||||
filename: bool = False
|
||||
dirname: bool = False
|
||||
strip: bool = False
|
||||
edited_version: bool = False
|
||||
export_dir: Optional[str] = None
|
||||
filepath: Optional[str] = None
|
||||
quote: bool = False
|
||||
|
||||
|
||||
class PhotoTemplateParser:
|
||||
"""Parser for PhotoTemplate """
|
||||
"""Parser for PhotoTemplate"""
|
||||
|
||||
# implemented as Singleton
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
"""create new object or return instance of already created singleton"""
|
||||
if not hasattr(cls, "instance") or not cls.instance:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
def __init__(self):
|
||||
""" return existing singleton or create a new one """
|
||||
"""return existing singleton or create a new one"""
|
||||
|
||||
if hasattr(self, "metamodel"):
|
||||
return
|
||||
@@ -238,15 +288,15 @@ class PhotoTemplateParser:
|
||||
self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False)
|
||||
|
||||
def parse(self, template_statement):
|
||||
"""Parse a template_statement string """
|
||||
"""Parse a template_statement string"""
|
||||
return self.metamodel.model_from_str(template_statement)
|
||||
|
||||
|
||||
class PhotoTemplate:
|
||||
""" PhotoTemplate class to render a template string from a PhotoInfo object """
|
||||
"""PhotoTemplate class to render a template string from a PhotoInfo object"""
|
||||
|
||||
def __init__(self, photo, exiftool_path=None):
|
||||
""" Inits PhotoTemplate class with photo
|
||||
"""Inits PhotoTemplate class with photo
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo instance.
|
||||
@@ -262,49 +312,51 @@ class PhotoTemplate:
|
||||
# get parser singleton
|
||||
self.parser = PhotoTemplateParser()
|
||||
|
||||
# should {edited_version} render True?
|
||||
self.edited_version = False
|
||||
# initialize render options
|
||||
# this will be done in render() but for testing, some of the lookup functions are called directly
|
||||
options = RenderOptions()
|
||||
self.path_sep = options.path_sep
|
||||
self.inplace_sep = options.inplace_sep
|
||||
self.edited_version = options.edited_version
|
||||
self.none_str = options.none_str
|
||||
self.expand_inplace = options.expand_inplace
|
||||
self.filename = options.filename
|
||||
self.dirname = options.dirname
|
||||
self.strip = options.strip
|
||||
self.export_dir = options.export_dir
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
|
||||
def render(
|
||||
self,
|
||||
template,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
edited_version=False,
|
||||
template: str,
|
||||
options: RenderOptions,
|
||||
):
|
||||
""" Render a filename or directory template
|
||||
"""Render a filename or directory template
|
||||
|
||||
Args:
|
||||
template: str template
|
||||
none_str: str to use default for None values, default is '_'
|
||||
path_sep: optional string to use as path separator, default is os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
strip: if True, strips leading/trailing whitespace from rendered templates
|
||||
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
|
||||
template: str template
|
||||
options: a RenderOptions instance
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
|
||||
if path_sep is None:
|
||||
path_sep = PATH_SEP_DEFAULT
|
||||
|
||||
if inplace_sep is None:
|
||||
inplace_sep = INPLACE_DEFAULT
|
||||
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
self.path_sep = options.path_sep
|
||||
self.inplace_sep = options.inplace_sep
|
||||
self.edited_version = options.edited_version
|
||||
self.none_str = options.none_str
|
||||
self.expand_inplace = options.expand_inplace
|
||||
self.filename = options.filename
|
||||
self.dirname = options.dirname
|
||||
self.strip = options.strip
|
||||
self.export_dir = options.export_dir
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
|
||||
try:
|
||||
model = self.parser.parse(template)
|
||||
except TextXSyntaxError as e:
|
||||
@@ -314,53 +366,29 @@ class PhotoTemplate:
|
||||
# empty string
|
||||
return [], []
|
||||
|
||||
self.edited_version = edited_version
|
||||
|
||||
return self._render_statement(
|
||||
model,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
strip=strip,
|
||||
)
|
||||
return self._render_statement(model)
|
||||
|
||||
def _render_statement(
|
||||
self,
|
||||
statement,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
):
|
||||
path_sep = path_sep or self.path_sep
|
||||
results = []
|
||||
unmatched = []
|
||||
for ts in statement.template_strings:
|
||||
results, unmatched = self._render_template_string(
|
||||
ts,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
results=results,
|
||||
unmatched=unmatched,
|
||||
ts, results=results, unmatched=unmatched, path_sep=path_sep
|
||||
)
|
||||
|
||||
rendered_strings = results
|
||||
|
||||
if filename:
|
||||
if self.filename:
|
||||
rendered_strings = [
|
||||
sanitize_filename(rendered_str) for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
if strip:
|
||||
if self.strip:
|
||||
rendered_strings = [
|
||||
rendered_str.strip() for rendered_str in rendered_strings
|
||||
]
|
||||
@@ -370,16 +398,11 @@ class PhotoTemplate:
|
||||
def _render_template_string(
|
||||
self,
|
||||
ts,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
path_sep,
|
||||
results=None,
|
||||
unmatched=None,
|
||||
):
|
||||
"""Render a TemplateString object """
|
||||
"""Render a TemplateString object"""
|
||||
|
||||
results = results or [""]
|
||||
unmatched = unmatched or []
|
||||
@@ -387,7 +410,8 @@ class PhotoTemplate:
|
||||
if ts.template:
|
||||
# have a template field to process
|
||||
field = ts.template.field
|
||||
if field not in FIELD_NAMES and not field.startswith("photo"):
|
||||
field_part = field.split(".")[0]
|
||||
if field not in FIELD_NAMES and field_part not in FIELD_NAMES:
|
||||
unmatched.append(field)
|
||||
return [], unmatched
|
||||
|
||||
@@ -414,12 +438,7 @@ class PhotoTemplate:
|
||||
if ts.template.bool.value is not None:
|
||||
bool_val, u = self._render_statement(
|
||||
ts.template.bool.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@@ -435,12 +454,7 @@ class PhotoTemplate:
|
||||
if ts.template.default.value is not None:
|
||||
default, u = self._render_statement(
|
||||
ts.template.default.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@@ -457,12 +471,7 @@ class PhotoTemplate:
|
||||
# conditional value is also a TemplateString
|
||||
conditional_value, u = self._render_statement(
|
||||
ts.template.conditional.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@@ -478,10 +487,8 @@ class PhotoTemplate:
|
||||
vals = self.get_template_value(
|
||||
field,
|
||||
default=default,
|
||||
delim=delim or inplace_sep,
|
||||
path_sep=path_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
# delim=delim or self.inplace_sep,
|
||||
# path_sep=path_sep,
|
||||
)
|
||||
elif field == "exiftool":
|
||||
if subfield is None:
|
||||
@@ -489,7 +496,7 @@ class PhotoTemplate:
|
||||
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
|
||||
)
|
||||
vals = self.get_template_value_exiftool(
|
||||
subfield, filename=filename, dirname=dirname
|
||||
subfield,
|
||||
)
|
||||
elif field == "function":
|
||||
if subfield is None:
|
||||
@@ -497,20 +504,22 @@ class PhotoTemplate:
|
||||
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
|
||||
)
|
||||
vals = self.get_template_value_function(
|
||||
subfield, filename=filename, dirname=dirname
|
||||
subfield,
|
||||
)
|
||||
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
|
||||
vals = self.get_template_value_multi(
|
||||
field, path_sep=path_sep, filename=filename, dirname=dirname
|
||||
field, path_sep=path_sep, default=default
|
||||
)
|
||||
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
|
||||
vals = self.get_template_value_pathlib(field)
|
||||
else:
|
||||
unmatched.append(field)
|
||||
return [], unmatched
|
||||
|
||||
vals = [val for val in vals if val is not None]
|
||||
|
||||
if expand_inplace or delim is not None:
|
||||
sep = delim if delim is not None else inplace_sep
|
||||
if self.expand_inplace or delim is not None:
|
||||
sep = delim if delim is not None else self.inplace_sep
|
||||
vals = [sep.join(sorted(vals))]
|
||||
|
||||
for filter_ in filters:
|
||||
@@ -531,7 +540,7 @@ class PhotoTemplate:
|
||||
# have a conditional operator
|
||||
|
||||
def string_test(test_function):
|
||||
""" Perform string comparison using test_function; closure to capture conditional_value, vals, negation """
|
||||
"""Perform string comparison using test_function; closure to capture conditional_value, vals, negation"""
|
||||
match = False
|
||||
for c in conditional_value:
|
||||
for v in vals:
|
||||
@@ -546,7 +555,7 @@ class PhotoTemplate:
|
||||
return []
|
||||
|
||||
def comparison_test(test_function):
|
||||
""" Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation """
|
||||
"""Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation"""
|
||||
if len(vals) != 1 or len(conditional_value) != 1:
|
||||
raise ValueError(
|
||||
f"comparison operators may only be used with a single value: {vals} {conditional_value}"
|
||||
@@ -607,7 +616,7 @@ class PhotoTemplate:
|
||||
if is_bool:
|
||||
vals = default if not vals else bool_val
|
||||
elif not vals:
|
||||
vals = default or [none_str]
|
||||
vals = default or [self.none_str]
|
||||
|
||||
pre = ts.pre or ""
|
||||
post = ts.post or ""
|
||||
@@ -632,29 +641,29 @@ class PhotoTemplate:
|
||||
self,
|
||||
field,
|
||||
default,
|
||||
bool_val=None,
|
||||
delim=None,
|
||||
path_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
# bool_val=None,
|
||||
# delim=None,
|
||||
# path_sep=None,
|
||||
):
|
||||
"""lookup value for template field (single-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
default: the default value provided by the user
|
||||
bool_val: True value if expression is boolean
|
||||
bool_val: True value if expression is boolean
|
||||
delim: delimiter for expand in place
|
||||
path_sep: path separator for fields that are path-like
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
|
||||
|
||||
Returns:
|
||||
The matching template value (which may be None).
|
||||
|
||||
Raises:
|
||||
ValueError if no rule exists for field.
|
||||
"""
|
||||
|
||||
if self.photo.uuid is None:
|
||||
return []
|
||||
|
||||
if field not in FIELD_NAMES:
|
||||
raise ValueError(f"SyntaxError: Unknown field: {field}")
|
||||
|
||||
@@ -920,9 +929,40 @@ class PhotoTemplate:
|
||||
# if here, didn't get a match
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
if filename:
|
||||
if self.filename:
|
||||
value = sanitize_pathpart(value)
|
||||
elif dirname:
|
||||
elif self.dirname:
|
||||
value = sanitize_dirname(value)
|
||||
|
||||
return [value]
|
||||
|
||||
def get_template_value_pathlib(self, field):
|
||||
"""lookup value for template pathlib template fields
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
|
||||
Returns:
|
||||
The matching template value (which may be None).
|
||||
|
||||
Raises:
|
||||
ValueError if no rule exists for field.
|
||||
"""
|
||||
field_stem = field.split(".")[0]
|
||||
if field_stem not in PATHLIB_SUBSTITUTIONS:
|
||||
raise ValueError(f"SyntaxError: Unknown field: {field}")
|
||||
|
||||
field_value = None
|
||||
try:
|
||||
field_value = getattr(self, field_stem)
|
||||
except AttributeError:
|
||||
raise ValueError(f"Unknown path-like field: {field_stem}")
|
||||
|
||||
value = _get_pathlib_value(field, field_value, self.quote)
|
||||
|
||||
if self.filename:
|
||||
value = sanitize_pathpart(value)
|
||||
elif self.dirname:
|
||||
value = sanitize_dirname(value)
|
||||
|
||||
return [value]
|
||||
@@ -968,20 +1008,25 @@ class PhotoTemplate:
|
||||
value = ["[" + v + "]" for v in values]
|
||||
else:
|
||||
value = ["[" + values + "]"] if values else []
|
||||
elif filter_ == "shell_quote":
|
||||
if values and type(values) == list:
|
||||
value = [shlex.quote(v) for v in values]
|
||||
else:
|
||||
value = [shlex.quote(values)] if values else []
|
||||
elif filter_.startswith("function:"):
|
||||
value = self.get_template_value_filter_function(filter_, values)
|
||||
else:
|
||||
value = []
|
||||
return value
|
||||
|
||||
def get_template_value_multi(self, field, path_sep, filename=False, dirname=False):
|
||||
def get_template_value_multi(self, field, path_sep, default):
|
||||
"""lookup value for template field (multi-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
path_sep: path separator to use for folder_album field
|
||||
dirname: if True, values will be sanitized to be valid directory names; default = False
|
||||
|
||||
default: value of default field
|
||||
|
||||
Returns:
|
||||
List of the matching template values or [].
|
||||
|
||||
@@ -990,6 +1035,10 @@ class PhotoTemplate:
|
||||
"""
|
||||
|
||||
""" return list of values for a multi-valued template field """
|
||||
|
||||
if self.photo.uuid is None:
|
||||
return []
|
||||
|
||||
values = []
|
||||
if field == "album":
|
||||
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
|
||||
@@ -1013,7 +1062,7 @@ class PhotoTemplate:
|
||||
for album in album_info:
|
||||
if album.folder_names:
|
||||
# album in folder
|
||||
if dirname:
|
||||
if self.dirname:
|
||||
# being used as a filepath so sanitize each part
|
||||
folder = path_sep.join(
|
||||
sanitize_dirname(f) for f in album.folder_names
|
||||
@@ -1025,7 +1074,7 @@ class PhotoTemplate:
|
||||
values.append(folder)
|
||||
else:
|
||||
# album not in folder
|
||||
if dirname:
|
||||
if self.dirname:
|
||||
values.append(sanitize_dirname(album.title))
|
||||
else:
|
||||
values.append(album.title)
|
||||
@@ -1043,6 +1092,8 @@ class PhotoTemplate:
|
||||
values = (
|
||||
self.photo.search_info.venue_types if self.photo.search_info else []
|
||||
)
|
||||
elif field == "shell_quote":
|
||||
values = [shlex.quote(v) for v in default if v]
|
||||
elif field.startswith("photo"):
|
||||
# provide access to PhotoInfo object
|
||||
properties = field.split(".")
|
||||
@@ -1073,9 +1124,9 @@ class PhotoTemplate:
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
# sanitize directory names if needed, folder_album handled differently above
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname and field != "folder_album":
|
||||
elif self.dirname and field != "folder_album":
|
||||
# skip folder_album because it would have been handled above
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
|
||||
@@ -1083,9 +1134,15 @@ class PhotoTemplate:
|
||||
values = values or []
|
||||
return values
|
||||
|
||||
def get_template_value_exiftool(self, subfield, filename=None, dirname=None):
|
||||
def get_template_value_exiftool(
|
||||
self,
|
||||
subfield,
|
||||
):
|
||||
"""Get template value for format "{exiftool:EXIF:Model}" """
|
||||
|
||||
if self.photo is None:
|
||||
return []
|
||||
|
||||
if not self.photo.path:
|
||||
return []
|
||||
|
||||
@@ -1098,17 +1155,20 @@ class PhotoTemplate:
|
||||
values = [str(v) for v in values]
|
||||
|
||||
# sanitize directory names if needed
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname:
|
||||
elif self.dirname:
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
else:
|
||||
values = []
|
||||
|
||||
return values
|
||||
|
||||
def get_template_value_function(self, subfield, filename=None, dirname=None):
|
||||
"""Get template value from external function """
|
||||
def get_template_value_function(
|
||||
self,
|
||||
subfield,
|
||||
):
|
||||
"""Get template value from external function"""
|
||||
|
||||
if "::" not in subfield:
|
||||
raise ValueError(
|
||||
@@ -1117,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)):
|
||||
@@ -1131,17 +1192,18 @@ class PhotoTemplate:
|
||||
values = [values]
|
||||
|
||||
# sanitize directory names if needed
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname:
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
elif self.dirname:
|
||||
# sanitize but don't replace any "/" as user function may want to create sub directories
|
||||
values = [sanitize_dirname(value, replacement=None) for value in values]
|
||||
|
||||
return values
|
||||
|
||||
def get_template_value_filter_function(self, filter_, values):
|
||||
"""Filter template value from external function """
|
||||
"""Filter template value from external function"""
|
||||
|
||||
filter_ = filter_.replace("function:","")
|
||||
filter_ = filter_.replace("function:", "")
|
||||
|
||||
if "::" not in filter_:
|
||||
raise ValueError(
|
||||
@@ -1150,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]
|
||||
@@ -1166,9 +1229,8 @@ class PhotoTemplate:
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def get_photo_video_type(self, default):
|
||||
""" return media type, e.g. photo or video """
|
||||
"""return media type, e.g. photo or video"""
|
||||
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
|
||||
if self.photo.isphoto:
|
||||
return default_dict["photo"]
|
||||
@@ -1176,7 +1238,7 @@ class PhotoTemplate:
|
||||
return default_dict["video"]
|
||||
|
||||
def get_media_type(self, default):
|
||||
""" return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type """
|
||||
"""return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type"""
|
||||
default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS)
|
||||
p = self.photo
|
||||
if p.selfie:
|
||||
@@ -1210,7 +1272,7 @@ class PhotoTemplate:
|
||||
|
||||
|
||||
def parse_default_kv(default, default_dict):
|
||||
""" parse a string in form key1=value1;key2=value2,... as used for some template fields
|
||||
"""parse a string in form key1=value1;key2=value2,... as used for some template fields
|
||||
|
||||
Args:
|
||||
default: str, in form 'photo=foto;video=vidéo'
|
||||
@@ -1235,9 +1297,38 @@ def parse_default_kv(default, default_dict):
|
||||
|
||||
|
||||
def get_template_help():
|
||||
"""Return help for template system as markdown string """
|
||||
"""Return help for template system as markdown string"""
|
||||
# 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 / "phototemplate.md"
|
||||
with open(help_file, "r") as fd:
|
||||
md = fd.read()
|
||||
return md
|
||||
|
||||
|
||||
def _get_pathlib_value(field, value, quote):
|
||||
"""Get the value for a pathlib.Path type template
|
||||
|
||||
Args:
|
||||
field: the path field, e.g. "filename.stem"
|
||||
value: the value for the path component
|
||||
quote: bool; if true, quotes the returned path for safe execution in the shell
|
||||
"""
|
||||
parts = field.split(".")
|
||||
|
||||
if len(parts) == 1:
|
||||
return shlex.quote(value) if quote else value
|
||||
|
||||
if len(parts) > 2:
|
||||
raise ValueError(f"Illegal value for path template: {field}")
|
||||
|
||||
path = parts[0]
|
||||
attribute = parts[1]
|
||||
path = pathlib.Path(value)
|
||||
try:
|
||||
val = getattr(path, attribute)
|
||||
val_str = str(val)
|
||||
if quote:
|
||||
val_str = shlex.quote(val_str)
|
||||
return val_str
|
||||
except AttributeError:
|
||||
raise ValueError("Illegal value for path template: {attribute}")
|
||||
|
||||
@@ -63,7 +63,8 @@ SubField:
|
||||
;
|
||||
|
||||
SUBFIELD_WORD:
|
||||
/[\.\w:\/]+/
|
||||
/[\.\w:\/\-\~\'\"\%\@\#\^\’]+/
|
||||
/\\\s/?
|
||||
;
|
||||
|
||||
Filter:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
""" QueryOptions class for PhotosDB.query """
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Iterable, Tuple
|
||||
import datetime
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
import bitmath
|
||||
|
||||
|
||||
@@ -30,7 +31,7 @@ class QueryOptions:
|
||||
shared: Optional[bool] = None
|
||||
not_shared: Optional[bool] = None
|
||||
photos: Optional[bool] = True
|
||||
movies: Optional[bool] = True
|
||||
movies: Optional[bool] = True
|
||||
uti: Optional[Iterable[str]] = None
|
||||
burst: Optional[bool] = None
|
||||
not_burst: Optional[bool] = None
|
||||
@@ -78,6 +79,10 @@ class QueryOptions:
|
||||
max_size: Optional[bitmath.Byte] = None
|
||||
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)
|
||||
|
||||
@@ -315,6 +315,40 @@ Then the next to you run osxphotos, you can simply do this:
|
||||
|
||||
The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like.
|
||||
|
||||
### Run commands on exported photos for post-processing
|
||||
|
||||
You can use the `--post-command` option to run one or more commands against exported files. The `--post-command` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: `osxphotos help export`. For example, the `exported` category includes all exported photos and the `skipped` category includes all photos that were skipped when running export with `--update`. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution.
|
||||
|
||||
For example, the following command generates a log of all exported files and their associated keywords:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"`
|
||||
|
||||
The special template field `{shell_quote}` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template `{filepath}` is set to the full path of the exported file and `{export_dir}` is set to the full path of the base export directory.
|
||||
|
||||
Explanation of the template string:
|
||||
|
||||
```txt
|
||||
{shell_quote,{filepath}{comma}{,+keyword,}}
|
||||
│ │ │ │ │
|
||||
│ │ │ | │
|
||||
└──> quote everything after comma for proper execution in the shell
|
||||
│ │ │ │
|
||||
└───> filepath of the exported file
|
||||
│ │ │
|
||||
└───> insert a comma
|
||||
│ │
|
||||
└───> join the list of keywords together with a ","
|
||||
│
|
||||
└───> if no keywords, insert nothing (empty string: "")
|
||||
```
|
||||
|
||||
Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"`
|
||||
|
||||
This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text.
|
||||
|
||||
|
||||
### An example from an actual osxphotos user
|
||||
|
||||
Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!):
|
||||
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
|
||||
@@ -316,8 +293,8 @@ def findfiles(pattern, path_):
|
||||
|
||||
|
||||
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 +305,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 +358,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 +371,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 +396,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")
|
||||
|
||||
@@ -19,3 +19,4 @@ osxmetadata==0.99.14
|
||||
textx==2.3.0
|
||||
rich==10.2.2
|
||||
bitmath==1.3.3.1
|
||||
more-itertools==8.8.0
|
||||
1
setup.py
@@ -94,6 +94,7 @@ setup(
|
||||
"textx==2.3.0",
|
||||
"rich==10.2.2",
|
||||
"bitmath==1.3.3.1",
|
||||
"more-itertools==8.8.0",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<key>hostname</key>
|
||||
<string>Rhets-MacBook-Pro.local</string>
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<string>585B80BF-8D1F-55EF-A9E8-6CF4E5523959</string>
|
||||
<key>pid</key>
|
||||
<integer>86501</integer>
|
||||
<integer>570</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 46 KiB |
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 |