Compare commits

...

405 Commits

Author SHA1 Message Date
Rhet Turnbull
5290fae2e0 Updated docs [skip ci] 2022-02-21 09:34:40 -08:00
Rhet Turnbull
ecbd370a47 Exportdb refactor (#638)
* Working on export_db refactor

* Added exportdb command, removed logic for missing export_db, #630

* Updated tests

* updated docs

* Added --config-only, #606

* Added validation for --exportdb

* Added --info to exportdb command

* Fixed exportdb --touch-file to migrate database if needed

* Added exportdb --migrate
2022-02-21 09:15:01 -08:00
Rhet Turnbull
d8204e65eb Allow multiple characters as path_sep, #634 2022-02-14 06:46:19 -08:00
Rhet Turnbull
9c26e5519b Added crash_reporter.py 2022-02-13 15:02:35 -08:00
Rhet Turnbull
060729c4c4 Added --debug and crash reporter to export, #628 2022-02-13 14:51:31 -08:00
Rhet Turnbull
65d51ab129 Updated docs [skip ci] 2022-02-13 00:21:27 -08:00
Rhet Turnbull
afbda030bc beta fix for #633, fix face regions in exiftool 2022-02-13 00:14:59 -08:00
Rhet Turnbull
d111d07fb7 Updated CHANGELOG.md [skip ci] 2022-02-12 21:13:56 -08:00
Rhet Turnbull
30abdddaf3 Added --force-update, #621 2022-02-12 21:01:16 -08:00
Rhet Turnbull
a2f329b8de Updated CHANGELOG.md [skip ci] 2022-02-12 17:59:48 -08:00
Rhet Turnbull
bfa888adc5 Added --force-update, #621 2022-02-12 17:49:40 -08:00
Rhet Turnbull
ac4083bfbb Fix for #630 2022-02-12 00:23:50 -08:00
Rhet Turnbull
5fb686ac0c Refactored fix for #627 2022-02-11 23:10:09 -08:00
Rhet Turnbull
49a7b80680 Fixed cleanup for #629 2022-02-11 06:18:17 -08:00
Rhet Turnbull
cb11967eac Implement #629, sqlite performance optimizatons for export db 2022-02-10 22:36:35 -08:00
Rhet Turnbull
a43bfc5a33 Updated CHANGELOG.md [skip ci] 2022-02-06 00:01:43 -08:00
Rhet Turnbull
1d6bc4e09e Additional fix for #615 2022-02-05 23:57:50 -08:00
Rhet Turnbull
3e14b718ef Updated docs [skip ci] 2022-02-05 23:12:42 -08:00
Rhet Turnbull
1ae6270561 Fixed exiftool to ignore unsupported file types, #615 2022-02-05 22:54:50 -08:00
Rhet Turnbull
55a601c07e Updated tests 2022-02-05 14:30:20 -08:00
Rhet Turnbull
7d67b81879 Updated CHANGELOG.md [skip ci] 2022-02-05 14:08:43 -08:00
Rhet Turnbull
cd02144ac3 Fix for --name searching only original_filename on Photos 5+, #594 2022-02-05 12:55:56 -08:00
Rhet Turnbull
9b247acd1c Fix for unicode in query strings, #618 2022-02-05 12:36:25 -08:00
Rhet Turnbull
942126ea3d Updated CHANGELOG.md [skip ci] 2022-02-05 10:56:18 -08:00
Rhet Turnbull
2b9ea11701 Updated docs [skip ci] 2022-02-05 10:39:35 -08:00
Rhet Turnbull
b3d3e14ffe Fix for #561, no really, I mean it this time 2022-02-05 10:36:23 -08:00
Rhet Turnbull
62ae5db9fd Updated CHANGELOG.md [skip ci] 2022-02-04 21:59:33 -08:00
Rhet Turnbull
77a49a09a1 Updated tests for #561 [skip ci] 2022-02-04 05:56:01 -08:00
Rhet Turnbull
06c5bbfcfd Updated docs [skip ci] 2022-02-03 22:49:56 -08:00
Rhet Turnbull
f3063d35be Fix for filenames with special characters, #561, #618 2022-02-03 22:46:11 -08:00
Rhet Turnbull
e32090bf39 Updated known issues [skip ci] 2022-02-01 06:53:25 -08:00
Rhet Turnbull
7ab500740b Added progress counter, #601 2022-01-29 19:02:25 -08:00
Rhet Turnbull
911bd30d28 Updated CHANGELOG.md [skip ci] 2022-01-29 19:02:00 -08:00
allcontributors[bot]
282857eae0 docs: add oPromessa as a contributor for ideas, test (#611)
* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-29 14:08:36 -08:00
Rhet Turnbull
d8c2f99c06 Added --timestamp option for --verbose, #600 2022-01-29 11:59:41 -08:00
Rhet Turnbull
16d3f74366 Updated formatting for elapsed time, #604 2022-01-29 11:05:33 -08:00
Rhet Turnbull
5fc28139ea Updated docs [skip ci] 2022-01-29 10:55:41 -08:00
Rhet Turnbull
b7b6876688 Updated CHANGELOG.md [skip ci] 2022-01-29 10:03:31 -08:00
Rhet Turnbull
235dea329c Implemented #605, refactor out export2 2022-01-29 09:38:52 -08:00
Rhet Turnbull
5afdf6fc20 Fix for #564, --preview with --download-missing 2022-01-29 08:27:43 -08:00
Rhet Turnbull
385059e973 Updated CHANGELOG.md [skip ci] 2022-01-28 23:32:46 -08:00
Rhet Turnbull
62aed02070 Updated docs [skip ci] 2022-01-28 23:20:27 -08:00
Rhet Turnbull
6843b8661d Refactored photoexporter for performance, #591 2022-01-28 23:15:02 -08:00
Rhet Turnbull
9da747ea9d Refactoring to support #591 2022-01-27 21:37:12 -08:00
Rhet Turnbull
22964afc69 Performance improvements and refactoring, #462, partial for #591 2022-01-27 06:28:12 -08:00
Rhet Turnbull
3bc53fd92b Performance improvements, partial for #591 2022-01-25 20:37:58 -08:00
Rhet Turnbull
bd31120569 Version bump 2022-01-24 06:28:58 -08:00
Rhet Turnbull
6af124e4d3 Removed exportdb requirement from PhotoTemplate 2022-01-24 06:20:34 -08:00
Rhet Turnbull
b3b1d8f193 Updated CHANGELOG.md [skip ci] 2022-01-23 22:01:54 -08:00
Rhet Turnbull
785580115b Added query options to repl, #597 2022-01-23 21:57:51 -08:00
Rhet Turnbull
b4bd04c146 Added run command, #598 2022-01-23 18:38:16 -08:00
Rhet Turnbull
e88c6b8a59 Bug fix for get_photos_library_version 2022-01-23 18:06:19 -08:00
Rhet Turnbull
74868238f3 Performance improvements, added --profile 2022-01-23 17:14:55 -08:00
Xiaoliang Wu
61a300250d creat unit test for __all__ (#599) 2022-01-23 16:40:20 -08:00
Rhet Turnbull
d8dbc0866f Updated CHANGELOG.md [skip ci] 2022-01-22 14:43:11 -08:00
Rhet Turnbull
586d96ae74 Updated docs [skip ci] 2022-01-22 14:40:38 -08:00
Rhet Turnbull
81032a5745 Added tutorial.md, #596 2022-01-22 14:38:22 -08:00
Rhet Turnbull
c2d726beaf More refactoring of export code, #462 2022-01-22 10:44:29 -08:00
Rhet Turnbull
3bafdf7bfd Blackified files 2022-01-22 09:25:08 -08:00
Xiaoliang Wu
edcc7ea34f Create __all__ for all python files (#589)
* add __all__ to files "adjustmentsinfo.py" and "albuminfo.py"

* add __all__ to file "cli.py"

* add __all__ to all files that misses except files with prefix "_"
2022-01-22 09:22:47 -08:00
Rhet Turnbull
6261a7b5c9 More refactoring of export code, #462 2022-01-22 09:03:01 -08:00
Rhet Turnbull
881832c92d Removed warning from test 2022-01-18 08:08:58 -08:00
Xiaoliang Wu
47d4dc7ef0 Create __all__ for the file cli.py (#587)
* add __all__ to files "adjustmentsinfo.py" and "albuminfo.py"

* add __all__ to file "cli.py"
2022-01-17 22:03:48 -08:00
allcontributors[bot]
10ce81bf98 docs: add xwu64 as a contributor for code (#585)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-15 22:49:56 -08:00
Xiaoliang Wu
98b3d9f81e add __all__ to files "adjustmentsinfo.py" and "albuminfo.py" (#584) 2022-01-15 22:49:03 -08:00
Rhet Turnbull
81cbb7dcc4 Refactored docstrings, #462 2022-01-15 17:45:38 -08:00
Rhet Turnbull
9517876bd0 Added ExportOptions to photoexporter.py, #462 2022-01-15 16:12:27 -08:00
Rhet Turnbull
231d132792 More refactoring of export code, #462 2022-01-14 21:57:27 -08:00
Rhet Turnbull
9ada5dfea4 More refactoring of export code, #462 2022-01-14 19:48:36 -08:00
Rhet Turnbull
476c94407f More refactoring of export code, #462 2022-01-14 18:31:50 -08:00
Rhet Turnbull
458da0e9b2 Refactored photoexporter sidecar writing, #462 2022-01-14 17:43:40 -08:00
Rhet Turnbull
66673012ac Updated tested versions 2022-01-14 17:10:28 -08:00
Rhet Turnbull
46f8b6dc5a Updated README.md 2022-01-14 15:05:15 -08:00
Rhet Turnbull
ee81e69ece Added dev tools 2022-01-14 15:02:33 -08:00
Rhet Turnbull
3927f05267 Added diff command 2022-01-09 09:35:42 -08:00
Rhet Turnbull
a010ab5a29 Added uuid command 2022-01-09 07:58:14 -08:00
Rhet Turnbull
c49bebd412 Updated CHANGELOG.md [skip ci] 2022-01-09 07:49:09 -08:00
Rhet Turnbull
5a8105f5a0 Fix for #575, database version 5001 2022-01-09 07:44:38 -08:00
allcontributors[bot]
df66adeef6 docs: add ahti123 as a contributor for code, bug (#578)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-09 07:29:15 -08:00
Ahti Liin
4e2367c868 changing photos_5 version constant to satisfy version 5001 (#577)
Co-authored-by: Ahti Liin <ahti@mooncascade.com>
2022-01-09 07:28:22 -08:00
Rhet Turnbull
53c701cc0e Added sqlgrep 2022-01-08 17:41:06 -08:00
Rhet Turnbull
92fced75da Added test for #576 2022-01-08 17:39:49 -08:00
Rhet Turnbull
4dd838b8bc Added grep command to CLI 2022-01-08 17:14:36 -08:00
Rhet Turnbull
0a3c375943 Updated CHANGELOG.md [skip ci] 2022-01-08 15:23:41 -08:00
Rhet Turnbull
64a0760a47 Updated docs [skip ci] 2022-01-08 15:23:14 -08:00
Rhet Turnbull
2e7db47806 Fix for #576, error exporting edited live photos 2022-01-08 15:15:28 -08:00
Rhet Turnbull
d2d56a7f71 Fix for burst images with pick type = 0, partial fix for #571 2022-01-06 22:46:16 -08:00
Rhet Turnbull
b4897ff1b5 version bump [skip ci] 2022-01-06 22:16:12 -08:00
Rhet Turnbull
661a573bf5 Fix for #570 2022-01-06 22:13:25 -08:00
Rhet Turnbull
0c9bd87602 More refactoring of export code, #462 2022-01-06 05:40:47 -08:00
Rhet Turnbull
896d888710 Updated CHANGELOG.md [skip ci] 2022-01-04 06:35:23 -08:00
Rhet Turnbull
76aee7f189 Export DB can now reside outside export directory, #568 2022-01-04 06:28:59 -08:00
Rhet Turnbull
147b30f973 More refactoring of export code, #462 2022-01-02 22:38:22 -08:00
Rhet Turnbull
a73dc72558 Refactored photoinfo, photoexporter; #462 2022-01-02 09:06:04 -08:00
Rhet Turnbull
c99cf5518d Updated CHANGELOG.md [skip ci] 2021-12-31 20:50:42 -08:00
Rhet Turnbull
1391675a3a Updated tests and docs 2021-12-31 20:31:15 -08:00
Rhet Turnbull
a3b2784f31 ImageConverter now uses generic context; #562 2021-12-31 17:34:41 -08:00
Rhet Turnbull
cbe79ee98c Updated docs [skip ci] 2021-12-31 09:45:42 -08:00
Rhet Turnbull
eb7a2988bf Updated CHANGELOG.md [skip ci] 2021-12-31 09:45:19 -08:00
Rhet Turnbull
42426b95ee Bug fix for #559 2021-12-31 09:35:12 -08:00
Rhet Turnbull
262a6f31e7 Updated CHANGELOG.md [skip ci] 2021-12-31 08:39:18 -08:00
Rhet Turnbull
04930c3644 Added --skip-uuid, --skip-uuid-from-file, #563 2021-12-31 08:35:26 -08:00
Rhet Turnbull
44594a8e43 Added support for projects, implements #559 2021-12-31 07:30:20 -08:00
Rhet Turnbull
690d981f31 Fixed test for #561 2021-12-30 15:45:32 -08:00
Rhet Turnbull
06ea8d1e6c Updated CHANGELOG.md [skip ci] 2021-12-29 11:24:29 -08:00
Rhet Turnbull
c4e3c5a8be Updated docs [skip ci] 2021-12-28 17:37:00 -08:00
Rhet Turnbull
03f4e7cc34 Fix for accented characters in album names, #561 2021-12-28 17:25:50 -08:00
Rhet Turnbull
0e54a08ae0 Updated docs [skip ci] 2021-12-26 20:09:10 -08:00
Rhet Turnbull
b71c752e9d Added get_photos_library_version 2021-12-26 19:57:33 -08:00
Rhet Turnbull
521848f955 Added export test for --exif 2021-12-25 05:53:26 -08:00
Rhet Turnbull
debb17c952 Implement #323 2021-12-25 05:41:37 -08:00
Rhet Turnbull
7819740f70 Fixed #463 2021-12-24 18:12:02 -08:00
Rhet Turnbull
b9ffb0d8de Fixed helped text, #493 2021-12-24 18:08:18 -08:00
Rhet Turnbull
d59852f594 Updated tests 2021-12-24 17:21:13 -08:00
Rhet Turnbull
085f482820 Added install/uninstall commands, #531 2021-12-24 17:05:01 -08:00
Rhet Turnbull
1cb8da96ce Updated dev_requirements.txt 2021-12-22 19:08:25 -08:00
Rhet Turnbull
50016a9eca Updated requirements_dev.txt 2021-12-22 18:10:15 -08:00
Rhet Turnbull
924f7325b4 Removed redundant dev_requirements.txt 2021-12-22 18:08:44 -08:00
Rhet Turnbull
181f678d9e Updated docs [skip ci] 2021-12-22 08:22:02 -08:00
Rhet Turnbull
6ce1b83ca2 Version bump 2021-12-21 09:39:05 -08:00
Rhet Turnbull
a08a653f20 Partial fix for #556 2021-12-21 09:36:42 -08:00
Rhet Turnbull
e1f1772080 Updated all-contributors 2021-12-16 22:11:52 -08:00
Andrew Louis
d2a1f792e9 Adds missing f-string to retry message (#553) 2021-12-16 17:23:33 -08:00
Rhet Turnbull
e7bd80e05f Update issue templates 2021-12-11 22:24:38 -08:00
Rhet Turnbull
9089c0323c Updated CHANGELOG.md [skip ci] 2021-12-10 20:40:40 -08:00
Rhet Turnbull
197e5663df Updated docs 2021-12-10 20:26:00 -08:00
Rhet Turnbull
f6dedaa619 Updated docs [skip ci] 2021-12-10 20:09:20 -08:00
Rhet Turnbull
0906dbe637 Fixed error for missing photo path, #547 2021-12-10 19:36:25 -08:00
Rhet Turnbull
0629b3f6d6 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-12-10 19:26:29 -08:00
Rhet Turnbull
55dfc0ec7d Fixed typo, thanks to @hyfen 2021-12-10 19:26:21 -08:00
Andrew Louis
b7f8b26f1d Fixes typo in README (#548) 2021-12-10 19:23:56 -08:00
Rhet Turnbull
9ca7dd50bc Updated all-contributors 2021-12-10 19:23:12 -08:00
allcontributors[bot]
0e73d57bdf docs: add alandefreitas as a contributor for bug (#551)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-12-10 19:08:32 -08:00
Rhet Turnbull
9de2c17e47 Added additional try/except in _photoinfo_export.py 2021-12-06 06:16:42 -08:00
Rhet Turnbull
1bae6d33f1 Updated to moment processing for consistency 2021-12-06 06:07:55 -08:00
Rhet Turnbull
a52b4d2f43 Added MomentInfo for Photos 5+, #71 2021-12-05 19:12:37 -08:00
Rhet Turnbull
3e038bf124 Added test library for Monterey on M1 2021-12-05 08:41:08 -08:00
Rhet Turnbull
870ed9c435 Updated README.md for Monterey 2021-12-04 10:46:06 -08:00
allcontributors[bot]
68e7ca3277 docs: add dgleich as a contributor for code (#541)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-11-25 10:06:35 -08:00
Rhet Turnbull
c9142c2156 Updated CHANGELOG.md [skip ci] 2021-11-25 10:01:54 -08:00
Rhet Turnbull
7d923590ae Updated dependencies for pyobjc 8.0 2021-11-25 08:34:55 -08:00
Rhet Turnbull
5383ced1ca Updated CHANGELOG.md [skip ci] 2021-11-11 11:05:19 -08:00
Rhet Turnbull
0e6c92dbd9 Fix for --use-photokit with --skip-live, #537 2021-11-11 10:54:48 -08:00
Rhet Turnbull
b00978c61a Updated CHANGELOG.md [skip ci] 2021-11-07 21:35:46 -08:00
Rhet Turnbull
fb583e28e0 Updated docs [skip ci] 2021-11-07 21:31:27 -08:00
Rhet Turnbull
760386e3d7 Updated tested versions 2021-11-07 21:21:50 -08:00
Rhet Turnbull
51ba54971a Test fixes for Monterey/M1 2021-11-07 08:33:08 -08:00
Rhet Turnbull
2ffcf1e82b Updated OTL to MTL 2021-11-06 07:13:02 -07:00
Rhet Turnbull
818f4f45a4 Dependency update for Monterey 2021-10-30 07:37:23 -07:00
Rhet Turnbull
2cf19f6af1 Updated docs [skip ci] 2021-10-30 07:25:26 -07:00
Rhet Turnbull
ef82c6e32b Updated for Monterey 12.0.1 release 2021-10-28 22:05:17 -07:00
Rhet Turnbull
0e9b9d6251 Updated docs [skip ci] 2021-10-15 05:45:17 -07:00
Rhet Turnbull
419b34ea73 Fix for #526 with --update 2021-10-15 05:36:41 -07:00
Rhet Turnbull
f64c4ed374 Fixed FileUtil to use correct import 2021-10-14 21:29:45 -07:00
Rhet Turnbull
1677f404d2 Updated CHANGELOG.md [skip ci] 2021-10-11 18:07:36 -07:00
allcontributors[bot]
a612a363ed docs: add spencerc99 as a contributor for bug (#527)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-10-11 18:03:50 -07:00
Rhet Turnbull
202bc1144b Fix for #526 2021-10-11 17:50:07 -07:00
Rhet Turnbull
a0c654e43f Updated README.md [skip ci] 2021-10-11 17:03:45 -07:00
Rhet Turnbull
2bb677dc19 Updated docs [skip ci] 2021-10-11 16:52:22 -07:00
Rhet Turnbull
e33805fe42 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-10-11 16:00:05 -07:00
Rhet Turnbull
04ac0a1121 Fix for #524 2021-10-11 15:59:40 -07:00
Rhet Turnbull
d2b0bd4e28 Fix for #525 2021-10-11 15:59:02 -07:00
allcontributors[bot]
d754899563 docs: add oPromessa as a contributor for bug (#525)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-10-11 15:55:49 -07:00
Rhet Turnbull
4a81e643a7 Updated CHANGELOG.md [skip ci] 2021-10-11 07:35:51 -07:00
Rhet Turnbull
b23e74f8f5 Updated docs [skip ci] 2021-10-11 07:32:15 -07:00
Rhet Turnbull
5dc766249a Updated dependencies 2021-10-10 23:05:53 -07:00
Rhet Turnbull
a895833c7f Updated dependencies 2021-10-10 22:27:00 -07:00
Rhet Turnbull
3f81a3c179 Added python 3.10 to supported versions 2021-10-09 09:39:23 -07:00
Rhet Turnbull
f1235f745f Updated CHANGELOG.md [skip ci] 2021-09-30 06:13:40 -07:00
Rhet Turnbull
1ddb1de998 Updated docs [skip ci] 2021-09-30 06:11:00 -07:00
Rhet Turnbull
c472698b1d Updated REPL, now with more cowbell 2021-09-30 05:59:03 -07:00
Rhet Turnbull
4e021a0731 Updated CHANGELOG.md [skip ci] 2021-09-26 15:57:07 -07:00
Rhet Turnbull
bfbc156821 Updated docs [skip ci] 2021-09-26 15:55:03 -07:00
Rhet Turnbull
bfd6274602 Fixed AlbumInfo.owner, #239 2021-09-26 15:52:38 -07:00
Rhet Turnbull
3abaa5ae84 Updated docs [skip ci] 2021-09-26 15:43:22 -07:00
Rhet Turnbull
65115a50a9 Updated CHANGELOG.md [skip ci] 2021-09-26 15:00:34 -07:00
Rhet Turnbull
06138e15d0 version bump 2021-09-26 14:56:10 -07:00
Rhet Turnbull
14710e3178 Performance fix for #239, owner 2021-09-26 14:55:43 -07:00
Rhet Turnbull
f705f09749 Updated CHANGELOG.md [skip ci] 2021-09-26 14:54:59 -07:00
Rhet Turnbull
82c445f41e Updated CHANGELOG.md [skip ci] 2021-09-26 14:29:36 -07:00
Rhet Turnbull
1b40e9d65f Fixed tests for comment fix 2021-09-26 14:07:35 -07:00
Rhet Turnbull
725f7c8735 Updated docs [skip ci] 2021-09-26 14:00:46 -07:00
Rhet Turnbull
7cc8578148 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-09-26 13:53:32 -07:00
Rhet Turnbull
6adafb8ce7 Fixed formatting 2021-09-26 13:53:21 -07:00
Rhet Turnbull
ac47df8475 Fix for #517, #239 2021-09-26 13:51:47 -07:00
Rhet Turnbull
f680cf78ab Removed macOS-11, need to fix detected_text test 2021-09-26 09:06:17 -07:00
Rhet Turnbull
c86e84c534 Removed python 3.10-dev, not available in GH 2021-09-26 08:06:32 -07:00
Rhet Turnbull
3fb611825c Added python 3.10, macOS 11 2021-09-26 07:53:32 -07:00
Rhet Turnbull
1cfdad0176 Updated CHANGELOG.md [skip ci] 2021-09-25 22:51:30 -07:00
Rhet Turnbull
59ba325273 Updated docs [skip ci] 2021-09-25 22:38:46 -07:00
Rhet Turnbull
c4b7c2623f Implemented PhotoInfo.owner, AlbumInfo.owner, #216, #239 2021-09-25 22:33:37 -07:00
Rhet Turnbull
e5b2d2ee45 Updated CHANGELOG.md [skip ci] 2021-09-25 09:51:50 -07:00
Rhet Turnbull
64c226b855 Updated docs [skip ci] 2021-09-25 08:54:16 -07:00
Rhet Turnbull
e3e1da2fd8 Fix for #516 2021-09-25 08:47:09 -07:00
Rhet Turnbull
57b2f8a413 Fixed README 2021-09-21 20:14:04 -07:00
Rhet Turnbull
5a76a511db Test pre-commit hook 2021-09-21 20:13:20 -07:00
Rhet Turnbull
283f049780 Test pre-commit hook 2021-09-21 20:12:42 -07:00
Rhet Turnbull
c4743cc867 Test pre-commit hook 2021-09-21 20:11:02 -07:00
Rhet Turnbull
c429a860b1 Update docs 2021-09-17 06:24:39 -07:00
Rhet Turnbull
1f748c829b Updated CHANGELOG.md [skip ci] 2021-09-15 20:35:18 -07:00
Rhet Turnbull
dd08c7f701 Fixed detected_text to use image orientation if available 2021-09-15 20:18:18 -07:00
Rhet Turnbull
77103193c0 Updated CHANGELOG.md [skip ci] 2021-09-14 17:38:22 -07:00
Rhet Turnbull
16335a6bd6 Added twine 2021-09-14 17:36:57 -07:00
Rhet Turnbull
e0f6d8ecf2 Added wheel 2021-09-14 17:36:20 -07:00
Rhet Turnbull
59c31ff88d Fix for #515, updated tests 2021-09-14 17:24:02 -07:00
Rhet Turnbull
93bf0c210c Fix for #515 2021-09-13 22:14:48 -07:00
Rhet Turnbull
4f7642b1d2 Updated tested versions 2021-09-12 17:34:16 -07:00
Rhet Turnbull
773dca8494 Updated docs 2021-09-12 17:31:15 -07:00
Rhet Turnbull
3cd26e2e38 Updated .gitignore 2021-09-12 16:35:48 -07:00
Rhet Turnbull
271761cf04 Updated CHANGELOG.md [skip ci] 2021-08-29 19:06:43 -07:00
Rhet Turnbull
6eea552fb9 Updated README [skip ci] 2021-08-29 18:34:35 -07:00
Rhet Turnbull
81dd1a7530 Updated README [skip ci] 2021-08-29 18:31:50 -07:00
Rhet Turnbull
2eb6e70e57 Updated dependencies 2021-08-29 18:30:23 -07:00
Rhet Turnbull
6bcc67634c Bug fix for null title, #512 2021-08-29 13:06:09 -07:00
Rhet Turnbull
062d8eb206 Updated CHANGELOG.md [skip ci] 2021-08-29 12:21:59 -07:00
Rhet Turnbull
f0d7496bc6 Fix for newlines in exif tags, #513 2021-08-29 12:18:20 -07:00
allcontributors[bot]
8e2b768236 docs: add dssinger as a contributor for bug (#514)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-08-29 07:07:51 -07:00
Rhet Turnbull
48bf326994 Updated CHANGELOG.md [skip ci] 2021-08-28 09:21:07 -07:00
Rhet Turnbull
159d1102aa Added {strip} template 2021-08-28 08:14:26 -07:00
Rhet Turnbull
dbb4dbc0a7 Fixed --strip behavior, #511 2021-08-28 08:01:08 -07:00
Rhet Turnbull
777e768243 Added selected and quit to repl 2021-08-28 07:23:17 -07:00
Rhet Turnbull
70999a70b8 Updated tutorial template 2021-08-27 23:52:14 -07:00
Rhet Turnbull
3a6b2c2c35 Update test_cli.py 2021-08-23 18:36:35 -07:00
Rhet Turnbull
dfb80ba8d6 Update test_cli.py 2021-08-23 18:30:34 -07:00
Rhet Turnbull
94b818b156 Update test_cli.py 2021-08-23 18:09:36 -07:00
Rhet Turnbull
f1cea1498b Update test for #506 2021-08-23 17:57:28 -07:00
Rhet Turnbull
345678577a Updated test for #506 2021-08-23 17:29:38 -07:00
Rhet Turnbull
fb4138cfe6 Updated README [skip ci] 2021-08-23 14:25:13 -07:00
Rhet Turnbull
db5b34d589 Fix for #506 2021-08-23 14:23:39 -07:00
Rhet Turnbull
8963af9229 Updated CHANGELOG.md [skip ci] 2021-08-15 14:14:51 -07:00
Rhet Turnbull
2041789ff4 Updated README.md [skip ci] 2021-08-15 14:12:15 -07:00
Rhet Turnbull
aec86f93ea Added inspect() to repl, closes #501 2021-08-15 13:50:37 -07:00
Rhet Turnbull
57bfb03e05 Updated CHANGELOG.md [skip ci] 2021-08-02 05:55:19 -07:00
Rhet Turnbull
c2b2476e38 Updated docs for Text Detection [skip ci] 2021-08-02 05:52:48 -07:00
Rhet Turnbull
fa2027d453 Improved caching of detected_text results 2021-08-02 05:10:26 -07:00
Rhet Turnbull
9d980e4917 Updated CHANGELOG.md [skip ci] 2021-07-29 21:27:51 -07:00
Rhet Turnbull
673243c6cd Fix for #500, check for macOS version before loading Vision 2021-07-29 21:16:33 -07:00
Rhet Turnbull
7376223eb8 Updated text_detection to detect macOS version 2021-07-29 07:39:01 -07:00
Rhet Turnbull
ecd0b8e22f Updated detected_text docs to make it clear this only works on Catalina+ 2021-07-29 07:03:04 -07:00
Rhet Turnbull
c4a608b5bd Updated CHANGELOG.md [skip ci] 2021-07-29 07:02:38 -07:00
Rhet Turnbull
4d9e21ea16 Added error logging to PhotoInfo.detected_text 2021-07-29 06:32:07 -07:00
Rhet Turnbull
1ee3e035c4 Updated README.md [skip ci] 2021-07-29 06:25:59 -07:00
Rhet Turnbull
b1c0fb3e82 Added error logging to {detected_text} processing, #499 2021-07-29 06:23:02 -07:00
Rhet Turnbull
de715d2afd Updated CHANGELOG.md [skip ci] 2021-07-28 06:36:10 -07:00
Rhet Turnbull
607cf80dda Removed unneeded test file [skip ci] 2021-07-28 06:27:44 -07:00
Rhet Turnbull
0c8fbd69af Updated dependencies 2021-07-28 06:21:21 -07:00
Rhet Turnbull
c2335236be Added {detected_text} template 2021-07-27 06:08:49 -07:00
Rhet Turnbull
123340eada Added PhotoInfo.detected_text() 2021-07-25 18:34:59 -07:00
Rhet Turnbull
852a06f99b Updated docs 2021-07-24 21:30:52 -07:00
Rhet Turnbull
9f8da5c623 Updated CHANGELOG.md [skip ci] 2021-07-24 21:30:03 -07:00
Rhet Turnbull
077d577c98 Fixed {album_seq} and {folder_album_seq} help text 2021-07-24 20:53:59 -07:00
Rhet Turnbull
12f39dbaf5 Added {album_seq} and {folder_album_seq}, #496 2021-07-24 20:41:31 -07:00
Rhet Turnbull
6e9f709279 Updated CHANGELOG.md [skip ci] 2021-07-23 06:14:35 -07:00
Rhet Turnbull
666b6cac33 Updated docs 2021-07-23 06:11:29 -07:00
Rhet Turnbull
e95c096784 Added {id} sequence number template, #154 2021-07-23 05:57:07 -07:00
Rhet Turnbull
745161fbd1 Updated CHANGELOG.md [skip ci] 2021-07-20 06:26:32 -07:00
Rhet Turnbull
8216c33b59 Updated example [skip ci] 2021-07-20 06:25:50 -07:00
Rhet Turnbull
a05e7be14e Updated test data 2021-07-20 06:09:40 -07:00
Rhet Turnbull
e27c40c772 Fixed album sort order for custom sort, #497 2021-07-20 05:41:13 -07:00
Rhet Turnbull
e752f3c7a7 Updated CHANGELOG.md [skip ci] 2021-07-18 21:09:39 -07:00
Rhet Turnbull
6f4cab6721 Updated example [skip ci] 2021-07-18 20:28:56 -07:00
Rhet Turnbull
2d899ef045 Pass dest_path to template function via RenderOptions, enable implementation of #496 2021-07-18 19:42:01 -07:00
Rhet Turnbull
4f17c8fb23 Updated CHANGELOG.md [skip ci] 2021-07-18 19:38:22 -07:00
Rhet Turnbull
173a0fce28 Added RenderOptions to {function} template, #496 2021-07-18 11:56:50 -07:00
Rhet Turnbull
b04ea8174d Added album_sort_order example 2021-07-18 09:07:16 -07:00
Rhet Turnbull
e40ecc45ad Update README.md 2021-07-18 07:57:34 -07:00
Rhet Turnbull
277b1614b9 Updated CHANGELOG.md [skip ci] 2021-07-16 20:09:48 -07:00
Rhet Turnbull
88099de688 Updated README.md [skip ci] 2021-07-16 20:07:50 -07:00
Rhet Turnbull
7d81b94c16 Upgraded osxmetadata to add new extended attributes 2021-07-16 19:45:19 -07:00
Rhet Turnbull
d627cfc4fa Update README.md 2021-07-15 20:25:28 -07:00
Rhet Turnbull
bf208bbe4b Updated tutorial with --regex example [skip ci] 2021-07-07 10:14:15 -07:00
Rhet Turnbull
79ba6f813f Updated CHANGELOG.md [skip ci] 2021-07-07 10:13:48 -07:00
Rhet Turnbull
141c0244e4 Added --selected, closes #489 2021-07-07 06:59:40 -07:00
Rhet Turnbull
7e0276beb7 Updated CHANGELOG.md [skip ci] 2021-07-06 22:03:48 -07:00
Rhet Turnbull
1bf11b0414 Fixed cleanup to delete empty folders, #491 2021-07-06 21:57:43 -07:00
allcontributors[bot]
c23f3fc5e4 docs: add mkirkland4874 as a contributor for example (#492)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-07-06 21:05:34 -07:00
Rhet Turnbull
016297d2ff Added example for {function} template 2021-07-06 21:00:16 -07:00
Rhet Turnbull
aa64283b55 Updated README.md [skip ci], closes #488 2021-07-06 12:17:02 -07:00
Rhet Turnbull
3973c27238 Updated CHANGELOG.md [skip ci] 2021-07-04 12:55:31 -07:00
Rhet Turnbull
2e32d62237 Added test for try/except block in cli export 2021-07-04 12:25:24 -07:00
Rhet Turnbull
d497b94ad5 Re-enabled try/except in cli export 2021-07-04 12:09:13 -07:00
Rhet Turnbull
8c09ae82a4 Updated CHANGELOG.md [skip ci] 2021-07-04 11:49:24 -07:00
Rhet Turnbull
632169f277 Added --preview-if-missing, #446 2021-07-04 10:03:36 -07:00
Rhet Turnbull
675371f0d7 Updated README.md [skip ci] 2021-07-04 08:41:58 -07:00
Rhet Turnbull
7e2d09bf12 Added --preview, #470 2021-07-04 08:39:06 -07:00
Rhet Turnbull
28c681aa96 Refactored export2, #485, #486 2021-07-03 22:50:03 -07:00
Rhet Turnbull
5d39aa92df Update README.md 2021-07-02 19:07:02 -07:00
Rhet Turnbull
b4dbad5e74 Fixed path_derivatives to always return jpeg if photo is a photo 2021-07-02 18:59:01 -07:00
Rhet Turnbull
b1b099257f Updated README.md [skip ci] 2021-07-02 13:27:22 -07:00
Rhet Turnbull
63e8410841 Updated CHANGELOG.md [skip ci] 2021-07-02 13:23:48 -07:00
Rhet Turnbull
2e1c91cd67 Added get_selected() to REPL 2021-07-02 13:19:15 -07:00
Rhet Turnbull
391b0a577b Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-07-02 13:01:28 -07:00
Rhet Turnbull
1d26ac9630 Removed _applescript, #461 2021-07-02 13:01:09 -07:00
Rhet Turnbull
03b4f59549 Removed _applescript, #461 2021-07-02 13:00:40 -07:00
Rhet Turnbull
9aa3ac3640 Updated CHANGELOG.md [skip ci] 2021-07-02 12:48:23 -07:00
Rhet Turnbull
6339e3c70e Updated README.md [skip ci] 2021-07-02 12:43:17 -07:00
Rhet Turnbull
4cc3220287 Fix for path_raw when file is reference, #480 2021-07-02 12:39:41 -07:00
Rhet Turnbull
f32c4f4acd Updated CHANGELOG.md [skip ci] 2021-06-30 22:54:34 -07:00
allcontributors[bot]
aba2ce0923 docs: add jcommisso07 as a contributor for data (#483)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-06-30 22:52:51 -07:00
allcontributors[bot]
c209ceae2e docs: add mkirkland4874 as a contributor for bug (#482)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-06-30 22:49:50 -07:00
Rhet Turnbull
94ac2bd04e Updated README.md [skip ci] 2021-06-30 22:43:56 -07:00
Rhet Turnbull
d1b1d20bcf Fixed --cleanup for empty export, #481 2021-06-30 22:41:03 -07:00
Rhet Turnbull
fb723fb8b7 Fixed raw+jpeg for Monterey 2021-06-29 18:22:48 -07:00
Rhet Turnbull
fc7c61b11b Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-06-29 17:47:36 -07:00
Rhet Turnbull
a73db3a1bb Updated photokit code to work with raw+jpeg, #478 2021-06-29 17:47:21 -07:00
Rhet Turnbull
d2dcbaaec4 Updated photokit code to work with raw+jpeg 2021-06-29 17:46:40 -07:00
Rhet Turnbull
08147e91d9 Alpha support for Monterey/macOS 12 2021-06-29 13:32:36 -07:00
Rhet Turnbull
d034605784 Refactored UTI utils to get ready for Monterey 2021-06-29 09:31:22 -07:00
Rhet Turnbull
64fd852535 Updated README.md [skip ci] 2021-06-23 22:43:36 -07:00
Rhet Turnbull
3fbfc55e84 Fixed deprecation warning 2021-06-23 22:40:23 -07:00
Rhet Turnbull
49317582c4 Bug fix for template functions #477 2021-06-23 22:36:58 -07:00
Rhet Turnbull
5ea01df69b Bug fix 2021-06-21 06:34:56 -07:00
Rhet Turnbull
4a9f8a9ef5 Updated CHANGELOG.md [skip ci] 2021-06-20 18:19:21 -07:00
Rhet Turnbull
49adff1f3b Updated example [skip ci] 2021-06-20 18:11:41 -07:00
Rhet Turnbull
377e165be4 Updated README.md [skip ci] 2021-06-20 17:56:48 -07:00
Rhet Turnbull
07da8031c6 Implemented --query-function, #430 2021-06-20 17:26:07 -07:00
Rhet Turnbull
be363b9727 Added query function [skip ci] 2021-06-20 16:38:51 -07:00
Rhet Turnbull
870a59a2fa Added --location, --no-location, #474 2021-06-20 15:33:03 -07:00
Rhet Turnbull
500cf71f7e Updated CHANGELOG.md [skip ci] 2021-06-20 15:31:44 -07:00
Rhet Turnbull
821e338b75 Fixed function names to work around Click.runner issue 2021-06-20 09:29:23 -07:00
Rhet Turnbull
987c91a9ff Implemented --post-function, #442 2021-06-20 08:52:45 -07:00
Rhet Turnbull
233942c9b6 Added post_function.py 2021-06-20 08:11:10 -07:00
Rhet Turnbull
a0ab64a841 Updated CHANGELOG.md [skip ci] 2021-06-19 21:56:01 -07:00
Rhet Turnbull
0cd8f32893 Bug fix for --download-missing, #456 2021-06-19 21:41:54 -07:00
Rhet Turnbull
904acbc576 Added isort cfg to match black 2021-06-19 18:03:05 -07:00
Rhet Turnbull
37dc023fcb Updated README.md [skip ci] 2021-06-19 18:02:32 -07:00
Rhet Turnbull
876ff17e3f Updated CHANGELOG.md [skip ci] 2021-06-19 17:49:47 -07:00
Rhet Turnbull
130df1a767 Updated README.md [skip ci] 2021-06-19 17:42:03 -07:00
Rhet Turnbull
5d7dea3fc3 Added repl command to CLI; closes #472 2021-06-19 17:31:02 -07:00
Rhet Turnbull
ca8397bc97 Updated CHANGELOG.md [skip ci] 2021-06-19 10:05:21 -07:00
Rhet Turnbull
91023ac8ec Added tutorial, closes #432 2021-06-19 09:59:43 -07:00
Rhet Turnbull
0ad59e9e29 Updated CHANGELOG.md [skip ci] 2021-06-18 22:14:14 -07:00
Rhet Turnbull
42c551de8a Updated help text, #469 2021-06-18 22:01:55 -07:00
Rhet Turnbull
62d49a7138 Updated README.md [skip ci] 2021-06-18 15:09:26 -07:00
Rhet Turnbull
bc5cd93e97 Added error handling for --add-to-album 2021-06-18 15:02:17 -07:00
Rhet Turnbull
7bd1ba8075 Updated CHANGELOG.md [skip ci] 2021-06-18 14:37:34 -07:00
Rhet Turnbull
64bb07a026 Added additional info to error message for --add-to-album 2021-06-18 14:03:59 -07:00
Rhet Turnbull
f1902b7fd4 Updated README.md [skip ci] 2021-06-18 13:10:06 -07:00
Rhet Turnbull
8e3f8fc7d0 Fix for #471 2021-06-18 13:05:37 -07:00
Rhet Turnbull
c588dcf0ba Updated CHANGELOG.md [skip ci] 2021-06-18 09:15:19 -07:00
Rhet Turnbull
fa29f51aeb Added --post-command, implements #443 2021-06-18 09:04:36 -07:00
Rhet Turnbull
ee0b369086 Added matrix for GitHub action OS 2021-06-18 08:49:39 -07:00
Rhet Turnbull
2fc45c2468 Added macos 10.15 and 11 2021-06-18 08:47:34 -07:00
Rhet Turnbull
15d2f45f0c Added macos 10.15 and 11 2021-06-18 08:46:21 -07:00
Rhet Turnbull
df7b73212f Added macos 10.15 and 11 2021-06-18 08:44:49 -07:00
Rhet Turnbull
5143b165b5 Updated CHANGELOG.md [skip ci] 2021-06-14 06:18:04 -07:00
Rhet Turnbull
10097323e5 Fixed missing more-itertools, #466 2021-06-14 05:16:56 -07:00
Rhet Turnbull
c0bd0ffc9f Added {filepath} template field in prep for --post-command and other goodies 2021-06-13 18:40:45 -07:00
Rhet Turnbull
2cdec3fc78 Refactored PhotoTemplate to support pathlib templates 2021-06-13 09:17:55 -07:00
Rhet Turnbull
1a46cdf63c Updated README.md [skip ci] 2021-06-12 22:08:34 -07:00
Rhet Turnbull
83892e096a Added --duplicate flag to find possible duplicates 2021-06-12 18:31:53 -07:00
Rhet Turnbull
6a0b8b4a3f version bump 2021-06-12 07:21:07 -07:00
Rhet Turnbull
5957fde809 Fixed cli status for --only-new and 0 photos to export 2021-06-12 07:20:39 -07:00
Rhet Turnbull
5711545b81 Fixed test for running in GitHub actions 2021-06-12 06:49:31 -07:00
Rhet Turnbull
0758f84dc4 Cleaned up tests, fixed bug in PhotosDB.query 2021-06-11 23:02:48 -07:00
Rhet Turnbull
4b6c35b5f9 Fix for --convert-to-jpeg with use_photos_export, #460 2021-06-09 04:00:05 -07:00
Rhet Turnbull
d7a9ad1d0a Refactored PhotoInfo.export2 2021-06-06 21:02:22 -07:00
Rhet Turnbull
bb96c35672 Updated test UUIDs 2021-06-06 13:58:44 -07:00
Rhet Turnbull
0880e5b9e8 added pyinstaller 2021-06-05 22:07:31 -07:00
Rhet Turnbull
87af23d98c Added python 3.9 to tests 2021-06-05 11:29:16 -07:00
Rhet Turnbull
61943d051b Updated dependencies to minimize pyobjc requirements 2021-06-05 11:25:41 -07:00
Rhet Turnbull
ef1daf5922 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-06-05 11:25:17 -07:00
Rhet Turnbull
bb98cff608 Test library update 2021-06-05 11:25:06 -07:00
Rhet Turnbull
620ba9ce03 Added dev_requirements.txt 2021-06-05 11:23:33 -07:00
Rhet Turnbull
86d94ad310 Added dev_requirements.txt 2021-06-05 11:22:37 -07:00
Rhet Turnbull
b8cf21ae82 Added venv [skip ci] 2021-06-04 07:01:33 -07:00
Rhet Turnbull
7accfdb066 Added PhotoInfo.duplicates 2021-06-01 17:32:43 -07:00
Rhet Turnbull
99f4394f8e Added CONTRIBUTING.md 2021-05-30 08:22:02 -07:00
Rhet Turnbull
748aed96cb Updated CHANGELOG.md [skip ci] 2021-05-29 09:08:46 -07:00
Rhet Turnbull
9161739ee6 Updated README.md [skip ci] 2021-05-29 09:05:05 -07:00
Rhet Turnbull
71cf8be94a Updated README.rst for PyPI 2021-05-29 09:03:35 -07:00
Rhet Turnbull
b48133cd83 Fix for #455 2021-05-29 08:51:58 -07:00
Rhet Turnbull
6b5a57fae9 Updated CHANGELOG.md [skip ci] 2021-05-28 09:09:26 -07:00
Rhet Turnbull
24ccf798c2 Updated README.md [skip ci] 2021-05-28 09:05:00 -07:00
Rhet Turnbull
a298772515 Updated tested versions to 11.3 2021-05-28 09:02:37 -07:00
Rhet Turnbull
2d68594b78 Fixes for #454 2021-05-28 08:48:21 -07:00
Rhet Turnbull
b026147c9a Updated README.md [skip ci] 2021-05-23 14:26:01 -07:00
Rhet Turnbull
186a5b77d0 Fixed bug in imageconverter exception handling, closes #440 2021-05-23 14:21:13 -07:00
Rhet Turnbull
518f855a9b PhotoInfo.exiftool now returns ExifToolCaching, closes #450 2021-05-23 14:14:22 -07:00
allcontributors[bot]
0d2067787c docs: add kaduskj as a contributor (#453)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-05-23 12:33:08 -07:00
Rhet Turnbull
0448a42329 Updated CHANGELOG.md [skip ci] 2021-05-23 12:30:10 -07:00
Rhet Turnbull
a724e15dd6 Updated README.md [skip ci] 2021-05-23 12:26:51 -07:00
Rhet Turnbull
be8fe9d059 Bug fix for #452 2021-05-23 12:01:36 -07:00
Rhet Turnbull
bd6656107b Updated CHANGELOG.md [skip ci] 2021-05-23 09:46:29 -07:00
Rhet Turnbull
a54e051d41 Updated README.md 2021-05-23 09:37:25 -07:00
Rhet Turnbull
7cde52bf9b Fixed #451, path_derivatives for Photos version <= 4 2021-05-23 09:34:40 -07:00
Rhet Turnbull
96037508c1 README.md update [skip ci] 2021-05-22 10:31:52 -07:00
Rhet Turnbull
9f2268fb2b Cleanup exiftool processes when exiting, #449 2021-05-19 06:16:52 -07:00
Rhet Turnbull
df167c00eb Added osxphotos related template fields, partial fix for #444 2021-05-15 14:46:35 -07:00
Rhet Turnbull
e8f9cda0c6 Update README.md 2021-05-09 18:21:38 -07:00
Rhet Turnbull
d4a951f547 Updated CHANGELOG.md, [skip ci] 2021-05-09 18:17:23 -07:00
Rhet Turnbull
f24e4a7e3c Updated path_derivatives to return results in sorted order (largest to smallest) 2021-05-09 17:52:24 -07:00
Rhet Turnbull
98b84c17f1 Updated CHANGELOG.md, [skip ci] 2021-05-08 23:18:04 -07:00
Rhet Turnbull
78c411a643 Updated docs 2021-05-08 23:10:47 -07:00
Rhet Turnbull
6bdf15b41e Added path_derivatives for Photos <= 4 2021-05-08 22:36:11 -07:00
Rhet Turnbull
a0fcec2a7a Fixed typo in README 2021-05-08 16:08:34 -07:00
Rhet Turnbull
63834ab8ab Added path_derivatives for Photos 5, issue #50 2021-05-08 15:11:59 -07:00
Rhet Turnbull
b23cfa32bb Updated docs 2021-05-07 23:00:17 -07:00
Rhet Turnbull
0e22ce54ab Added date_added for Photos 4, #439 2021-05-07 22:56:03 -07:00
Rhet Turnbull
0f41588701 Added date_added, #439 2021-05-05 06:50:41 -07:00
Rhet Turnbull
442b542794 Added --add-to-album example to README 2021-05-02 13:51:48 -07:00
Rhet Turnbull
88fae81b19 Updated CHANGELOG.md, [skip ci] 2021-05-02 13:51:26 -07:00
Rhet Turnbull
c4fec00f67 Updated docs [skip ci] 2021-05-02 09:15:13 -07:00
Rhet Turnbull
9a0cc3e8fa Added --add-to-album to query 2021-05-02 08:35:37 -07:00
Rhet Turnbull
3ed2362fe3 Updated CHANGELOG.md, [skip ci] 2021-05-01 21:26:49 -07:00
946 changed files with 34279 additions and 16615 deletions

View File

@@ -213,6 +213,119 @@
"example",
"ideas"
]
},
{
"login": "kaduskj",
"name": "kaduskj",
"avatar_url": "https://avatars.githubusercontent.com/u/983067?v=4",
"profile": "https://github.com/kaduskj",
"contributions": [
"bug"
]
},
{
"login": "mkirkland4874",
"name": "mkirkland4874",
"avatar_url": "https://avatars.githubusercontent.com/u/36466711?v=4",
"profile": "https://github.com/mkirkland4874",
"contributions": [
"bug",
"example"
]
},
{
"login": "jcommisso07",
"name": "Joseph Commisso",
"avatar_url": "https://avatars.githubusercontent.com/u/3111054?v=4",
"profile": "https://github.com/jcommisso07",
"contributions": [
"data"
]
},
{
"login": "dssinger",
"name": "David Singer",
"avatar_url": "https://avatars.githubusercontent.com/u/1817903?v=4",
"profile": "https://github.com/dssinger",
"contributions": [
"bug"
]
},
{
"login": "oPromessa",
"name": "oPromessa",
"avatar_url": "https://avatars.githubusercontent.com/u/21261491?v=4",
"profile": "https://github.com/oPromessa",
"contributions": [
"bug",
"ideas",
"test"
]
},
{
"login": "spencerc99",
"name": "Spencer Chang",
"avatar_url": "https://avatars.githubusercontent.com/u/14796580?v=4",
"profile": "http://spencerchang.me",
"contributions": [
"bug"
]
},
{
"login": "dgleich",
"name": "David Gleich",
"avatar_url": "https://avatars.githubusercontent.com/u/33995?v=4",
"profile": "https://www.cs.purdue.edu/homes/dgleich",
"contributions": [
"code"
]
},
{
"login": "alandefreitas",
"name": "Alan de Freitas",
"avatar_url": "https://avatars.githubusercontent.com/u/5369819?v=4",
"profile": "https://alandefreitas.github.io/alandefreitas/",
"contributions": [
"bug"
]
},
{
"login": "hyfen",
"name": "Andrew Louis",
"avatar_url": "https://avatars.githubusercontent.com/u/6291?v=4",
"profile": "https://hyfen.net",
"contributions": [
"doc",
"code"
]
},
{
"login": "neebah",
"name": "neebah",
"avatar_url": "https://avatars.githubusercontent.com/u/71442026?v=4",
"profile": "https://github.com/neebah",
"contributions": [
"bug"
]
},
{
"login": "ahti123",
"name": "Ahti Liin",
"avatar_url": "https://avatars.githubusercontent.com/u/22232632?v=4",
"profile": "https://github.com/ahti123",
"contributions": [
"code",
"bug"
]
},
{
"login": "xwu64",
"name": "Xiaoliang Wu",
"avatar_url": "https://avatars.githubusercontent.com/u/10580396?v=4",
"profile": "https://github.com/xwu64",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
** Before submitting a bug report, please ensure you are running the most recent version of osxphotos and that the bug is reproducible on the latest version **
- If you installed with pipx: `pipx upgrade osxphotos`
- If you installed with pip: `pip install --upgrade osxphotos`
- If you installed the pre-built binary, download and install the latest [release](https://github.com/RhetTbull/osxphotos/releases)
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. What' the full command line you used with osxphotos?
2. What was the error output?
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. which version of macOS]
- osxphotos version (`osxphotos --version`)
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -4,13 +4,13 @@ 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:
python-version: [3.7, 3.8]
os: [macos-10.15]
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v1
@@ -21,6 +21,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r dev_requirements.txt
pip install -r requirements.txt
# - name: Lint with flake8
# run: |
@@ -31,6 +32,4 @@ jobs:
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pip install pytest
pip install pytest-mock
python -m pytest tests/

2
.gitignore vendored
View File

@@ -15,3 +15,5 @@ osxphotos.egg-info/
cli.spec
*.pyc
docsrc/_build/
venv/
.python-version

3
.isort.cfg Normal file
View File

@@ -0,0 +1,3 @@
[settings]
profile=black
multi_line_output=3

File diff suppressed because it is too large Load Diff

15
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,15 @@
# Contributing
Contributions of all kinds are welcome! You don't need to know python to contribute to this project. For example, documentation updates are just as welcome as code!
Please explore open [issues](https://github.com/RhetTbull/osxphotos/issues), [discussions](https://github.com/RhetTbull/osxphotos/discussions), and the project [wiki](https://github.com/RhetTbull/osxphotos/wiki) to learn more about the project.
If you want to contribute source code, I recommend you explore the [wiki](https://github.com/RhetTbull/osxphotos/wiki/Structure-of-the-code) to learn about the source structure first.
See the [README.md](tests/README.md) in the tests directory before running any tests.
## Code of Conduct
Be nice to each other. Treat everyone with dignity and respect.
Abusive behavior of any kind will not be tolerated here.

View File

@@ -1,5 +1,7 @@
include README.md
include README.rst
include osxphotos/templates/*
include osxphotos/*.json
include osxphotos/*.md
include osxphotos/phototemplate.tx
include osxphotos/phototemplate.md
include osxphotos/queries/*
include osxphotos/templates/*
include README.md
include README.rst

1017
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,9 @@ You can also easily export both the original and edited photos.
Supported operating systems
---------------------------
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Catalina (10.15.7).
Beta support for macOS Big Sur (10.16.01/11.01).
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.
@@ -109,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>``
@@ -146,6 +149,11 @@ export default library using 'country name/year' as output directory (but use "N
``osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose``
find all videos larger than 200MB and add them to Photos album "Big Videos" creating the album if necessary
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``osxphotos query --only-movies --min-size 200MB --add-to-album "Big Videos"``
Example uses of the package
---------------------------

View File

@@ -3,9 +3,10 @@
# script to help build osxphotos release
# this is unique to my own dev setup
activate osxphotos
# source venv/bin/activate
rm -rf dist; rm -rf build
python3 utils/update_readme.py
(cd docsrc && make github && make pdf)
python3 setup.py sdist bdist_wheel
./make_cli_exe.sh
# python3 setup.py sdist bdist_wheel
python3 -m build
./make_cli_exe.sh

12
dev_requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
build
m2r2
pdbpp
pyinstaller==4.4
pytest-mock
pytest==6.2.4
Sphinx
sphinx_click
sphinx_rtd_theme
sphinxcontrib-programoutput
twine
wheel

View File

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

View File

@@ -5,10 +5,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.42.14 documentation</title>
<link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script>
<title>Overview: module code &#8212; osxphotos 0.46.0 documentation</title>
<link rel="stylesheet" type="text/css" href="../_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="../_static/alabaster.css" />
<script data-url_root="../" id="documentation_options" src="../_static/documentation_options.js"></script>
<script src="../_static/jquery.js"></script>
<script src="../_static/underscore.js"></script>
<script src="../_static/doctools.js"></script>
@@ -31,7 +31,7 @@
<div class="body" role="main">
<h1>All modules for which code is available</h1>
<ul><li><a href="osxphotos/photoinfo/photoinfo.html">osxphotos.photoinfo.photoinfo</a></li>
<ul><li><a href="osxphotos/photoinfo.html">osxphotos.photoinfo</a></li>
<li><a href="osxphotos/photosdb/photosdb.html">osxphotos.photosdb.photosdb</a></li>
</ul>
@@ -67,7 +67,7 @@
<h3 id="searchlabel">Quick search</h3>
<div class="searchformwrapper">
<form class="search" action="../search.html" method="get">
<input type="text" name="q" aria-labelledby="searchlabel" />
<input type="text" name="q" aria-labelledby="searchlabel" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
<input type="submit" value="Go" />
</form>
</div>
@@ -89,7 +89,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,4 +3,6 @@ osxphotos command line interface (CLI)
.. click:: osxphotos.cli:cli
:prog: osxphotos
:nested: full
:nested: full
.. program-output:: python3 -m osxphotos --help

111
docs/_static/basic.css vendored
View File

@@ -130,7 +130,7 @@ ul.search li a {
font-weight: bold;
}
ul.search li div.context {
ul.search li p.context {
color: #888;
margin: 2px 0 0 30px;
text-align: left;
@@ -277,25 +277,25 @@ p.rubric {
font-weight: bold;
}
img.align-left, .figure.align-left, object.align-left {
img.align-left, figure.align-left, .figure.align-left, object.align-left {
clear: left;
float: left;
margin-right: 1em;
}
img.align-right, .figure.align-right, object.align-right {
img.align-right, figure.align-right, .figure.align-right, object.align-right {
clear: right;
float: right;
margin-left: 1em;
}
img.align-center, .figure.align-center, object.align-center {
img.align-center, figure.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
img.align-default, .figure.align-default {
img.align-default, figure.align-default, .figure.align-default {
display: block;
margin-left: auto;
margin-right: auto;
@@ -319,7 +319,8 @@ img.align-default, .figure.align-default {
/* -- sidebars -------------------------------------------------------------- */
div.sidebar {
div.sidebar,
aside.sidebar {
margin: 0 0 0.5em 1em;
border: 1px solid #ddb;
padding: 7px;
@@ -377,12 +378,14 @@ div.body p.centered {
/* -- content of sidebars/topics/admonitions -------------------------------- */
div.sidebar > :last-child,
aside.sidebar > :last-child,
div.topic > :last-child,
div.admonition > :last-child {
margin-bottom: 0;
}
div.sidebar::after,
aside.sidebar::after,
div.topic::after,
div.admonition::after,
blockquote::after {
@@ -455,20 +458,22 @@ td > :last-child {
/* -- figures --------------------------------------------------------------- */
div.figure {
div.figure, figure {
margin: 0.5em;
padding: 0.5em;
}
div.figure p.caption {
div.figure p.caption, figcaption {
padding: 0.3em;
}
div.figure p.caption span.caption-number {
div.figure p.caption span.caption-number,
figcaption span.caption-number {
font-style: italic;
}
div.figure p.caption span.caption-text {
div.figure p.caption span.caption-text,
figcaption span.caption-text {
}
/* -- field list styles ----------------------------------------------------- */
@@ -503,6 +508,63 @@ table.hlist td {
vertical-align: top;
}
/* -- object description styles --------------------------------------------- */
.sig {
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
}
.sig-name, code.descname {
background-color: transparent;
font-weight: bold;
}
.sig-name {
font-size: 1.1em;
}
code.descname {
font-size: 1.2em;
}
.sig-prename, code.descclassname {
background-color: transparent;
}
.optional {
font-size: 1.3em;
}
.sig-paren {
font-size: larger;
}
.sig-param.n {
font-style: italic;
}
/* C++ specific styling */
.sig-inline.c-texpr,
.sig-inline.cpp-texpr {
font-family: unset;
}
.sig.c .k, .sig.c .kt,
.sig.cpp .k, .sig.cpp .kt {
color: #0033B3;
}
.sig.c .m,
.sig.cpp .m {
color: #1750EB;
}
.sig.c .s, .sig.c .sc,
.sig.cpp .s, .sig.cpp .sc {
color: #067D17;
}
/* -- other body styles ----------------------------------------------------- */
@@ -629,14 +691,6 @@ dl.glossary dt {
font-size: 1.1em;
}
.optional {
font-size: 1.3em;
}
.sig-paren {
font-size: larger;
}
.versionmodified {
font-style: italic;
}
@@ -677,8 +731,9 @@ dl.glossary dt {
.classifier:before {
font-style: normal;
margin: 0.5em;
margin: 0 0.5em;
content: ":";
display: inline-block;
}
abbr, acronym {
@@ -765,8 +820,12 @@ div.code-block-caption code {
table.highlighttable td.linenos,
span.linenos,
div.doctest > div.highlight span.gp { /* gp: Generic.Prompt */
user-select: none;
div.highlight span.gp { /* gp: Generic.Prompt */
user-select: none;
-webkit-user-select: text; /* Safari fallback only */
-webkit-user-select: none; /* Chrome/Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE10+ */
}
div.code-block-caption span.caption-number {
@@ -781,16 +840,6 @@ div.literal-block-wrapper {
margin: 1em 0;
}
code.descname {
background-color: transparent;
font-weight: bold;
font-size: 1.2em;
}
code.descclassname {
background-color: transparent;
}
code.xref, a code {
background-color: transparent;
font-weight: bold;

View File

@@ -301,12 +301,14 @@ var Documentation = {
window.location.href = prevHref;
return false;
}
break;
case 39: // right
var nextHref = $('link[rel="next"]').prop('href');
if (nextHref) {
window.location.href = nextHref;
return false;
}
break;
}
}
});

View File

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

View File

@@ -282,7 +282,10 @@ var Search = {
complete: function(jqxhr, textstatus) {
var data = jqxhr.responseText;
if (data !== '' && data !== undefined) {
listItem.append(Search.makeSearchSummary(data, searchterms, hlterms));
var summary = Search.makeSearchSummary(data, searchterms, hlterms);
if (summary) {
listItem.append(summary);
}
}
Search.output.append(listItem);
setTimeout(function() {
@@ -325,7 +328,9 @@ var Search = {
var results = [];
for (var prefix in objects) {
for (var name in objects[prefix]) {
for (var iMatch = 0; iMatch != objects[prefix].length; ++iMatch) {
var match = objects[prefix][iMatch];
var name = match[4];
var fullname = (prefix ? prefix + '.' : '') + name;
var fullnameLower = fullname.toLowerCase()
if (fullnameLower.indexOf(object) > -1) {
@@ -339,7 +344,6 @@ var Search = {
} else if (parts[parts.length - 1].indexOf(object) > -1) {
score += Scorer.objPartialMatch;
}
var match = objects[prefix][name];
var objname = objnames[match[1]][2];
var title = titles[match[0]];
// If more than one term searched for, we require other words to be
@@ -498,6 +502,9 @@ var Search = {
*/
makeSearchSummary : function(htmlText, keywords, hlwords) {
var text = Search.htmlToText(htmlText);
if (text == "") {
return null;
}
var textLower = text.toLowerCase();
var start = 0;
$.each(keywords, function() {
@@ -509,7 +516,7 @@ var Search = {
var excerpt = ((start > 0) ? '...' : '') +
$.trim(text.substr(start, 240)) +
((start + 240 - text.length) ? '...' : '');
var rv = $('<div class="context"></div>').text(excerpt);
var rv = $('<p class="context"></p>').text(excerpt);
$.each(hlwords, function() {
rv = rv.highlightText(this, 'highlighted');
});

2042
docs/_static/underscore-1.13.1.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,12 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.14 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.46.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
@@ -31,30 +32,30 @@
<div class="body" role="main">
<div class="section" id="welcome-to-osxphotos-s-documentation">
<section id="welcome-to-osxphotos-s-documentation">
<h1>Welcome to osxphotoss documentation!<a class="headerlink" href="#welcome-to-osxphotos-s-documentation" title="Permalink to this headline"></a></h1>
</div>
<div class="section" id="osxphotos">
</section>
<section id="osxphotos">
<h1>OSXPhotos<a class="headerlink" href="#osxphotos" title="Permalink to this headline"></a></h1>
<div class="section" id="what-is-osxphotos">
<section id="what-is-osxphotos">
<h2>What is osxphotos?<a class="headerlink" href="#what-is-osxphotos" title="Permalink to this headline"></a></h2>
<p>OSXPhotos provides both the ability to interact with and query Apples Photos.app library on macOS directly from your python code
as well as a very flexible command line interface (CLI) app for exporting photos.
You can query the Photos library database for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc.
You can also easily export both the original and edited photos.</p>
</div>
<div class="section" id="supported-operating-systems">
</section>
<section id="supported-operating-systems">
<h2>Supported operating systems<a class="headerlink" href="#supported-operating-systems" title="Permalink to this headline"></a></h2>
<p>Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Catalina (10.15.7).
Beta support for macOS Big Sur (10.16.01/11.01).</p>
<p>Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Big Sur (11.3).</p>
<p>If you have access to macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please contact me via GitHub.</p>
<p>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.</p>
<p>Requires python &gt;= <code class="docutils literal notranslate"><span class="pre">3.7</span></code>.</p>
</div>
<div class="section" id="installation">
</section>
<section id="installation">
<h2>Installation<a class="headerlink" href="#installation" title="Permalink to this headline"></a></h2>
<p>If you are new to python and just want to use the command line application, I recommend you to install using pipx. See other advanced options below.</p>
<div class="section" id="installation-using-pipx">
<section id="installation-using-pipx">
<h3>Installation using pipx<a class="headerlink" href="#installation-using-pipx" title="Permalink to this headline"></a></h3>
<p>If you arent familiar with installing python applications, I recommend you install <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code> with <a class="reference external" href="https://github.com/pipxproject/pipx">pipx</a>. If you use <code class="docutils literal notranslate"><span class="pre">pipx</span></code>, you will not need to create a virtual environment as <code class="docutils literal notranslate"><span class="pre">pipx</span></code> takes care of this. The easiest way to do this on a Mac is to use <a class="reference external" href="https://brew.sh/">homebrew</a>:</p>
<ul class="simple">
@@ -64,15 +65,15 @@ E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine
<li><p>Then type this: <code class="docutils literal notranslate"><span class="pre">pipx</span> <span class="pre">install</span> <span class="pre">osxphotos</span></code></p></li>
<li><p>Now you should be able to run <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code> by typing: <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code></p></li>
</ul>
</div>
<div class="section" id="installation-using-pip">
</section>
<section id="installation-using-pip">
<h3>Installation using pip<a class="headerlink" href="#installation-using-pip" title="Permalink to this headline"></a></h3>
<p>You can also install directly from <a class="reference external" href="https://pypi.org/project/osxphotos/">pypi</a>:</p>
<div class="highlight-default notranslate"><div class="highlight"><pre><span></span><span class="n">pip</span> <span class="n">install</span> <span class="n">osxphotos</span>
</pre></div>
</div>
</div>
<div class="section" id="installation-from-git-repository">
</section>
<section id="installation-from-git-repository">
<h3>Installation from git repository<a class="headerlink" href="#installation-from-git-repository" title="Permalink to this headline"></a></h3>
<p>OSXPhotos uses setuptools, thus simply run:</p>
<div class="highlight-default notranslate"><div class="highlight"><pre><span></span><span class="n">git</span> <span class="n">clone</span> <span class="n">https</span><span class="p">:</span><span class="o">//</span><span class="n">github</span><span class="o">.</span><span class="n">com</span><span class="o">/</span><span class="n">RhetTbull</span><span class="o">/</span><span class="n">osxphotos</span><span class="o">.</span><span class="n">git</span>
@@ -87,9 +88,9 @@ I recommend you install the latest version from <a class="reference external" hr
libraries. If you just want to use the command line utility, you can download a pre-built executable of the latest
<a class="reference external" href="https://github.com/RhetTbull/osxphotos/releases">release</a> or you can install via <code class="docutils literal notranslate"><span class="pre">pip</span></code> which also installs the command line app.
If you arent comfortable with running python on your Mac, start with the pre-built executable or <code class="docutils literal notranslate"><span class="pre">pipx</span></code> as described above.</p>
</div>
</div>
<div class="section" id="command-line-usage">
</section>
</section>
<section id="command-line-usage">
<h2>Command Line Usage<a class="headerlink" href="#command-line-usage" title="Permalink to this headline"></a></h2>
<p>This package will install a command line utility called <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code> that allows you to query the Photos database and export photos.
Alternatively, you can also run the command line utility like this: <code class="docutils literal notranslate"><span class="pre">python3</span> <span class="pre">-m</span> <span class="pre">osxphotos</span></code></p>
@@ -122,37 +123,43 @@ Alternatively, you can also run the command line utility like this: <code class=
<span class="n">persons</span> <span class="n">Print</span> <span class="n">out</span> <span class="n">persons</span> <span class="p">(</span><span class="n">faces</span><span class="p">)</span> <span class="n">found</span> <span class="ow">in</span> <span class="n">the</span> <span class="n">Photos</span> <span class="n">library</span><span class="o">.</span>
<span class="n">places</span> <span class="n">Print</span> <span class="n">out</span> <span class="n">places</span> <span class="n">found</span> <span class="ow">in</span> <span class="n">the</span> <span class="n">Photos</span> <span class="n">library</span><span class="o">.</span>
<span class="n">query</span> <span class="n">Query</span> <span class="n">the</span> <span class="n">Photos</span> <span class="n">database</span> <span class="n">using</span> <span class="mi">1</span> <span class="ow">or</span> <span class="n">more</span> <span class="n">search</span> <span class="n">options</span><span class="p">;</span> <span class="k">if</span><span class="o">...</span>
<span class="n">repl</span> <span class="n">Run</span> <span class="n">interactive</span> <span class="n">osxphotos</span> <span class="n">shell</span>
<span class="n">tutorial</span> <span class="n">Display</span> <span class="n">osxphotos</span> <span class="n">tutorial</span><span class="o">.</span>
</pre></div>
</div>
<p>To get help on a specific command, use <code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">help</span> <span class="pre">&lt;command_name&gt;</span></code></p>
<div class="section" id="command-line-examples">
<section id="command-line-examples">
<h3>Command line examples<a class="headerlink" href="#command-line-examples" title="Permalink to this headline"></a></h3>
<div class="section" id="export-all-photos-to-desktop-export-group-in-folders-by-date-created">
<section id="export-all-photos-to-desktop-export-group-in-folders-by-date-created">
<h4>export all photos to ~/Desktop/export group in folders by date created<a class="headerlink" href="#export-all-photos-to-desktop-export-group-in-folders-by-date-created" title="Permalink to this headline"></a></h4>
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">--export-by-date</span> <span class="pre">~/Pictures/Photos\</span> <span class="pre">Library.photoslibrary</span> <span class="pre">~/Desktop/export</span></code></p>
<p><strong>Note</strong>: Photos library/database path can also be specified using <code class="docutils literal notranslate"><span class="pre">--db</span></code> option:</p>
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">--export-by-date</span> <span class="pre">--db</span> <span class="pre">~/Pictures/Photos\</span> <span class="pre">Library.photoslibrary</span> <span class="pre">~/Desktop/export</span></code></p>
</div>
<div class="section" id="find-all-photos-with-keyword-kids-and-output-results-to-json-file-named-results-json">
</section>
<section id="find-all-photos-with-keyword-kids-and-output-results-to-json-file-named-results-json">
<h4>find all photos with keyword “Kids” and output results to json file named results.json:<a class="headerlink" href="#find-all-photos-with-keyword-kids-and-output-results-to-json-file-named-results-json" title="Permalink to this headline"></a></h4>
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">query</span> <span class="pre">--keyword</span> <span class="pre">Kids</span> <span class="pre">--json</span> <span class="pre">~/Pictures/Photos\</span> <span class="pre">Library.photoslibrary</span> <span class="pre">&gt;results.json</span></code></p>
</div>
<div class="section" id="export-photos-to-file-structure-based-on-4-digit-year-and-full-name-of-month-of-photo-s-creation-date">
</section>
<section id="export-photos-to-file-structure-based-on-4-digit-year-and-full-name-of-month-of-photo-s-creation-date">
<h4>export photos to file structure based on 4-digit year and full name of month of photos creation date:<a class="headerlink" href="#export-photos-to-file-structure-based-on-4-digit-year-and-full-name-of-month-of-photo-s-creation-date" title="Permalink to this headline"></a></h4>
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">~/Desktop/export</span> <span class="pre">--directory</span> <span class="pre">&quot;{created.year}/{created.month}&quot;</span></code></p>
<p>(by default, it will attempt to use the system library)</p>
</div>
<div class="section" id="export-photos-to-file-structure-based-on-4-digit-year-of-photo-s-creation-date-and-add-keywords-for-media-type-and-labels-labels-are-only-awailable-on-photos-5-and-higher">
</section>
<section id="export-photos-to-file-structure-based-on-4-digit-year-of-photo-s-creation-date-and-add-keywords-for-media-type-and-labels-labels-are-only-awailable-on-photos-5-and-higher">
<h4>export photos to file structure based on 4-digit year of photos creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):<a class="headerlink" href="#export-photos-to-file-structure-based-on-4-digit-year-of-photo-s-creation-date-and-add-keywords-for-media-type-and-labels-labels-are-only-awailable-on-photos-5-and-higher" title="Permalink to this headline"></a></h4>
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">~/Desktop/export</span> <span class="pre">--directory</span> <span class="pre">&quot;{created.year}&quot;</span> <span class="pre">--keyword-template</span> <span class="pre">&quot;{label}&quot;</span> <span class="pre">--keyword-template</span> <span class="pre">&quot;{media_type}&quot;</span></code></p>
</div>
<div class="section" id="export-default-library-using-country-name-year-as-output-directory-but-use-nocountry-year-if-country-not-specified-add-persons-album-names-and-year-as-keywords-write-exif-metadata-to-files-when-exporting-update-only-changed-files-print-verbose-ouput">
</section>
<section id="export-default-library-using-country-name-year-as-output-directory-but-use-nocountry-year-if-country-not-specified-add-persons-album-names-and-year-as-keywords-write-exif-metadata-to-files-when-exporting-update-only-changed-files-print-verbose-ouput">
<h4>export default library using country name/year as output directory (but use “NoCountry/year” if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput<a class="headerlink" href="#export-default-library-using-country-name-year-as-output-directory-but-use-nocountry-year-if-country-not-specified-add-persons-album-names-and-year-as-keywords-write-exif-metadata-to-files-when-exporting-update-only-changed-files-print-verbose-ouput" title="Permalink to this headline"></a></h4>
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">~/Desktop/export</span> <span class="pre">--directory</span> <span class="pre">&quot;{place.name.country,NoCountry}/{created.year}&quot;</span>&#160; <span class="pre">--person-keyword</span> <span class="pre">--album-keyword</span> <span class="pre">--keyword-template</span> <span class="pre">&quot;{created.year}&quot;</span> <span class="pre">--exiftool</span> <span class="pre">--update</span> <span class="pre">--verbose</span></code></p>
</div>
</div>
</div>
<div class="section" id="example-uses-of-the-package">
</section>
<section id="find-all-videos-larger-than-200mb-and-add-them-to-photos-album-big-videos-creating-the-album-if-necessary">
<h4>find all videos larger than 200MB and add them to Photos album “Big Videos” creating the album if necessary<a class="headerlink" href="#find-all-videos-larger-than-200mb-and-add-them-to-photos-album-big-videos-creating-the-album-if-necessary" title="Permalink to this headline"></a></h4>
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">query</span> <span class="pre">--only-movies</span> <span class="pre">--min-size</span> <span class="pre">200MB</span> <span class="pre">--add-to-album</span> <span class="pre">&quot;Big</span> <span class="pre">Videos&quot;</span></code></p>
</section>
</section>
</section>
<section id="example-uses-of-the-package">
<h2>Example uses of the package<a class="headerlink" href="#example-uses-of-the-package" title="Permalink to this headline"></a></h2>
<div class="highlight-python notranslate"><div class="highlight"><pre><span></span><span class="sd">&quot;&quot;&quot; Simple usage of the package &quot;&quot;&quot;</span>
<span class="kn">import</span> <span class="nn">osxphotos</span>
@@ -268,8 +275,8 @@ Alternatively, you can also run the command line utility like this: <code class=
<span class="n">export</span><span class="p">()</span> <span class="c1"># pylint: disable=no-value-for-parameter</span>
</pre></div>
</div>
</div>
<div class="section" id="package-interface">
</section>
<section id="package-interface">
<h2>Package Interface<a class="headerlink" href="#package-interface" title="Permalink to this headline"></a></h2>
<p>Reference full documentation on <a class="reference external" href="https://github.com/RhetTbull/osxphotos/blob/master/README.md">GitHub</a></p>
<div class="toctree-wrapper compound">
@@ -278,16 +285,24 @@ Alternatively, you can also run the command line utility like this: <code class=
<li class="toctree-l2"><a class="reference internal" href="cli.html#osxphotos">osxphotos</a><ul>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-about">about</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-albums">albums</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-diff">diff</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-dump">dump</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-export">export</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-help">help</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-info">info</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-install">install</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-keywords">keywords</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-labels">labels</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-list">list</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-persons">persons</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-places">places</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-query">query</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-repl">repl</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-run">run</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-snap">snap</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-tutorial">tutorial</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-uninstall">uninstall</a></li>
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-uuid">uuid</a></li>
</ul>
</li>
</ul>
@@ -298,16 +313,16 @@ Alternatively, you can also run the command line utility like this: <code class=
</li>
</ul>
</div>
</div>
</div>
<div class="section" id="indices-and-tables">
</section>
</section>
<section id="indices-and-tables">
<h1>Indices and tables<a class="headerlink" href="#indices-and-tables" title="Permalink to this headline"></a></h1>
<ul class="simple">
<li><p><a class="reference internal" href="genindex.html"><span class="std std-ref">Index</span></a></p></li>
<li><p><a class="reference internal" href="py-modindex.html"><span class="std std-ref">Module Index</span></a></p></li>
<li><p><a class="reference internal" href="search.html"><span class="std std-ref">Search Page</span></a></p></li>
</ul>
</div>
</section>
</div>
@@ -343,7 +358,7 @@ Alternatively, you can also run the command line utility like this: <code class=
<h3 id="searchlabel">Quick search</h3>
<div class="searchformwrapper">
<form class="search" action="search.html" method="get">
<input type="text" name="q" aria-labelledby="searchlabel" />
<input type="text" name="q" aria-labelledby="searchlabel" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
<input type="submit" value="Go" />
</form>
</div>
@@ -365,7 +380,7 @@ Alternatively, you can also run the command line utility like this: <code class=
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

View File

@@ -4,11 +4,12 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos &#8212; osxphotos 0.42.14 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos &#8212; osxphotos 0.46.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
@@ -30,11 +31,11 @@
<div class="body" role="main">
<div class="section" id="osxphotos">
<section id="osxphotos">
<h1>osxphotos<a class="headerlink" href="#osxphotos" title="Permalink to this headline"></a></h1>
<div class="toctree-wrapper compound">
</div>
</div>
</section>
</div>
@@ -69,7 +70,7 @@
<h3 id="searchlabel">Quick search</h3>
<div class="searchformwrapper">
<form class="search" action="search.html" method="get">
<input type="text" name="q" aria-labelledby="searchlabel" />
<input type="text" name="q" aria-labelledby="searchlabel" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
<input type="submit" value="Go" />
</form>
</div>
@@ -91,7 +92,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -5,11 +5,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.42.14 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<title>Search &#8212; osxphotos 0.46.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
@@ -37,26 +37,35 @@
<div class="body" role="main">
<h1 id="search-documentation">Search</h1>
<div id="fallback" class="admonition warning">
<script>$('#fallback').hide();</script>
<noscript>
<div class="admonition warning">
<p>
Please activate JavaScript to enable the search
functionality.
</p>
</div>
</noscript>
<p>
Searching for multiple words only shows matches that contain
all words.
</p>
<form action="" method="get">
<input type="text" name="q" aria-labelledby="search-documentation" value="" />
<input type="text" name="q" aria-labelledby="search-documentation" value="" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
<input type="submit" value="search" />
<span id="search-progress" style="padding-left: 10px"></span>
</form>
<div id="search-results">
</div>
</div>
@@ -102,7 +111,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -3,4 +3,6 @@ osxphotos command line interface (CLI)
.. click:: osxphotos.cli:cli
:prog: osxphotos
:nested: full
:nested: full
.. program-output:: python3 -m osxphotos --help

View File

@@ -16,7 +16,7 @@ import sys
import sphinx_rtd_theme
sys.path.insert(0, os.path.abspath(".."))
sys.path.insert(0, os.path.abspath("../.."))
# -- Project information -----------------------------------------------------

View File

@@ -0,0 +1,112 @@
""" Example function for use with osxphotos export --post-function option showing how to record album sort order """
import os
import pathlib
from typing import Optional
from osxphotos import ExportResults, PhotoInfo
from osxphotos.albuminfo import AlbumInfo
from osxphotos.path_utils import sanitize_dirname
from osxphotos.phototemplate import RenderOptions
def album_sequence(photo: PhotoInfo, options: RenderOptions, **kwargs) -> str:
"""Call this with {function} template to get album sequence (sort order) when exporting with {folder_album} template
For example, calling this template function like the following prepends sequence#_ to each exported file if the file is in an album:
osxphotos export /path/to/export -V --directory "{folder_album}" --filename "{album?{function:examples/album_sort_order.py::album_sequence}_,}{original_name}"
The sequence will start at 0. To change the sequence to start at a different offset (e.g. 1), set the environment variable OSXPHOTOS_ALBUM_SEQUENCE_START=1 (or whatever offset you want)
"""
dest_path = options.dest_path
if not dest_path:
return ""
album_info = None
for album in photo.album_info:
# following code is how {folder_album} builds the folder path
folder = "/".join(sanitize_dirname(f) for f in album.folder_names)
folder += "/" + sanitize_dirname(album.title)
if dest_path.endswith(folder):
album_info = album
break
else:
# didn't find the album, so skip this file
return ""
start_index = int(os.getenv("OSXPHOTOS_ALBUM_SEQUENCE_START", 0))
return str(album_info.photo_index(photo) + start_index)
def album_sort_order(
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
):
"""Call this with osxphotos export /path/to/export --post-function album_sort_order.py::album_sort_order
This will get called immediately after the photo has been exported
Args:
photo: PhotoInfo instance for the photo that's just been exported
results: ExportResults instance with information about the files associated with the exported photo
verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed)
**kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions
Notes:
Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag
Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color
Will not be called if --dry-run flag is enabled
Will be called immediately after export and before any --post-command commands are executed
"""
# ExportResults has the following properties
# fields with filenames contain the full path to the file
# exported: list of all files exported
# new: list of all new files exported (--update)
# updated: list of all files updated (--update)
# skipped: list of all files skipped (--update)
# exif_updated: list of all files that were updated with --exiftool
# touched: list of all files that had date updated with --touch-file
# converted_to_jpeg: list of files converted to jpeg with --convert-to-jpeg
# sidecar_json_written: list of all JSON sidecar files written
# sidecar_json_skipped: list of all JSON sidecar files skipped (--update)
# sidecar_exiftool_written: list of all exiftool sidecar files written
# sidecar_exiftool_skipped: list of all exiftool sidecar files skipped (--update)
# sidecar_xmp_written: list of all XMP sidecar files written
# sidecar_xmp_skipped: list of all XMP sidecar files skipped (--update)
# missing: list of all missing files
# error: list tuples of (filename, error) for any errors generated during export
# exiftool_warning: list of tuples of (filename, warning) for any warnings generated by exiftool with --exiftool
# exiftool_error: list of tuples of (filename, error) for any errors generated by exiftool with --exiftool
# xattr_written: list of files that had extended attributes written
# xattr_skipped: list of files that where extended attributes were skipped (--update)
# deleted_files: list of deleted files
# deleted_directories: list of deleted directories
# exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album
# skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album
# missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album
for filepath in results.exported:
# do your processing here
filepath = pathlib.Path(filepath)
album_dir = filepath.parent.name
if album_dir not in photo.albums:
return
# get the first album that matches this name of which the photo is a member
album_info = None
for album in photo.album_info:
if album.title == album_dir:
album_info = album
break
else:
# didn't find the album, so skip this file
return
try:
sort_order = album_info.photo_index(photo)
except ValueError:
# photo not in album, so skip this file
return
verbose(f"Sort order for {filepath} in album {album_dir} is {sort_order}")
with open(str(filepath) + "_sort_order.txt", "w") as f:
f.write(str(sort_order))

173
examples/export_template.py Normal file
View File

@@ -0,0 +1,173 @@
""" Example showing how to use a custom function for osxphotos {function} template
to export photos in a folder structure similar to Photos' own structure
Use: osxphotos export /path/to/export --directory "{function:/path/to/export_template.py::photos_folders}"
This will likely export multiple copies of each photo. If using APFS file system, this should be
a non-issue as osxphotos will use copy-on-write so each exported photo doesn't take up additional space
unless you edit the photo.
Thank-you @mkirkland4874 for the inspiration for this example!
This will produce output similar to this:
Library
- Photos
-- {created.year}
---- {created.mm}
------ {created.dd}
- Favorites
- Hidden
- Recently Deleted
- People
- Places
- Imports
Media Types
- Videos
- Selfies
- Portrait
- Panoramas
- Time-lapse
- Slow-mo
- Bursts
- Screenshots
My Albums
-- Album 1
-- Album 2
-- Folder 1
---- Album 3
Shared Albums
-- Shared Album 1
-- Shared Album 2
"""
from typing import List, Union
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.datetime_formatter import DateTimeFormatter
from osxphotos.path_utils import sanitize_dirname
from osxphotos.phototemplate import RenderOptions
def place_folder(photo: osxphotos.PhotoInfo) -> str:
"""Return places as folder in format Country/State/City/etc."""
if not photo.place:
return ""
places = []
if photo.place.names.country:
places.append(photo.place.names.country[0])
if photo.place.names.state_province:
places.append(photo.place.names.state_province[0])
if photo.place.names.sub_administrative_area:
places.append(photo.place.names.sub_administrative_area[0])
if photo.place.names.additional_city_info:
places.append(photo.place.names.additional_city_info[0])
if photo.place.names.area_of_interest:
places.append(photo.place.names.area_of_interest[0])
if places:
return "Library/Places/" + "/".join(sanitize_dirname(place) for place in places)
else:
return ""
def photos_folders(photo: osxphotos.PhotoInfo, options: osxphotos.phototemplate.RenderOptions, **kwargs) -> Union[List, str]:
"""template function for use with --directory to export photos in a folder structure similar to Photos
Args:
photo: osxphotos.PhotoInfo object
options: RenderOptions instance
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
Returns: list of directories for each photo
"""
rendered_date, _ = photo.render_template("{created.year}/{created.mm}/{created.dd}")
date_path = rendered_date[0]
def add_date_path(path):
"""add date path (year/mm/dd)"""
return f"{path}/{date_path}"
# Library
directories = []
if not photo.hidden and not photo.intrash and not photo.shared:
# set directories to [Library/Photos/year/mm/dd]
# render_template returns a tuple of [rendered value(s)], [unmatched]
# here, we can ignore the unmatched value, assigned to _, as we know template will match
directories, _ = photo.render_template(
"Library/Photos/{created.year}/{created.mm}/{created.dd}"
)
if photo.favorite:
directories.append(add_date_path("Library/Favorites"))
if photo.hidden:
directories.append(add_date_path("Library/Hidden"))
if photo.intrash:
directories.append(add_date_path("Library/Recently Deleted"))
directories.extend(
[
add_date_path(f"Library/People/{person}")
for person in photo.persons
if person != _UNKNOWN_PERSON
]
)
if photo.place:
directories.append(add_date_path(place_folder(photo)))
if photo.import_info:
dt = DateTimeFormatter(photo.import_info.creation_date)
directories.append(f"Library/Imports/{dt.year}/{dt.mm}/{dt.dd}")
# Media Types
if photo.ismovie:
directories.append(add_date_path("Media Types/Videos"))
if photo.selfie:
directories.append(add_date_path("Media Types/Selfies"))
if photo.live_photo:
directories.append(add_date_path("Media Types/Live Photos"))
if photo.portrait:
directories.append(add_date_path("Media Types/Portrait"))
if photo.panorama:
directories.append(add_date_path("Media Types/Panoramas"))
if photo.time_lapse:
directories.append(add_date_path("Media Types/Time-lapse"))
if photo.slow_mo:
directories.append(add_date_path("Media Types/Slo-mo"))
if photo.burst:
directories.append(add_date_path("Media Types/Bursts"))
if photo.screenshot:
directories.append(add_date_path("Media Types/Screenshots"))
# Albums
# render the folders and albums in folder/subfolder/album format
# the __NO_ALBUM__ is used as a sentinel to strip out photos not in an album
# use RenderOptions.dirname to force the rendered folder_album value to be sanitized as a valid path
# use RenderOptions.none_str to specify custom value for any photo that doesn't belong to an album so
# those can be filtered out; if not specified, none_str is "_"
folder_albums, _ = photo.render_template(
"{folder_album}", RenderOptions(dirname=True, none_str="__NO_ALBUM__")
)
root_directory = "Shared Albums/" if photo.shared else "My Albums/"
directories.extend(
[
root_directory + folder_album
for folder_album in folder_albums
if folder_album != "__NO_ALBUM__"
]
)
return directories

54
examples/post_function.py Normal file
View 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}")

View 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

View File

@@ -8,41 +8,51 @@ 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"),
("osxphotos/exiftool_filetypes.json", "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,
)

View File

@@ -1,11 +1,44 @@
from ._constants import AlbumSortOrder
from ._version import __version__
from .exiftool import ExifTool
from .export_db import ExportDB
from .fileutil import FileUtil, FileUtilNoOp
from .momentinfo import MomentInfo
from .personinfo import PersonInfo
from .photoexporter import ExportOptions, ExportResults, PhotoExporter
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototemplate import PhotoTemplate
from .placeinfo import PlaceInfo
from .queryoptions import QueryOptions
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
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
__all__ = [
"__version__",
"_debug",
"_get_logger",
"_set_debug",
"AlbumSortOrder",
"CommentInfo",
"ExifTool",
"ExportDB",
"ExportDBTemp",
"ExportOptions",
"ExportResults",
"FileUtil",
"FileUtilNoOp",
"LikeInfo",
"MomentInfo",
"PersonInfo",
"PhotoExporter",
"PhotoInfo",
"PhotosDB",
"PhotoTemplate",
"PlaceInfo",
"QueryOptions",
"ScoreInfo",
"SearchInfo",
]

View File

@@ -1,162 +0,0 @@
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
import sys
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
from . import kae
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
######################################################################
class AppleScript:
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
"""
_codecs = Codecs()
def __init__(self, source=None, path=None):
"""
source : str | None -- AppleScript source code
path : str | None -- full path to .scpt/.applescript file
Notes:
- Either the path or the source argument must be provided.
- If the script cannot be read/compiled, a ScriptError is raised.
"""
if path:
url = NSURL.fileURLWithPath_(path)
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
if errorinfo:
raise ScriptError(errorinfo)
elif source:
self._script = NSAppleScript.alloc().initWithSource_(source)
else:
raise ValueError("Missing source or path argument.")
if not self._script.isCompiled():
errorinfo = self._script.compileAndReturnError_(None)[1]
if errorinfo:
raise ScriptError(errorinfo)
def __repr__(self):
s = self.source
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
##
def _newevent(self, suite, code, args):
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
return evt
def _unpackresult(self, result, errorinfo):
if not result:
raise ScriptError(errorinfo)
return self._codecs.unpack(result)
##
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
def run(self, *args):
""" Run the script, optionally passing arguments to its run handler.
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
Result : anything | None -- the script's return value, if any
Notes:
- The run handler must be explicitly declared in order to pass arguments.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
if args:
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
else:
return self._unpackresult(*self._script.executeAndReturnError_(None))
def call(self, name, *args):
""" Call the specified user-defined handler.
name : str -- the handler's name (case-sensitive)
args : anything -- arguments to pass to script, if any; see documentation for supported types
Result : anything | None -- the script's return value, if any
Notes:
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
fourcharcode(kae.keyASSubroutineName))
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
##
class ScriptError(Exception):
""" Indicates an AppleScript compilation/execution error. """
def __init__(self, errorinfo):
self._errorinfo = dict(errorinfo)
def __repr__(self):
return 'ScriptError({})'.format(self._errorinfo)
@property
def message(self):
""" str -- the error message """
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
if not msg:
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
return msg
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
doc="int | None -- the error number, if given")
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
doc="str | None -- the name of the application that reported the error, where relevant")
@property
def range(self):
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
range = self._errorinfo.get(NSAppleScriptErrorRange)
if range:
start = range.rangeValue().location
end = start + range.rangeValue().length
return (start, end)
else:
return None
def __str__(self):
msg = self.message
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
if v is not None:
msg += s.format(v)
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
##
kMissingValue = AEType(kae.cMissingValue) # convenience constant

View File

@@ -1,269 +0,0 @@
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
import datetime, struct, sys
from Foundation import NSAppleEventDescriptor, NSURL
from . import kae
__all__ = ['Codecs', 'AEType', 'AEEnum']
######################################################################
def fourcharcode(code):
""" Convert four-char code for use in NSAppleEventDescriptor methods.
code : bytes -- four-char code, e.g. b'utxt'
Result : int -- OSType, e.g. 1970567284
"""
return struct.unpack('>I', code)[0]
#######
class Codecs:
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
kMacEpoch = datetime.datetime(1904, 1, 1)
kUSRF = fourcharcode(kae.keyASUserRecordFields)
def __init__(self):
# Clients may add/remove/replace encoder and decoder items:
self.encoders = {
NSAppleEventDescriptor.class__(): self.packdesc,
type(None): self.packnone,
bool: self.packbool,
int: self.packint,
float: self.packfloat,
bytes: self.packbytes,
str: self.packstr,
list: self.packlist,
tuple: self.packlist,
dict: self.packdict,
datetime.datetime: self.packdatetime,
AEType: self.packtype,
AEEnum: self.packenum,
}
if sys.version_info.major < 3: # 2.7 compatibility
self.encoders[unicode] = self.packstr
self.decoders = {fourcharcode(k): v for k, v in {
kae.typeNull: self.unpacknull,
kae.typeBoolean: self.unpackboolean,
kae.typeFalse: self.unpackboolean,
kae.typeTrue: self.unpackboolean,
kae.typeSInt32: self.unpacksint32,
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
kae.typeUTF8Text: self.unpackunicodetext,
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
kae.typeUnicodeText: self.unpackunicodetext,
kae.typeLongDateTime: self.unpacklongdatetime,
kae.typeAEList: self.unpackaelist,
kae.typeAERecord: self.unpackaerecord,
kae.typeAlias: self.unpackfile,
kae.typeFSS: self.unpackfile,
kae.typeFSRef: self.unpackfile,
kae.typeFileURL: self.unpackfile,
kae.typeType: self.unpacktype,
kae.typeEnumeration: self.unpackenumeration,
}.items()}
def pack(self, data):
"""Pack Python data.
data : anything -- a Python value
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
"""
try:
return self.encoders[data.__class__](data) # quick lookup by type/class
except (KeyError, AttributeError) as e:
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
if isinstance(data, type):
return encoder(data)
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
def unpack(self, desc):
"""Unpack an Apple event descriptor.
desc : NSAppleEventDescriptor
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
"""
decoder = self.decoders.get(desc.descriptorType())
# unpack known type
if decoder:
return decoder(desc)
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
if rec:
rec = self.unpackaerecord(rec)
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
return rec
# return as-is
return desc
##
def _packbytes(self, desctype, data):
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
fourcharcode(desctype), data, len(data))
def packdesc(self, val):
return val
def packnone(self, val):
return NSAppleEventDescriptor.nullDescriptor()
def packbool(self, val):
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
def packint(self, val):
if (-2**31) <= val < (2**31):
return NSAppleEventDescriptor.descriptorWithInt32_(val)
else:
return self.pack(float(val))
def packfloat(self, val):
return self._packbytes(kae.typeFloat, struct.pack('d', val))
def packbytes(self, val):
return self._packbytes(kae.typeData, val)
def packstr(self, val):
return NSAppleEventDescriptor.descriptorWithString_(val)
def packdatetime(self, val):
delta = val - self.kMacEpoch
sec = delta.days * 3600 * 24 + delta.seconds
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
def packlist(self, val):
lst = NSAppleEventDescriptor.listDescriptor()
for item in val:
lst.insertDescriptor_atIndex_(self.pack(item), 0)
return lst
def packdict(self, val):
record = NSAppleEventDescriptor.recordDescriptor()
usrf = desctype = None
for key, value in val.items():
if isinstance(key, AEType):
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
desctype = value
else:
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
else:
if not usrf:
usrf = NSAppleEventDescriptor.listDescriptor()
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
if usrf:
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
if desctype:
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
if newrecord:
record = newrecord
else: # coercion failed for some reason, so pack as normal key-value pair
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
return record
def packtype(self, val):
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
def packenum(self, val):
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
#######
def unpacknull(self, desc):
return None
def unpackboolean(self, desc):
return desc.booleanValue()
def unpacksint32(self, desc):
return desc.int32Value()
def unpackfloat64(self, desc):
return struct.unpack('d', bytes(desc.data()))[0]
def unpackunicodetext(self, desc):
return desc.stringValue()
def unpacklongdatetime(self, desc):
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
def unpackaelist(self, desc):
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
def unpackaerecord(self, desc):
dct = {}
for i in range(desc.numberOfItems()):
key = desc.keywordForDescriptorAtIndex_(i + 1)
value = desc.descriptorForKeyword_(key)
if key == self.kUSRF:
lst = self.unpackaelist(value)
for i in range(0, len(lst), 2):
dct[lst[i]] = lst[i+1]
else:
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
return dct
def unpacktype(self, desc):
return AEType(struct.pack('>I', desc.typeCodeValue()))
def unpackenumeration(self, desc):
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
def unpackfile(self, desc):
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
return NSURL.URLWithString_(url).path()
#######
class AETypeBase:
""" Base class for AEType and AEEnum.
Notes:
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
"""
def __init__(self, code):
"""
code : bytes -- four-char code, e.g. b'utxt'
"""
if not isinstance(code, bytes):
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
elif len(code) != 4:
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
self._code = code
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
def __hash__(self):
return hash(self._code)
def __eq__(self, val):
return val.__class__ == self.__class__ and val.code == self._code
def __ne__(self, val):
return not self == val
def __repr__(self):
return "{}({!r})".format(self.__class__.__name__, self._code)
##
class AEType(AETypeBase):
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
class AEEnum(AETypeBase):
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ Constants used by osxphotos
import os.path
from datetime import datetime
from enum import Enum
OSXPHOTOS_URL = "https://github.com/RhetTbull/osxphotos"
@@ -19,8 +20,8 @@ UNICODE_FORMAT = "NFC"
# Photos 3.0 (10.13.6) == 3301
# Photos 4.0 (10.14.5) == 4016
# Photos 4.0 (10.14.6) == 4025
# Photos 5.0 (10.15.0) == 6000
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# Photos 5.0 (10.15.0) == 6000 or 5001
_TESTED_DB_VERSIONS = ["6000", "5001", "4025", "4016", "3301", "2622"]
# database model versions (applies to Photos 5, Photos 6)
# these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST
@@ -29,16 +30,22 @@ _TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# Photos 6 (10.16.0 Beta) == 14104
_TEST_MODEL_VERSIONS = ["13537", "13703", "14104"]
_PHOTOS_2_VERSION = "2622"
# only version 3 - 4 have RKVersion.selfPortrait
_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 = "5000" # I've seen both 5001 and 6000. 6000 is most common on Catalina and up but there are some version 5001 database in the wild
# 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, 12.1 is 15331
# some table names differ between Photos 5 and Photos 6
_DB_TABLE_NAMES = {
@@ -49,6 +56,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 +68,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",
},
}
@@ -70,6 +97,12 @@ _TESTED_OS_VERSIONS = [
("11", "0"),
("11", "1"),
("11", "2"),
("11", "3"),
("11", "4"),
("11", "5"),
("11", "6"),
("12", "0"),
("12", "1"),
]
# Photos 5 has persons who are empty string if unidentified face
@@ -95,12 +128,20 @@ _XMP_TEMPLATE_NAME_BETA = "xmp_sidecar_beta.mako"
# Constants used for processing folders and albums
_PHOTOS_5_ALBUM_KIND = 2 # normal user album
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
_PHOTOS_5_PROJECT_ALBUM_KIND = 1508 # My Projects (e.g. Calendar, Card, Slideshow)
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND = 1506 # import session
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
_PHOTOS_4_ALBUM_TYPE_ALBUM = 1 # RKAlbum.albumType
_PHOTOS_4_ALBUM_TYPE_PROJECT = 9 # RKAlbum.albumType
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW = 8 # RKAlbum.albumType
_PHOTOS_4_TOP_LEVEL_ALBUMS = [
"TopLevelAlbums",
"TopLevelKeepsakes",
"TopLevelSlideshows",
]
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
# EXIF related constants
@@ -187,6 +228,9 @@ DEFAULT_EDITED_SUFFIX = "_edited"
# Default suffix to add to original images
DEFAULT_ORIGINAL_SUFFIX = ""
# Default suffix to add to preview images
DEFAULT_PREVIEW_SUFFIX = "_preview"
# Colors for print CLI messages
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"
@@ -201,10 +245,17 @@ EXTENDED_ATTRIBUTE_NAMES = [
"authors",
"comment",
"copyright",
"creator",
"description",
"findercomment",
"headline",
"keywords",
"participants",
"projects",
"rating",
"subject",
"title",
"version",
]
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
@@ -212,8 +263,63 @@ EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
# bit flags for burst images ("burstPickType")
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
BURST_SELECTED = 0b1000 # 8: burst image is selected
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
BURST_PICK_TYPE_NONE = 0b0 # 0: sometimes used for single images with a burst UUID
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
BURST_SELECTED = 0b1000 # 8: burst image is selected
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",
}
class AlbumSortOrder(Enum):
"""Album Sort Order"""
UNKNOWN = 0
MANUAL = 1
NEWEST_FIRST = 2
OLDEST_FIRST = 3
TITLE = 5
TEXT_DETECTION_CONFIDENCE_THRESHOLD = 0.75
# stat sort order for cProfile: https://docs.python.org/3/library/profile.html#pstats.Stats.sort_stats
PROFILE_SORT_KEYS = [
"calls",
"cumulative",
"cumtime",
"file",
"filename",
"module",
"ncalls",
"pcalls",
"line",
"name",
"nfl",
"stdname",
"time",
"tottime",
]

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.14"
__version__ = "0.46.0"

View File

@@ -16,6 +16,8 @@ import zlib
from .datetime_utils import datetime_naive_to_utc
__all__ = ["AdjustmentsDecodeError", "AdjustmentsInfo"]
class AdjustmentsDecodeError(Exception):
"""Could not decode adjustments plist file"""
@@ -73,37 +75,37 @@ class AdjustmentsInfo:
@property
def plist(self):
"""The actual adjustments plist content as a dict """
"""The actual adjustments plist content as a dict"""
return self._plist
@property
def data(self):
"""The raw adjustments data as a binary blob """
"""The raw adjustments data as a binary blob"""
return self._data
@property
def editor(self):
"""The editor bundle ID for app/plug-in which made the adjustments """
"""The editor bundle ID for app/plug-in which made the adjustments"""
return self._editor_bundle_id
@property
def format_id(self):
"""The value of the adjustmentFormatIdentifier field in the plist """
"""The value of the adjustmentFormatIdentifier field in the plist"""
return self._format_identifier
@property
def base_version(self):
"""Value of adjustmentBaseVersion field """
"""Value of adjustmentBaseVersion field"""
return self._base_version
@property
def format_version(self):
"""The value of the adjustmentFormatVersion in the plist """
"""The value of the adjustmentFormatVersion in the plist"""
return self._format_version
@property
def timestamp(self):
"""The time stamp of the adjustment as timezone aware datetime.datetime object or None if no timestamp """
"""The time stamp of the adjustment as timezone aware datetime.datetime object or None if no timestamp"""
return self._timestamp
@property

View File

@@ -14,26 +14,37 @@ from datetime import datetime, timedelta, timezone
from ._constants import (
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_4_TOP_LEVEL_ALBUMS,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
TIME_DELTA,
AlbumSortOrder,
)
from .datetime_utils import get_local_tz
from .query_builder import get_query
__all__ = [
"sort_list_by_keys",
"AlbumInfoBaseClass",
"AlbumInfo",
"ImportInfo",
"ProjectInfo",
"FolderInfo",
]
def sort_list_by_keys(values, sort_keys):
""" Sorts list values by a second list sort_keys
"""Sorts list values by a second list sort_keys
e.g. given ["a","c","b"], [1, 3, 2], returns ["a", "b", "c"]
Args:
values: a list of values to be sorted
sort_keys: a list of keys to sort values by
Returns:
list of values, sorted by sort_keys
Raises:
ValueError: raised if len(values) != len(sort_keys)
"""
@@ -63,12 +74,12 @@ class AlbumInfoBaseClass:
@property
def uuid(self):
""" return uuid of album """
"""return uuid of album"""
return self._uuid
@property
def creation_date(self):
""" return creation date of album """
"""return creation date of album"""
try:
return self._creation_date
except AttributeError:
@@ -90,8 +101,8 @@ class AlbumInfoBaseClass:
@property
def start_date(self):
""" For Albums, return start date (earliest image) of album or None for albums with no images
For Import Sessions, return start date of import session (when import began) """
"""For Albums, return start date (earliest image) of album or None for albums with no images
For Import Sessions, return start date of import session (when import began)"""
try:
return self._start_date
except AttributeError:
@@ -109,8 +120,8 @@ class AlbumInfoBaseClass:
@property
def end_date(self):
""" For Albums, return end date (most recent image) of album or None for albums with no images
For Import Sessions, return end date of import sessions (when import was completed) """
"""For Albums, return end date (most recent image) of album or None for albums with no images
For Import Sessions, return end date of import sessions (when import was completed)"""
try:
return self._end_date
except AttributeError:
@@ -130,43 +141,74 @@ class AlbumInfoBaseClass:
def photos(self):
return []
@property
def owner(self):
"""Return name of photo owner for shared album (Photos 5+ only), or None if not shared"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
try:
return self._owner
except AttributeError:
try:
personid = self._db._dbalbum_details[self.uuid][
"cloudownerhashedpersonid"
]
self._owner = (
self._db._db_hashed_person_id[personid]["full_name"]
if personid
else None
)
except KeyError:
self._owner = None
return self._owner
def __len__(self):
""" return number of photos contained in album """
"""return number of photos contained in album"""
return len(self.photos)
class AlbumInfo(AlbumInfoBaseClass):
"""
Base class for AlbumInfo, ImportInfo
Info about a specific Album, contains all the details about the album
including folders, photos, etc.
"""
@property
def title(self):
""" return title / name of album """
"""return title / name of album"""
return self._title
@property
def photos(self):
""" return list of photos contained in album sorted in same sort order as Photos """
"""return list of photos contained in album sorted in same sort order as Photos"""
try:
return self._photos
except AttributeError:
if self.uuid in self._db._dbalbums_album:
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
sorted_uuid = sort_list_by_keys(uuid, sort_order)
self._photos = self._db.photos_by_uuid(sorted_uuid)
photos = self._db.photos_by_uuid(sorted_uuid)
sort_order = self.sort_order
if sort_order == AlbumSortOrder.NEWEST_FIRST:
self._photos = sorted(photos, key=lambda p: p.date, reverse=True)
elif sort_order == AlbumSortOrder.OLDEST_FIRST:
self._photos = sorted(photos, key=lambda p: p.date)
elif sort_order == AlbumSortOrder.TITLE:
self._photos = sorted(photos, key=lambda p: p.title or "")
else:
# assume AlbumSortOrder.MANUAL
self._photos = photos
else:
self._photos = []
return self._photos
@property
def folder_names(self):
""" return hierarchical list of folders the album is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
"""return hierarchical list of folders the album is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders"""
try:
return self._folder_names
@@ -176,10 +218,10 @@ class AlbumInfo(AlbumInfoBaseClass):
@property
def folder_list(self):
""" return hierarchical list of folders the album is contained in
as list of FolderInfo objects in form
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
"""return hierarchical list of folders the album is contained in
as list of FolderInfo objects in form
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders"""
try:
return self._folders
@@ -189,7 +231,7 @@ class AlbumInfo(AlbumInfoBaseClass):
@property
def parent(self):
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
"""returns FolderInfo object for parent folder or None if no parent (e.g. top-level album)"""
try:
return self._parent
except AttributeError:
@@ -197,7 +239,7 @@ class AlbumInfo(AlbumInfoBaseClass):
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS
else None
)
else:
@@ -209,11 +251,43 @@ class AlbumInfo(AlbumInfoBaseClass):
)
return self._parent
@property
def sort_order(self):
"""return sort order of album"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return AlbumSortOrder.MANUAL
details = self._db._dbalbum_details[self._uuid]
if details["customsortkey"] == 1:
if details["customsortascending"] == 0:
return AlbumSortOrder.NEWEST_FIRST
elif details["customsortascending"] == 1:
return AlbumSortOrder.OLDEST_FIRST
else:
return AlbumSortOrder.UNKNOWN
elif details["customsortkey"] == 5:
return AlbumSortOrder.TITLE
elif details["customsortkey"] == 0:
return AlbumSortOrder.MANUAL
else:
return AlbumSortOrder.UNKNOWN
def photo_index(self, photo):
"""return index of photo in album (based on album sort order)"""
for index, p in enumerate(self.photos):
if p.uuid == photo.uuid:
return index
raise ValueError(
f"Photo with uuid {photo.uuid} does not appear to be in this album"
)
class ImportInfo(AlbumInfoBaseClass):
"""Information about import sessions"""
@property
def photos(self):
""" return list of photos contained in import session """
"""return list of photos contained in import session"""
try:
return self._photos
except AttributeError:
@@ -229,9 +303,18 @@ class ImportInfo(AlbumInfoBaseClass):
return self._photos
class ProjectInfo(AlbumInfo):
"""
ProjectInfo with info about projects
Projects are cards, calendars, slideshows, etc.
"""
...
class FolderInfo:
"""
Info about a specific folder, contains all the details about the folder
Info about a specific folder, contains all the details about the folder
including folders, albums, etc
"""
@@ -247,17 +330,17 @@ class FolderInfo:
@property
def title(self):
""" return title / name of folder"""
"""return title / name of folder"""
return self._title
@property
def uuid(self):
""" return uuid of folder """
"""return uuid of folder"""
return self._uuid
@property
def album_info(self):
""" return list of albums (as AlbumInfo objects) contained in the folder """
"""return list of albums (as AlbumInfo objects) contained in the folder"""
try:
return self._albums
except AttributeError:
@@ -282,7 +365,7 @@ class FolderInfo:
@property
def parent(self):
""" returns FolderInfo object for parent or None if no parent (e.g. top-level folder) """
"""returns FolderInfo object for parent or None if no parent (e.g. top-level folder)"""
try:
return self._parent
except AttributeError:
@@ -290,7 +373,7 @@ class FolderInfo:
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS
else None
)
else:
@@ -304,7 +387,7 @@ class FolderInfo:
@property
def subfolders(self):
""" return list of folders (as FolderInfo objects) contained in the folder """
"""return list of folders (as FolderInfo objects) contained in the folder"""
try:
return self._folders
except AttributeError:
@@ -328,5 +411,5 @@ class FolderInfo:
return self._folders
def __len__(self):
""" returns count of folders + albums contained in the folder """
"""returns count of folders + albums contained in the folder"""
return len(self.subfolders) + len(self.album_info)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
"""Help text helper class for osxphotos CLI """
import io
import pathlib
import re
import click
@@ -12,16 +13,30 @@ 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,
)
__all__ = [
"ExportCommand",
"template_help",
"tutorial_help",
"rich_text",
"strip_md_header_and_links",
"strip_md_links",
"strip_html_comments",
"get_tutorial_text",
]
# 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 +80,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 +116,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 +147,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 +174,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 = [("Category", "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|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}' is properly quoted "
+ "and the '{shell_quote}' template ensures the constructed path of '{exported_dir}/exported.txt' is properly quoted. "
"If '{filepath}' 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 +305,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 +324,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

View File

@@ -1,9 +1,16 @@
""" ConfigOptions class to load/save config settings for osxphotos CLI """
import toml
__all__ = [
"ConfigOptionsException",
"ConfigOptionsInvalidError",
"ConfigOptionsLoadError",
"ConfigOptions",
]
class ConfigOptionsException(Exception):
""" Invalid combination of options. """
"""Invalid combination of options."""
def __init__(self, message):
self.message = message
@@ -19,10 +26,10 @@ class ConfigOptionsLoadError(ConfigOptionsException):
class ConfigOptions:
""" data class to store and load options for osxphotos commands """
"""data class to store and load options for osxphotos commands"""
def __init__(self, name, attrs, ignore=None):
""" init ConfigOptions class
"""init ConfigOptions class
Args:
name: name for these options, will be used for section heading in TOML file when saving/loading from file
@@ -53,21 +60,21 @@ class ConfigOptions:
raise KeyError(f"Missing argument: {attr}")
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
""" validate combinations of otions
"""validate combinations of otions
Args:
exclusive: list of tuples in form [("option_1", "option_2")...] which are exclusive;
ie. either option_1 can be set or option_2 but not both;
inclusive: list of tuples in form [("option_1", "option_2")...] which are inclusive;
exclusive: list of tuples in form [("option_1", "option_2")...] which are exclusive;
ie. either option_1 can be set or option_2 but not both;
inclusive: list of tuples in form [("option_1", "option_2")...] which are inclusive;
ie. if either option_1 or option_2 is set, the other must be set
dependent: list of tuples in form [("option_1", ("option_2", "option_3"))...]
dependent: list of tuples in form [("option_1", ("option_2", "option_3"))...]
where if option_1 is set, then at least one of the options in the second tuple must also be set
cli: bool, set to True if called to validate CLI options;
cli: bool, set to True if called to validate CLI options;
will prepend '--' to option names in InvalidOptions.message and change _ to - in option names
Returns:
True if all options valid
Raises:
InvalidOption if any combination of options is invalid
InvalidOption.message will be descriptive message of invalid options
@@ -121,27 +128,21 @@ class ConfigOptions:
return True
def write_to_file(self, filename):
""" Write self to TOML file
"""Write self to TOML file
Args:
filename: full path to TOML file to write; filename will be overwritten if it exists
"""
# todo: add overwrite and option to merge contents already in TOML file (under different [section] with new content)
data = {}
for attr in sorted(self._attrs.keys()):
val = getattr(self, attr)
if val in [False, ()]:
val = None
else:
val = list(val) if type(val) == tuple else val
data[attr] = val
with open(filename, "w") as fd:
toml.dump({self._name: data}, fd)
toml.dump(self._get_toml_dict(), fd)
def write_to_str(self) -> str:
"""Write self to TOML str"""
return toml.dumps(self._get_toml_dict())
def load_from_file(self, filename, override=False):
""" Load options from a TOML file.
"""Load options from a TOML file.
Args:
filename: full path to TOML file
@@ -171,3 +172,17 @@ class ConfigOptions:
def asdict(self):
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}
def _get_toml_dict(self):
"""Return dict for writing to TOML file"""
data = {}
for attr in sorted(self._attrs.keys()):
val = getattr(self, attr)
if val in [False, ()]:
val = None
else:
val = list(val) if type(val) == tuple else val
data[attr] = val
return {self._name: data}

View File

@@ -0,0 +1,46 @@
"""Error logger/crash reporter decorator"""
import datetime
import functools
import platform
import sys
import traceback
from rich import print
def crash_reporter(filename, message, title, postamble, *extra_args):
"""Create a crash dump file on error named filename
On error, create a crash dump file named filename with exception and stack trace.
message is printed to stderr
title is printed at beginning of crash dump file
postamble is printed to stderr after crash dump file is created
If extra_args is not None, any additional arguments to the function will be printed to the file.
"""
def decorated(func):
@functools.wraps(func)
def wrapped(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
print(message, file=sys.stderr)
print(f"[red]{e}[/red]", file=sys.stderr)
with open(filename, "w") as f:
f.write(f"{title}\n")
f.write(f"Created: {datetime.datetime.now()}\n")
f.write(f"Python version: {sys.version}\n")
f.write(f"Platform: {platform.platform()}\n")
f.write(f"sys.argv: {sys.argv}\n")
for arg in extra_args:
f.write(f"{arg}\n")
f.write(f"Error: {e}\n")
traceback.print_exc(file=f)
print(f"Crash log written to '{filename}'", file=sys.stderr)
print(f"{postamble}", file=sys.stderr)
sys.exit(1)
return wrapped
return decorated

View File

@@ -2,69 +2,71 @@
import datetime
__all__ = ["DateTimeFormatter"]
class DateTimeFormatter:
""" provides property access to formatted datetime.datetime strftime values """
"""provides property access to formatted datetime.datetime strftime values"""
def __init__(self, dt: datetime.datetime):
self.dt = dt
@property
def date(self):
""" ISO date in form 2020-03-22 """
"""ISO date in form 2020-03-22"""
return self.dt.date().isoformat()
@property
def year(self):
""" 4 digit year """
"""4 digit year"""
return f"{self.dt.year}"
@property
def yy(self):
""" 2 digit year """
"""2 digit year"""
return f"{self.dt.strftime('%y')}"
@property
def mm(self):
""" 2 digit month """
"""2 digit month"""
return f"{self.dt.strftime('%m')}"
@property
def month(self):
""" Month as locale's full name """
"""Month as locale's full name"""
return f"{self.dt.strftime('%B')}"
@property
def mon(self):
""" Month as locale's abbreviated name """
"""Month as locale's abbreviated name"""
return f"{self.dt.strftime('%b')}"
@property
def dd(self):
""" 2-digit day of the month """
"""2-digit day of the month"""
return f"{self.dt.strftime('%d')}"
@property
def dow(self):
""" Day of week as locale's name """
"""Day of week as locale's name"""
return f"{self.dt.strftime('%A')}"
@property
def doy(self):
""" Julian day of year starting from 001 """
"""Julian day of year starting from 001"""
return f"{self.dt.strftime('%j')}"
@property
def hour(self):
""" 2-digit hour """
"""2-digit hour"""
return f"{self.dt.strftime('%H')}"
@property
def min(self):
""" 2-digit minute """
"""2-digit minute"""
return f"{self.dt.strftime('%M')}"
@property
def sec(self):
""" 2-digit second """
"""2-digit second"""
return f"{self.dt.strftime('%S')}"

View File

@@ -2,13 +2,23 @@
import datetime
__all__ = [
"get_local_tz",
"datetime_has_tz",
"datetime_tz_to_utc",
"datetime_remove_tz",
"datetime_naive_to_utc",
"datetime_naive_to_local",
"datetime_utc_to_local",
]
def get_local_tz(dt):
""" Return local timezone as datetime.timezone tzinfo for dt
"""Return local timezone as datetime.timezone tzinfo for dt
Args:
dt: datetime.datetime
Returns:
local timezone for dt as datetime.timezone
@@ -22,14 +32,14 @@ def get_local_tz(dt):
def datetime_has_tz(dt):
""" Return True if datetime dt has tzinfo else False
"""Return True if datetime dt has tzinfo else False
Args:
dt: datetime.datetime
Returns:
True if dt is timezone aware, else False
Raises:
TypeError if dt is not a datetime.datetime object
"""
@@ -41,15 +51,15 @@ def datetime_has_tz(dt):
def datetime_tz_to_utc(dt):
""" Convert datetime.datetime object with timezone to UTC timezone
"""Convert datetime.datetime object with timezone to UTC timezone
Args:
dt: datetime.datetime object
Returns:
datetime.datetime in UTC timezone
Raises:
Raises:
TypeError if dt is not datetime.datetime object
ValueError if dt does not have timeone information
"""
@@ -64,14 +74,14 @@ def datetime_tz_to_utc(dt):
def datetime_remove_tz(dt):
""" Remove timezone from a datetime.datetime object
"""Remove timezone from a datetime.datetime object
Args:
dt: datetime.datetime object with tzinfo
Returns:
dt without any timezone info (naive datetime object)
dt without any timezone info (naive datetime object)
Raises:
TypeError if dt is not a datetime.datetime object
"""
@@ -83,15 +93,15 @@ def datetime_remove_tz(dt):
def datetime_naive_to_utc(dt):
""" Convert naive (timezone unaware) datetime.datetime
"""Convert naive (timezone unaware) datetime.datetime
to aware timezone in UTC timezone
Args:
dt: datetime.datetime without timezone
Returns:
datetime.datetime with UTC timezone
Raises:
TypeError if dt is not a datetime.datetime object
ValueError if dt is not a naive/timezone unaware object
@@ -111,15 +121,15 @@ def datetime_naive_to_utc(dt):
def datetime_naive_to_local(dt):
""" Convert naive (timezone unaware) datetime.datetime
"""Convert naive (timezone unaware) datetime.datetime
to aware timezone in local timezone
Args:
dt: datetime.datetime without timezone
Returns:
datetime.datetime with local timezone
Raises:
TypeError if dt is not a datetime.datetime object
ValueError if dt is not a naive/timezone unaware object
@@ -139,7 +149,7 @@ def datetime_naive_to_local(dt):
def datetime_utc_to_local(dt):
""" Convert datetime.datetime object in UTC timezone to local timezone
"""Convert datetime.datetime object in UTC timezone to local timezone
Args:
dt: datetime.datetime object

30
osxphotos/exifinfo.py Normal file
View File

@@ -0,0 +1,30 @@
""" ExifInfo class to expose EXIF info from the library """
from dataclasses import dataclass
__all__ = ["ExifInfo"]
@dataclass(frozen=True)
class ExifInfo:
"""EXIF info associated with a photo from the Photos library"""
flash_fired: bool
iso: int
metering_mode: int
sample_rate: int
track_format: int
white_balance: int
aperture: float
bit_rate: float
duration: float
exposure_bias: float
focal_length: float
fps: float
latitude: float
longitude: float
shutter_speed: float
camera_make: str
camera_model: str
codec: str
lens_model: str

View File

@@ -6,23 +6,82 @@
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
import atexit
import html
import json
import logging
import os
import pathlib
import re
import shutil
import subprocess
from functools import lru_cache # pylint: disable=syntax-error
from abc import ABC, abstractmethod
from functools import lru_cache # pylint: disable=syntax-error
__all__ = [
"escape_str",
"exiftool_can_write",
"ExifTool",
"ExifToolCaching",
"get_exiftool_path",
"terminate_exiftool",
"unescape_str",
]
# exiftool -stay_open commands outputs this EOF marker after command is run
EXIFTOOL_STAYOPEN_EOF = "{ready}"
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
# list of exiftool processes to cleanup when exiting or when terminate is called
EXIFTOOL_PROCESSES = []
# exiftool supported file types, created by utils/exiftool_supported_types.py
EXIFTOOL_FILETYPES_JSON = "exiftool_filetypes.json"
with (pathlib.Path(__file__).parent / EXIFTOOL_FILETYPES_JSON).open("r") as f:
EXIFTOOL_SUPPORTED_FILETYPES = json.load(f)
def exiftool_can_write(suffix: str) -> bool:
"""Return True if exiftool supports writing to a file with the given suffix, otherwise False"""
if not suffix:
return False
suffix = suffix.lower()
if suffix[0] == ".":
suffix = suffix[1:]
return (
suffix in EXIFTOOL_SUPPORTED_FILETYPES
and EXIFTOOL_SUPPORTED_FILETYPES[suffix]["write"]
)
def escape_str(s):
"""escape string for use with exiftool -E"""
if type(s) != str:
return s
s = html.escape(s)
s = s.replace("\n", "&#xa;")
s = s.replace("\t", "&#x9;")
s = s.replace("\r", "&#xd;")
return s
def unescape_str(s):
"""unescape an HTML string returned by exiftool -E"""
if type(s) != str:
return s
return html.unescape(s)
@atexit.register
def terminate_exiftool():
"""Terminate any running ExifTool subprocesses; call this to cleanup when done using ExifTool"""
for proc in EXIFTOOL_PROCESSES:
proc._stop_proc()
@lru_cache(maxsize=1)
def get_exiftool_path():
""" return path of exiftool, cache result """
"""return path of exiftool, cache result"""
exiftool_path = shutil.which("exiftool")
if exiftool_path:
return exiftool_path.rstrip()
@@ -38,7 +97,7 @@ class _ExifToolProc:
Creates a singleton object"""
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)
@@ -63,24 +122,25 @@ class _ExifToolProc:
@property
def process(self):
""" return the exiftool subprocess """
"""return the exiftool subprocess"""
if self._process_running:
return self._process
else:
raise ValueError("exiftool process is not running")
self._start_proc()
return self._process
@property
def pid(self):
""" return process id (PID) of the exiftool process """
"""return process id (PID) of the exiftool process"""
return self._process.pid
@property
def exiftool(self):
""" return path to exiftool process """
"""return path to exiftool process"""
return self._exiftool
def _start_proc(self):
""" start exiftool in batch mode """
"""start exiftool in batch mode"""
if self._process_running:
logging.warning("exiftool already running: {self._process}")
@@ -98,6 +158,7 @@ class _ExifToolProc:
"-n", # no print conversion (e.g. print tag values in machine readable format)
"-P", # Preserve file modification date/time
"-G", # print group name for each tag
"-E", # escape tag values for HTML (allows use of HTML &#xa; for newlines)
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
@@ -105,33 +166,33 @@ class _ExifToolProc:
)
self._process_running = True
EXIFTOOL_PROCESSES.append(self)
def _stop_proc(self):
""" stop the exiftool process if it's running, otherwise, do nothing """
"""stop the exiftool process if it's running, otherwise, do nothing"""
if not self._process_running:
return
self._process.stdin.write(b"-stay_open\n")
self._process.stdin.write(b"False\n")
self._process.stdin.flush()
try:
self._process.stdin.write(b"-stay_open\n")
self._process.stdin.write(b"False\n")
self._process.stdin.flush()
except Exception as e:
pass
try:
self._process.communicate(timeout=5)
except subprocess.TimeoutExpired:
logging.warning(
f"exiftool pid {self._process.pid} did not exit, killing it"
)
self._process.kill()
self._process.communicate()
del self._process
self._process_running = False
def __del__(self):
self._stop_proc()
class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
"""Basic exiftool interface for reading and writing EXIF tags"""
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
"""Create ExifTool object
@@ -154,9 +215,12 @@ class ExifTool:
# if running as a context manager, self._context_mgr will be True
self._context_mgr = False
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
self._process = self._exiftoolproc.process
self._read_exif()
@property
def _process(self):
return self._exiftoolproc.process
def setvalue(self, tag, value):
"""Set tag to value(s); if value is None, will delete tag
@@ -174,6 +238,7 @@ class ExifTool:
if value is None:
value = ""
value = escape_str(value)
command = [f"-{tag}={value}"]
if self.overwrite and not self._context_mgr:
command.append("-overwrite_original")
@@ -186,7 +251,7 @@ class ExifTool:
return True
else:
_, _, error = self.run_commands(*command)
return error is None
return error == ""
def addvalues(self, tag, *values):
"""Add one or more value(s) to tag
@@ -218,6 +283,7 @@ class ExifTool:
for value in values:
if value is None:
raise ValueError("Can't add None value to tag")
value = escape_str(value)
command.append(f"-{tag}+={value}")
if self.overwrite and not self._context_mgr:
@@ -228,7 +294,7 @@ class ExifTool:
return True
else:
_, _, error = self.run_commands(*command)
return error is None
return error == ""
def run_commands(self, *commands, no_file=False):
"""Run commands in the exiftool process and return result.
@@ -300,12 +366,12 @@ class ExifTool:
@property
def pid(self):
""" return process id (PID) of the exiftool process """
"""return process id (PID) of the exiftool process"""
return self._process.pid
@property
def version(self):
""" returns exiftool version """
"""returns exiftool version"""
ver, _, _ = self.run_commands("-ver", no_file=True)
return ver.decode("utf-8")
@@ -320,6 +386,7 @@ class ExifTool:
json_str, _, _ = self.run_commands("-json")
if not json_str:
return dict()
json_str = unescape_str(json_str.decode("utf-8"))
try:
exifdict = json.loads(json_str)
@@ -327,7 +394,6 @@ class ExifTool:
# will fail with some commands, e.g --ext AVI which produces
# 'No file with specified extension' instead of json
return dict()
exifdict = exifdict[0]
if not tag_groups:
# strip tag groups
@@ -343,12 +409,13 @@ class ExifTool:
return exifdict
def json(self):
""" returns JSON string containing all EXIF tags and values from exiftool """
"""returns JSON string containing all EXIF tags and values from exiftool"""
json, _, _ = self.run_commands("-json")
json = unescape_str(json.decode("utf-8"))
return json
def _read_exif(self):
""" read exif data from file """
"""read exif data from file"""
data = self.asdict()
self.data = {k: v for k, v in data.items()}
@@ -369,15 +436,15 @@ class ExifTool:
class ExifToolCaching(ExifTool):
""" Basic exiftool interface for reading and writing EXIF tags, with caching.
Use this only when you know the file's EXIF data will not be changed by any external process.
Creates a singleton cached ExifTool instance """
"""Basic exiftool interface for reading and writing EXIF tags, with caching.
Use this only when you know the file's EXIF data will not be changed by any external process.
Creates a singleton cached ExifTool instance"""
_singletons = {}
def __new__(cls, filepath, exiftool=None):
""" create new object or return instance of already created singleton """
"""create new object or return instance of already created singleton"""
if filepath not in cls._singletons:
cls._singletons[filepath] = _ExifToolCaching(filepath, exiftool=exiftool)
return cls._singletons[filepath]
@@ -433,7 +500,6 @@ class _ExifToolCaching(ExifTool):
return self._asdict_cache[tag_groups][normalized]
def flush_cache(self):
""" Clear cached data so that calls to json or asdict return fresh data """
"""Clear cached data so that calls to json or asdict return fresh data"""
self._json_cache = None
self._asdict_cache = {}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
""" Utility functions for working with export_db """
import pathlib
import sqlite3
from typing import Optional, Tuple, Union
import datetime
import os
import toml
from rich import print
from ._constants import OSXPHOTOS_EXPORT_DB
from ._version import __version__
from .export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB
from .fileutil import FileUtil
from .photosdb import PhotosDB
__all__ = [
"export_db_check_signatures",
"export_db_get_last_run",
"export_db_get_version",
"export_db_save_config_to_file",
"export_db_touch_files",
"export_db_update_signatures",
"export_db_vacuum",
]
def isotime_from_ts(ts: int) -> str:
"""Convert timestamp to ISO 8601 time string"""
return datetime.datetime.fromtimestamp(ts).isoformat()
def export_db_get_version(
dbfile: Union[str, pathlib.Path]
) -> Tuple[Optional[int], Optional[int]]:
"""returns version from export database as tuple of (osxphotos version, export_db version)"""
conn = sqlite3.connect(str(dbfile))
c = conn.cursor()
row = c.execute(
"SELECT osxphotos, exportdb FROM version ORDER BY id DESC LIMIT 1;"
).fetchone()
if row:
return (row[0], row[1])
return (None, None)
def export_db_vacuum(dbfile: Union[str, pathlib.Path]) -> None:
"""Vacuum export database"""
conn = sqlite3.connect(str(dbfile))
c = conn.cursor()
c.execute("VACUUM;")
conn.commit()
def export_db_update_signatures(
dbfile: Union[str, pathlib.Path],
export_dir: Union[str, pathlib.Path],
verbose: bool = False,
dry_run: bool = False,
) -> Tuple[int, int]:
"""Update signatures for all files found in the export database to match what's on disk
Returns: tuple of (updated, skipped)
"""
export_dir = pathlib.Path(export_dir)
fileutil = FileUtil
conn = sqlite3.connect(str(dbfile))
c = conn.cursor()
c.execute("SELECT filepath_normalized, filepath FROM export_data;")
rows = c.fetchall()
updated = 0
skipped = 0
for row in rows:
filepath_normalized = row[0]
filepath = row[1]
filepath = export_dir / filepath
if not os.path.exists(filepath):
skipped += 1
if verbose:
print(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'")
continue
updated += 1
file_sig = fileutil.file_sig(filepath)
if verbose:
print(f"[green]Updating signature for[/green]: '{filepath}'")
if not dry_run:
c.execute(
"UPDATE export_data SET dest_mode = ?, dest_size = ?, dest_mtime = ? WHERE filepath_normalized = ?;",
(file_sig[0], file_sig[1], file_sig[2], filepath_normalized),
)
if not dry_run:
conn.commit()
return (updated, skipped)
def export_db_get_last_run(
export_db: Union[str, pathlib.Path]
) -> Tuple[Optional[str], Optional[str]]:
"""Get last run from export database"""
conn = sqlite3.connect(str(export_db))
c = conn.cursor()
row = c.execute(
"SELECT datetime, args FROM runs ORDER BY id DESC LIMIT 1;"
).fetchone()
if row:
return row[0], row[1]
return None, None
def export_db_save_config_to_file(
export_db: Union[str, pathlib.Path], config_file: Union[str, pathlib.Path]
) -> None:
"""Save export_db last run config to file"""
export_db = pathlib.Path(export_db)
config_file = pathlib.Path(config_file)
conn = sqlite3.connect(str(export_db))
c = conn.cursor()
row = c.execute("SELECT config FROM config ORDER BY id DESC LIMIT 1;").fetchone()
if not row:
return ValueError("No config found in export_db")
with config_file.open("w") as f:
f.write(row[0])
def export_db_check_signatures(
dbfile: Union[str, pathlib.Path],
export_dir: Union[str, pathlib.Path],
verbose: bool = False,
) -> Tuple[int, int, int]:
"""Check signatures for all files found in the export database to verify what matches the on disk files
Returns: tuple of (updated, skipped)
"""
export_dir = pathlib.Path(export_dir)
fileutil = FileUtil
conn = sqlite3.connect(str(dbfile))
c = conn.cursor()
c.execute("SELECT filepath_normalized, filepath FROM export_data;")
rows = c.fetchall()
exportdb = ExportDB(dbfile, export_dir)
matched = 0
notmatched = 0
skipped = 0
for row in rows:
filepath_normalized = row[0]
filepath = row[1]
filepath = export_dir / filepath
if not filepath.exists():
skipped += 1
if verbose:
print(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'")
continue
file_sig = fileutil.file_sig(filepath)
file_rec = exportdb.get_file_record(filepath)
if file_rec.dest_sig == file_sig:
matched += 1
if verbose:
print(f"[green]Signatures matched[/green]: '{filepath}'")
else:
notmatched += 1
if verbose:
print(f"[deep_pink3]Signatures do not match[/deep_pink3]: '{filepath}'")
return (matched, notmatched, skipped)
def export_db_touch_files(
dbfile: Union[str, pathlib.Path],
export_dir: Union[str, pathlib.Path],
verbose: bool = False,
dry_run: bool = False,
) -> Tuple[int, int, int]:
"""Touch files on disk to match the Photos library created date
Returns: tuple of (touched, not_touched, skipped)
"""
export_dir = pathlib.Path(export_dir)
# open and close exportdb to ensure it gets migrated
exportdb = ExportDB(dbfile, export_dir)
upgraded = exportdb.was_upgraded
if upgraded and verbose:
print(
f"Upgraded export database {dbfile} from version {upgraded[0]} to {upgraded[1]}"
)
exportdb.close()
conn = sqlite3.connect(str(dbfile))
c = conn.cursor()
# get most recent config
row = c.execute("SELECT config FROM config ORDER BY id DESC LIMIT 1;").fetchone()
if row:
config = toml.loads(row[0])
try:
photos_db_path = config["export"].get("db", None)
except KeyError:
photos_db_path = None
else:
# TODO: parse the runs table to get the last --db
# in the mean time, photos_db_path = None will use the default library
photos_db_path = None
verbose_ = print if verbose else lambda *args, **kwargs: None
photosdb = PhotosDB(dbfile=photos_db_path, verbose=verbose_)
exportdb = ExportDB(dbfile, export_dir)
c.execute(
"SELECT filepath_normalized, filepath, uuid, dest_mode, dest_size FROM export_data;"
)
rows = c.fetchall()
touched = 0
not_touched = 0
skipped = 0
for row in rows:
filepath_normalized = row[0]
filepath = row[1]
filepath = export_dir / filepath
uuid = row[2]
dest_mode = row[3]
dest_size = row[4]
if not filepath.exists():
skipped += 1
if verbose:
print(
f"[dark_orange]Skipping missing file (not in export directory)[/dark_orange]: '{filepath}'"
)
continue
photo = photosdb.get_photo(uuid)
if not photo:
skipped += 1
if verbose:
print(
f"[dark_orange]Skipping missing photo (did not find in Photos Library)[/dark_orange]: '{filepath}' ({uuid})"
)
continue
ts = int(photo.date.timestamp())
stat = os.stat(str(filepath))
mtime = stat.st_mtime
if mtime == ts:
not_touched += 1
if verbose:
print(
f"[green]Skipping file (timestamp matches)[/green]: '{filepath}' [dodger_blue1]{isotime_from_ts(ts)} ({ts})[/dodger_blue1]"
)
continue
touched += 1
if verbose:
print(
f"[deep_pink3]Touching file[/deep_pink3]: '{filepath}' "
f"[dodger_blue1]{isotime_from_ts(mtime)} ({mtime}) -> {isotime_from_ts(ts)} ({ts})[/dodger_blue1]"
)
if not dry_run:
os.utime(str(filepath), (ts, ts))
rec = exportdb.get_file_record(filepath)
rec.dest_sig = (dest_mode, dest_size, ts)
return (touched, not_touched, skipped)

View File

@@ -7,13 +7,15 @@ import subprocess
import sys
from abc import ABC, abstractmethod
import CoreFoundation
import Foundation
from .imageconverter import ImageConverter
__all__ = ["FileUtilABC", "FileUtilMacOS", "FileUtil", "FileUtilNoOp"]
class FileUtilABC(ABC):
""" Abstract base class for FileUtil """
"""Abstract base class for FileUtil"""
@classmethod
@abstractmethod
@@ -67,14 +69,14 @@ class FileUtilABC(ABC):
class FileUtilMacOS(FileUtilABC):
""" Various file utilities """
"""Various file utilities"""
@classmethod
def hardlink(cls, src, dest):
""" Hardlinks a file from src path to dest path
src: source path as string
dest: destination path as string
Raises exception if linking fails or either path is None """
"""Hardlinks a file from src path to dest path
src: source path as string
dest: destination path as string
Raises exception if linking fails or either path is None"""
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
@@ -90,17 +92,17 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def copy(cls, src, dest):
""" Copies a file from src path to dest path
"""Copies a file from src path to dest path
Args:
src: source path as string; must be a valid file path
dest: destination path as string
dest may be either directory or file; in either case, src file must not exist in dest
Note: src and dest may be either a string or a pathlib.Path object
Returns:
True if copy succeeded
Raises:
OSError if copy fails
TypeError if either path is None
@@ -114,7 +116,7 @@ class FileUtilMacOS(FileUtilABC):
if dest.is_dir():
dest /= src.name
filemgr = CoreFoundation.NSFileManager.defaultManager()
filemgr = Foundation.NSFileManager.defaultManager()
error = filemgr.copyItemAtPath_toPath_error_(str(src), str(dest), None)
# error is a tuple of (bool, error_string)
# error[0] is True if copy succeeded
@@ -124,7 +126,7 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def unlink(cls, filepath):
""" unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink """
"""unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink"""
if isinstance(filepath, pathlib.Path):
filepath.unlink()
else:
@@ -132,7 +134,7 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def rmdir(cls, dirpath):
""" remove directory filepath; dirpath must be empty """
"""remove directory filepath; dirpath must be empty"""
if isinstance(dirpath, pathlib.Path):
dirpath.rmdir()
else:
@@ -140,8 +142,8 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def utime(cls, path, times):
""" Set the access and modified time of path. """
os.utime(path, times)
"""Set the access and modified time of path."""
os.utime(path, times=times)
@classmethod
def cmp(cls, f1, f2, mtime1=None):
@@ -152,7 +154,7 @@ class FileUtilMacOS(FileUtilABC):
mtime1 -- optional, pass alternate file modification timestamp for f1; will be converted to int
Return value:
True if the file signatures as returned by stat are the same, False otherwise.
True if the file signatures as returned by stat are the same, False otherwise.
Does not do a byte-by-byte comparison.
"""
@@ -179,27 +181,26 @@ class FileUtilMacOS(FileUtilABC):
return False
s1 = cls._sig(os.stat(f1))
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False
return s1 == s2
@classmethod
def file_sig(cls, f1):
""" return os.stat signature for file f1 """
"""return os.stat signature for file f1 as tuple of (mode, size, mtime)"""
return cls._sig(os.stat(f1))
@classmethod
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
""" converts image file src_file to jpeg format as dest_file
"""converts image file src_file to jpeg format as dest_file
Args:
src_file: image file to convert
dest_file: destination path to write converted file to
compression quality: JPEG compression quality in range 0.0 <= compression_quality <= 1.0; default 1.0 (best quality)
Returns:
True if success, otherwise False
Args:
src_file: image file to convert
dest_file: destination path to write converted file to
compression quality: JPEG compression quality in range 0.0 <= compression_quality <= 1.0; default 1.0 (best quality)
Returns:
True if success, otherwise False
"""
converter = ImageConverter()
return converter.write_jpeg(
@@ -208,40 +209,40 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def rename(cls, src, dest):
""" Copy src to dest
"""Copy src to dest
Args:
src: path to source file
dest: path to destination file
Returns:
Name of renamed file (dest)
"""
os.rename(str(src), str(dest))
return dest
@staticmethod
def _sig(st):
""" return tuple of (mode, size, mtime) of file based on os.stat
Args:
st: os.stat signature
"""return tuple of (mode, size, mtime) of file based on os.stat
Args:
st: os.stat signature
"""
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
class FileUtil(FileUtilMacOS):
""" Various file utilities """
"""Various file utilities"""
pass
class FileUtilNoOp(FileUtil):
""" No-Op implementation of FileUtil for testing / dry-run mode
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
cmp and cmp_file_sig functions as FileUtil methods do
file_cmp returns mock data
"""No-Op implementation of FileUtil for testing / dry-run mode
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
cmp and cmp_file_sig functions as FileUtil methods do
file_cmp returns mock data
"""
@staticmethod

View File

@@ -15,27 +15,29 @@ from Foundation import NSDictionary
# needed to capture system-level stderr
from wurlitzer import pipes
__all__ = ["ImageConversionError", "ImageConverter"]
class ImageConversionError(Exception):
"""Base class for exceptions in this module. """
"""Base class for exceptions in this module."""
pass
class ImageConverter:
""" Convert images to jpeg. This class is a singleton
which will re-use the Core Image CIContext to avoid
creating a new context for every conversion. """
"""Convert images to jpeg. This class is a singleton
which will re-use the Core Image CIContext to avoid
creating a new context for every conversion."""
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, "context"):
return
@@ -47,13 +49,10 @@ class ImageConverter:
"workingFormat": Quartz.kCIFormatRGBAh,
}
)
mtldevice = Metal.MTLCreateSystemDefaultDevice()
self.context = Quartz.CIContext.contextWithMTLDevice_options_(
mtldevice, context_options
)
self.context = Quartz.CIContext.contextWithOptions_(context_options)
def write_jpeg(self, input_path, output_path, compression_quality=1.0):
""" convert image to jpeg and write image to output_path
"""convert image to jpeg and write image to output_path
Args:
input_path: path to input image (e.g. '/path/to/import/file.CR2') as str or pathlib.Path
@@ -104,8 +103,11 @@ class ImageConverter:
if input_image is None:
raise ImageConversionError(f"Could not create CIImage for {input_path}")
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName(
Quartz.CoreGraphics.kCGColorSpaceSRGB
output_colorspace = (
input_image.colorSpace()
or Quartz.CGColorSpaceCreateWithName(
Quartz.CoreGraphics.kCGColorSpaceSRGB
)
)
output_options = NSDictionary.dictionaryWithDictionary_(
@@ -121,6 +123,5 @@ class ImageConverter:
return True
else:
raise ImageConversionError(
"Error converting file {input_path} to jpeg at {output_path}: {error}"
f"Error converting file {input_path} to jpeg at {output_path}: {error}"
)

70
osxphotos/momentinfo.py Normal file
View File

@@ -0,0 +1,70 @@
__all__ = ["MomentInfo"]
"""MomentInfo class with details about photo moments."""
class MomentInfo:
"""Info about a photo moment"""
def __init__(self, db, moment_pk):
"""Initialize with a moment PK; returns None if PK not found."""
self._db = db
self._pk = moment_pk
self._moment = self._db._db_moment_pk.get(moment_pk)
if not self._moment:
raise ValueError(f"No moment with PK {moment_pk}")
@property
def pk(self):
"""Primary key of the moment."""
return self._pk
@property
def location(self):
"""Location of the moment."""
return (self._moment.get("latitude"), self._moment.get("longitude"))
@property
def title(self):
"""Title of the moment."""
return self._moment.get("title")
@property
def subtitle(self):
"""Subtitle of the moment."""
return self._moment.get("subtitle")
@property
def start_date(self):
"""Start date of the moment."""
return self._moment.get("startDate")
@property
def end_date(self):
"""Stop date of the moment."""
return self._moment.get("endDate")
@property
def date(self):
"""Date of the moment."""
return self._moment.get("representativeDate")
@property
def modification_date(self):
"""Modification date of the moment."""
return self._moment.get("modificationDate")
@property
def photos(self):
"""All photos in this moment"""
try:
return self._photos
except AttributeError:
photo_uuids = [
uuid
for uuid, photo in self._db._dbphotos.items()
if photo["momentID"] == self._pk
]
self._photos = self._db.photos_by_uuid(photo_uuids)
return self._photos

View File

@@ -4,24 +4,32 @@ import pathvalidate
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
__all__ = [
"sanitize_filepath",
"is_valid_filepath",
"sanitize_filename",
"sanitize_dirname",
"sanitize_pathpart",
]
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 +54,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 +69,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

View File

@@ -6,6 +6,8 @@ import math
from collections import namedtuple
__all__ = ["PersonInfo", "FaceInfo", "rotate_image_point"]
MWG_RS_Area = namedtuple("MWG_RS_Area", ["x", "y", "h", "w"])
MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"])
@@ -51,7 +53,7 @@ class PersonInfo:
@property
def photos(self):
""" Returns list of PhotoInfo objects associated with this person """
"""Returns list of PhotoInfo objects associated with this person"""
return self._db.photos_by_uuid(self._db._dbfaces_pk[self._pk])
@property
@@ -71,7 +73,7 @@ class PersonInfo:
return []
def asdict(self):
""" Returns dictionary representation of class instance """
"""Returns dictionary representation of class instance"""
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
return {
"uuid": self.uuid,
@@ -83,7 +85,7 @@ class PersonInfo:
}
def json(self):
""" Returns JSON representation of class instance """
"""Returns JSON representation of class instance"""
return json.dumps(self.asdict())
def __str__(self):
@@ -141,7 +143,7 @@ class FaceInfo:
self.manual = face["manual"]
self.face_type = face["facetype"]
self.age_type = face["agetype"]
self.bald_type = face["baldtype"]
# self.bald_type = face["baldtype"]
self.eye_makeup_type = face["eyemakeuptype"]
self.eye_state = face["eyestate"]
self.facial_hair_type = face["facialhairtype"]
@@ -201,7 +203,7 @@ class FaceInfo:
@property
def person_info(self):
""" PersonInfo instance for person associated with this face """
"""PersonInfo instance for person associated with this face"""
try:
return self._person
except AttributeError:
@@ -210,7 +212,7 @@ class FaceInfo:
@property
def photo(self):
""" PhotoInfo instance associated with this face """
"""PhotoInfo instance associated with this face"""
try:
return self._photo
except AttributeError:
@@ -292,7 +294,7 @@ class FaceInfo:
return [(x0, y0), (x1, y1)]
def roll_pitch_yaw(self):
""" Roll, pitch, yaw of face in radians as tuple """
"""Roll, pitch, yaw of face in radians as tuple"""
info = self._info
roll = 0 if info["roll"] is None else info["roll"]
pitch = 0 if info["pitch"] is None else info["pitch"]
@@ -302,19 +304,19 @@ class FaceInfo:
@property
def roll(self):
""" Return roll angle in radians of the face region """
"""Return roll angle in radians of the face region"""
roll, _, _ = self.roll_pitch_yaw()
return roll
@property
def pitch(self):
""" Return pitch angle in radians of the face region """
"""Return pitch angle in radians of the face region"""
_, pitch, _ = self.roll_pitch_yaw()
return pitch
@property
def yaw(self):
""" Return yaw angle in radians of the face region """
"""Return yaw angle in radians of the face region"""
_, _, yaw = self.roll_pitch_yaw()
return yaw
@@ -402,7 +404,7 @@ class FaceInfo:
return (int(xr), int(yr))
def asdict(self):
""" Returns dict representation of class instance """
"""Returns dict representation of class instance"""
roll, pitch, yaw = self.roll_pitch_yaw()
return {
"_pk": self._pk,
@@ -438,7 +440,7 @@ class FaceInfo:
"manual": self.manual,
"face_type": self.face_type,
"age_type": self.age_type,
"bald_type": self.bald_type,
# "bald_type": self.bald_type,
"eye_makeup_type": self.eye_makeup_type,
"eye_state": self.eye_state,
"facial_hair_type": self.facial_hair_type,
@@ -451,7 +453,7 @@ class FaceInfo:
}
def json(self):
""" Return JSON representation of FaceInfo instance """
"""Return JSON representation of FaceInfo instance"""
return json.dumps(self.asdict())
def __str__(self):

2017
osxphotos/photoexporter.py Normal file

File diff suppressed because it is too large Load Diff

1767
osxphotos/photoinfo.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
"""
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
"""
from ._photoinfo_exifinfo import ExifInfo
from ._photoinfo_export import ExportResults
from ._photoinfo_scoreinfo import ScoreInfo
from .photoinfo import PhotoInfo

View File

@@ -1,17 +0,0 @@
""" PhotoInfo methods to expose comments and likes for shared photos """
@property
def comments(self):
""" Returns list of Comment objects for any comments on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["comments"]
except:
return []
@property
def likes(self):
""" Returns list of Like objects for any likes on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["likes"]
except:
return []

View File

@@ -1,94 +0,0 @@
""" PhotoInfo methods to expose EXIF info from the library """
import logging
from dataclasses import dataclass
from .._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ExifInfo:
""" EXIF info associated with a photo from the Photos library """
flash_fired: bool
iso: int
metering_mode: int
sample_rate: int
track_format: int
white_balance: int
aperture: float
bit_rate: float
duration: float
exposure_bias: float
focal_length: float
fps: float
latitude: float
longitude: float
shutter_speed: float
camera_make: str
camera_model: str
codec: str
lens_model: str
@property
def exif_info(self):
""" Returns an ExifInfo object with the EXIF data for photo
Note: the returned EXIF data is the data Photos stores in the database on import;
ExifInfo does not provide access to the EXIF info in the actual image file
Some or all of the fields may be None
Only valid for Photos 5; on earlier database returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"exif_info not implemented for this database version")
return None
try:
exif = self._db._db_exifinfo_uuid[self.uuid]
exif_info = ExifInfo(
iso=exif["ZISO"],
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
metering_mode=exif["ZMETERINGMODE"],
sample_rate=exif["ZSAMPLERATE"],
track_format=exif["ZTRACKFORMAT"],
white_balance=exif["ZWHITEBALANCE"],
aperture=exif["ZAPERTURE"],
bit_rate=exif["ZBITRATE"],
duration=exif["ZDURATION"],
exposure_bias=exif["ZEXPOSUREBIAS"],
focal_length=exif["ZFOCALLENGTH"],
fps=exif["ZFPS"],
latitude=exif["ZLATITUDE"],
longitude=exif["ZLONGITUDE"],
shutter_speed=exif["ZSHUTTERSPEED"],
camera_make=exif["ZCAMERAMAKE"],
camera_model=exif["ZCAMERAMODEL"],
codec=exif["ZCODEC"],
lens_model=exif["ZLENSMODEL"],
)
except KeyError:
logging.debug(f"Could not find exif record for uuid {self.uuid}")
exif_info = ExifInfo(
iso=None,
flash_fired=None,
metering_mode=None,
sample_rate=None,
track_format=None,
white_balance=None,
aperture=None,
bit_rate=None,
duration=None,
exposure_bias=None,
focal_length=None,
fps=None,
latitude=None,
longitude=None,
shutter_speed=None,
camera_make=None,
camera_model=None,
codec=None,
lens_model=None,
)
return exif_info

View File

@@ -1,34 +0,0 @@
""" Implementation for PhotoInfo.exiftool property which returns ExifTool object for a photo """
import logging
import os
from ..exiftool import ExifTool, get_exiftool_path
@property
def exiftool(self):
""" Returns an ExifTool object for the photo
requires that exiftool (https://exiftool.org/) be installed
If exiftool not installed, logs warning and returns None
If photo path is missing, returns None
"""
try:
# return the memoized instance if it exists
return self._exiftool
except AttributeError:
try:
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifTool(self.path, exiftool=exiftool_path)
else:
exiftool = None
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None
logging.warning(
f"exiftool not in path; download and install from https://exiftool.org/"
)
self._exiftool = exiftool
return self._exiftool

File diff suppressed because it is too large Load Diff

View File

@@ -1,119 +0,0 @@
""" PhotoInfo methods to expose computed score info from the library """
import logging
from dataclasses import dataclass
from .._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ScoreInfo:
""" Computed photo score info associated with a photo from the Photos library """
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float
@property
def score(self):
""" Computed score information for a photo
Returns:
ScoreInfo instance
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"score not implemented for this database version")
return None
try:
return self._scoreinfo # pylint: disable=access-member-before-definition
except AttributeError:
try:
scores = self._db._db_scoreinfo_uuid[self.uuid]
self._scoreinfo = ScoreInfo(
overall=scores["overall_aesthetic"],
curation=scores["curation"],
promotion=scores["promotion"],
highlight_visibility=scores["highlight_visibility"],
behavioral=scores["behavioral"],
failure=scores["failure"],
harmonious_color=scores["harmonious_color"],
immersiveness=scores["immersiveness"],
interaction=scores["interaction"],
interesting_subject=scores["interesting_subject"],
intrusive_object_presence=scores["intrusive_object_presence"],
lively_color=scores["lively_color"],
low_light=scores["low_light"],
noise=scores["noise"],
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
pleasant_composition=scores["pleasant_composition"],
pleasant_lighting=scores["pleasant_lighting"],
pleasant_pattern=scores["pleasant_pattern"],
pleasant_perspective=scores["pleasant_perspective"],
pleasant_post_processing=scores["pleasant_post_processing"],
pleasant_reflection=scores["pleasant_reflection"],
pleasant_symmetry=scores["pleasant_symmetry"],
sharply_focused_subject=scores["sharply_focused_subject"],
tastefully_blurred=scores["tastefully_blurred"],
well_chosen_subject=scores["well_chosen_subject"],
well_framed_subject=scores["well_framed_subject"],
well_timed_shot=scores["well_timed_shot"],
)
return self._scoreinfo
except KeyError:
self._scoreinfo = ScoreInfo(
overall=0.0,
curation=0.0,
promotion=0.0,
highlight_visibility=0.0,
behavioral=0.0,
failure=0.0,
harmonious_color=0.0,
immersiveness=0.0,
interaction=0.0,
interesting_subject=0.0,
intrusive_object_presence=0.0,
lively_color=0.0,
low_light=0.0,
noise=0.0,
pleasant_camera_tilt=0.0,
pleasant_composition=0.0,
pleasant_lighting=0.0,
pleasant_pattern=0.0,
pleasant_perspective=0.0,
pleasant_post_processing=0.0,
pleasant_reflection=0.0,
pleasant_symmetry=0.0,
sharply_focused_subject=0.0,
tastefully_blurred=0.0,
well_chosen_subject=0.0,
well_framed_subject=0.0,
well_timed_shot=0.0,
)
return self._scoreinfo

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
""" PhotosAlbum class to create an album in default Photos library and add photos to it """
from typing import Optional
from typing import List, Optional
import photoscript
from more_itertools import chunked
from .photoinfo import PhotoInfo
from .utils import noop
__all__ = ["PhotosAlbum"]
class PhotosAlbum:
def __init__(self, name: str, verbose: Optional[callable] = None):
@@ -25,50 +30,18 @@ class PhotosAlbum:
f"Added {photo.original_filename} ({photo.uuid}) to album {self.name}"
)
def add_list(self, photo_list: List[PhotoInfo]):
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}")
def photos(self):
return self.album.photos()
# def add_photo_to_album(photo, album_pairs, results):
# # todo: class PhotoAlbum
# # keeps a name, maintains state
# """ add photo to album(s) as defined in album_pairs
# Args:
# photo: PhotoInfo object
# album_pairs: list of tuples with [(album name, results_list)]
# results: ExportResults object
# Returns:
# updated ExportResults object
# """
# for album, result_list in album_pairs:
# try:
# if album_export is None:
# # first time fetching the album, see if it exists already
# album_export = photos_library.album(
# add_exported_to_album
# )
# if album_export is None:
# # album doesn't exist, so create it
# verbose_(
# f"Creating Photos album '{add_exported_to_album}'"
# )
# album_export = photos_library.create_album(
# add_exported_to_album
# )
# exported_photo = photoscript.Photo(p.uuid)
# album_export.add([exported_photo])
# verbose_(
# f"Added {p.original_filename} ({p.uuid}) to album {add_exported_to_album}"
# )
# exported_album = [
# (filename, add_exported_to_album)
# for filename in export_results.exported
# ]
# export_results.exported_album = exported_album
# if
# except Exception as e:
# click.echo(
# f"Error adding photo {p.original_filename} ({p.uuid}) to album {add_exported_to_album}"
# )

View File

@@ -10,9 +10,9 @@ from ..utils import _open_sql_file, normalize_unicode
def _process_comments(self):
""" load the comments and likes data from the database
this is a PhotosDB method that should be imported in
the PhotosDB class definition in photosdb.py
"""load the comments and likes data from the database
this is a PhotosDB method that should be imported in
the PhotosDB class definition in photosdb.py
"""
self._db_hashed_person_id = {}
self._db_comments_uuid = {}
@@ -24,7 +24,7 @@ def _process_comments(self):
@dataclass
class CommentInfo:
""" Class for shared photo comments """
"""Class for shared photo comments"""
datetime: datetime.datetime
user: str
@@ -37,7 +37,7 @@ class CommentInfo:
@dataclass
class LikeInfo:
""" Class for shared photo likes """
"""Class for shared photo likes"""
datetime: datetime.datetime
user: str
@@ -50,16 +50,16 @@ class LikeInfo:
# The following methods do not get imported into PhotosDB
# but will get called by _process_comments
def _process_comments_4(photosdb):
""" process comments and likes info for Photos <= 4
photosdb: PhotosDB instance """
"""process comments and likes info for Photos <= 4
photosdb: PhotosDB instance"""
raise NotImplementedError(
f"Not implemented for database version {photosdb._db_version}."
)
def _process_comments_5(photosdb):
""" process comments and likes info for Photos >= 5
photosdb: PhotosDB instance """
"""process comments and likes info for Photos >= 5
photosdb: PhotosDB instance"""
db = photosdb._tmp_db
@@ -70,12 +70,24 @@ def _process_comments_5(photosdb):
results = conn.execute(
"""
SELECT DISTINCT
ZINVITEEHASHEDPERSONID,
ZINVITEEFIRSTNAME,
ZINVITEELASTNAME,
ZINVITEEFULLNAME
FROM
ZCLOUDSHAREDALBUMINVITATIONRECORD
ZINVITEEHASHEDPERSONID AS HASHEDPERSONID,
ZINVITEEFIRSTNAME AS FIRSTNAME,
ZINVITEELASTNAME AS LASTNAME,
ZINVITEEFULLNAME AS FULLNAME
FROM ZCLOUDSHAREDALBUMINVITATIONRECORD
WHERE HASHEDPERSONID IS NOT NULL
AND HASHEDPERSONID != ""
AND NOT (FIRSTNAME IS NULL AND LASTNAME IS NULL)
UNION
SELECT DISTINCT
ZCLOUDOWNERHASHEDPERSONID AS HASHEDPERSONID,
ZCLOUDOWNERFIRSTNAME AS FIRSTNAME,
ZCLOUDOWNERLASTNAME AS LASTNAME,
ZCLOUDOWNERFULLNAME AS FULLNAME
FROM ZGENERICALBUM
WHERE HASHEDPERSONID IS NOT NULL
AND HASHEDPERSONID != ""
AND NOT (FIRSTNAME IS NULL AND LASTNAME IS NULL)
"""
)
@@ -148,10 +160,10 @@ def _process_comments_5(photosdb):
db_comments["comments"].append(CommentInfo(dt, user_name, ismine, text))
# sort results
for uuid in photosdb._db_comments_uuid:
for uuid, value in photosdb._db_comments_uuid.items():
if photosdb._db_comments_uuid[uuid]["likes"]:
photosdb._db_comments_uuid[uuid]["likes"].sort(key=lambda x: x.datetime)
if photosdb._db_comments_uuid[uuid]["comments"]:
photosdb._db_comments_uuid[uuid]["comments"].sort(key=lambda x: x.datetime)
value["comments"].sort(key=lambda x: x.datetime)
conn.close()

View File

@@ -4,13 +4,14 @@
import logging
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
from ..utils import _db_is_locked, _debug, _open_sql_file
from ..utils import _db_is_locked, _open_sql_file
from .photosdb_utils import get_db_version
def _process_exifinfo(self):
""" load the exif data from the database
this is a PhotosDB method that should be imported in
the PhotosDB class definition in photosdb.py
"""load the exif data from the database
this is a PhotosDB method that should be imported in
the PhotosDB class definition in photosdb.py
"""
if self._db_version <= _PHOTOS_4_VERSION:
_process_exifinfo_4(self)
@@ -23,20 +24,20 @@ def _process_exifinfo(self):
def _process_exifinfo_4(photosdb):
""" process exif info for Photos <= 4
photosdb: PhotosDB instance """
"""process exif info for Photos <= 4
photosdb: PhotosDB instance"""
photosdb._db_exifinfo_uuid = {}
raise NotImplementedError(f"search info not implemented for this database version")
def _process_exifinfo_5(photosdb):
""" process exif info for Photos >= 5
photosdb: PhotosDB instance """
"""process exif info for Photos >= 5
photosdb: PhotosDB instance"""
db = photosdb._tmp_db
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
(conn, cursor) = _open_sql_file(db)
result = conn.execute(

View File

@@ -22,8 +22,7 @@ from .photosdb_utils import get_db_version
def _process_faceinfo(self):
""" Process face information
"""
"""Process face information"""
self._db_faceinfo_pk = {}
self._db_faceinfo_uuid = {}
@@ -36,7 +35,7 @@ def _process_faceinfo(self):
def _process_faceinfo_4(photosdb):
""" Process face information for Photos 4 databases
"""Process face information for Photos 4 databases
Args:
photosdb: an OSXPhotosDB instance
@@ -146,7 +145,6 @@ def _process_faceinfo_4(photosdb):
# Photos 5 only
face["agetype"] = None
face["baldtype"] = None
face["eyemakeuptype"] = None
face["eyestate"] = None
face["facialhairtype"] = None
@@ -173,7 +171,7 @@ def _process_faceinfo_4(photosdb):
def _process_faceinfo_5(photosdb):
""" Process face information for Photos 5 databases
"""Process face information for Photos 5 databases
Args:
photosdb: an OSXPhotosDB instance
@@ -194,7 +192,7 @@ def _process_faceinfo_5(photosdb):
ZDETECTEDFACE.ZPERSON,
ZPERSON.ZFULLNAME,
ZDETECTEDFACE.ZAGETYPE,
ZDETECTEDFACE.ZBALDTYPE,
NULL, -- ZDETECTEDFACE.ZBALDTYPE (Removed in Monterey)
ZDETECTEDFACE.ZEYEMAKEUPTYPE,
ZDETECTEDFACE.ZEYESSTATE,
ZDETECTEDFACE.ZFACIALHAIRTYPE,
@@ -239,7 +237,7 @@ def _process_faceinfo_5(photosdb):
# 3 ZDETECTEDFACE.ZPERSON,
# 4 ZPERSON.ZFULLNAME,
# 5 ZDETECTEDFACE.ZAGETYPE,
# 6 ZDETECTEDFACE.ZBALDTYPE,
# 6 ZDETECTEDFACE.ZBALDTYPE, (Not available on Monterey)
# 7 ZDETECTEDFACE.ZEYEMAKEUPTYPE,
# 8 ZDETECTEDFACE.ZEYESSTATE,
# 9 ZDETECTEDFACE.ZFACIALHAIRTYPE,
@@ -284,7 +282,6 @@ def _process_faceinfo_5(photosdb):
face["person"] = person_pk
face["fullname"] = normalize_unicode(row[4])
face["agetype"] = row[5]
face["baldtype"] = row[6]
face["eyemakeuptype"] = row[7]
face["eyestate"] = row[8]
face["facialhairtype"] = row[9]

View File

@@ -22,8 +22,8 @@ from .photosdb_utils import get_db_version
def _process_scoreinfo(self):
""" Process computed photo scores
Note: Only works on Photos version == 5.0
"""Process computed photo scores
Note: Only works on Photos version == 5.0
"""
# _db_scoreinfo_uuid is dict in form {uuid: {score values}}
@@ -38,7 +38,7 @@ def _process_scoreinfo(self):
def _process_scoreinfo_5(photosdb):
""" Process computed photo scores for Photos 5 databases
"""Process computed photo scores for Photos 5 databases
Args:
photosdb: an OSXPhotosDB instance
@@ -147,4 +147,4 @@ def _process_scoreinfo_5(photosdb):
scores["well_timed_shot"] = row[27]
photosdb._db_scoreinfo_uuid[uuid] = scores
conn.close()
conn.close()

View File

@@ -10,7 +10,7 @@ import uuid as uuidlib
from pprint import pformat
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
from ..utils import _db_is_locked, _debug, _open_sql_file, normalize_unicode
from ..utils import _db_is_locked, _open_sql_file, normalize_unicode
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
@@ -35,10 +35,10 @@ from ..utils import _db_is_locked, _debug, _open_sql_file, normalize_unicode
def _process_searchinfo(self):
""" load machine learning/search term label info from a Photos library
db_connection: a connection to the SQLite database file containing the
search terms. In Photos 5, this is called psi.sqlite
Note: Only works on Photos version == 5.0 """
"""load machine learning/search term label info from a Photos library
db_connection: a connection to the SQLite database file containing the
search terms. In Photos 5, this is called psi.sqlite
Note: Only works on Photos version == 5.0"""
# _db_searchinfo_uuid is dict in form {uuid : [list of associated search info records]
self._db_searchinfo_uuid = _db_searchinfo_uuid = {}
@@ -139,23 +139,12 @@ def _process_searchinfo(self):
_db_searchinfo_labels[label] = [uuid]
_db_searchinfo_labels_normalized[label_norm] = [uuid]
if _debug():
logging.debug(
"_db_searchinfo_categories: \n" + pformat(self._db_searchinfo_categories)
)
logging.debug("_db_searchinfo_uuid: \n" + pformat(self._db_searchinfo_uuid))
logging.debug("_db_searchinfo_labels: \n" + pformat(self._db_searchinfo_labels))
logging.debug(
"_db_searchinfo_labels_normalized: \n"
+ pformat(self._db_searchinfo_labels_normalized)
)
conn.close()
@property
def labels(self):
""" return list of all search info labels found in the library """
"""return list of all search info labels found in the library"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return []
@@ -165,7 +154,7 @@ def labels(self):
@property
def labels_normalized(self):
""" return list of all normalized search info labels found in the library """
"""return list of all normalized search info labels found in the library"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return []
@@ -175,7 +164,7 @@ def labels_normalized(self):
@property
def labels_as_dict(self):
""" return labels as dict of label: count in reverse sorted order (descending) """
"""return labels as dict of label: count in reverse sorted order (descending)"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return dict()
@@ -187,7 +176,7 @@ def labels_as_dict(self):
@property
def labels_normalized_as_dict(self):
""" return normalized labels as dict of label: count in reverse sorted order (descending) """
"""return normalized labels as dict of label: count in reverse sorted order (descending)"""
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return dict()
@@ -201,8 +190,8 @@ def labels_normalized_as_dict(self):
@lru_cache(maxsize=128)
def ints_to_uuid(uuid_0, uuid_1):
""" convert two signed ints into a UUID strings
uuid_0, uuid_1: the two int components of an RFC 4122 UUID """
"""convert two signed ints into a UUID strings
uuid_0, uuid_1: the two int components of an RFC 4122 UUID"""
# assumes uuid imported as uuidlib (to avoid namespace conflict with other uses of uuid)

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,32 @@
""" utility functions used by PhotosDB """
import logging
import pathlib
import plistlib
from .._constants import (
_PHOTOS_2_VERSION,
_PHOTOS_3_VERSION,
_PHOTOS_4_VERSION,
_PHOTOS_5_MODEL_VERSION,
_PHOTOS_5_VERSION,
_PHOTOS_6_MODEL_VERSION,
_PHOTOS_7_MODEL_VERSION,
_TESTED_DB_VERSIONS,
)
from ..utils import _open_sql_file
__all__ = [
"get_db_version",
"get_model_version",
"get_db_model_version",
"UnknownLibraryVersion",
"get_photos_library_version",
]
def get_db_version(db_file):
""" Gets the Photos DB version from LiGlobals table
"""Gets the Photos DB version from LiGlobals table
Args:
db_file: path to photos.db database file containing LiGlobals table
@@ -39,11 +53,11 @@ def get_db_version(db_file):
def get_model_version(db_file):
""" Returns the database model version from Z_METADATA
"""Returns the database model version from Z_METADATA
Args:
db_file: path to Photos.sqlite database file containing Z_METADATA table
Returns: model version as str
"""
@@ -62,23 +76,51 @@ def get_model_version(db_file):
def get_db_model_version(db_file):
""" Returns Photos version based on model version found in db_file
"""Returns Photos version based on model version found in db_file
Args:
db_file: path to Photos.sqlite file
Returns: int of major Photos version number (e.g. 5 or 6).
If unknown model version found, logs warning and returns most current Photos version.
"""
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 7
return db_ver
class UnknownLibraryVersion(Exception):
pass
def get_photos_library_version(library_path):
"""Return int indicating which Photos version a library was created with"""
library_path = pathlib.Path(library_path)
db_ver = get_db_version(str(library_path / "database" / "photos.db"))
db_ver = int(db_ver)
if db_ver == int(_PHOTOS_2_VERSION):
return 2
if db_ver == int(_PHOTOS_3_VERSION):
return 3
if db_ver == int(_PHOTOS_4_VERSION):
return 4
# assume it's a Photos 5+ library, get the model version to determine which version
model_ver = get_model_version(str(library_path / "database" / "Photos.sqlite"))
model_ver = int(model_ver)
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
return 5
if _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
return 6
if _PHOTOS_7_MODEL_VERSION[0] <= model_ver <= _PHOTOS_7_MODEL_VERSION[1]:
return 7
raise UnknownLibraryVersion(f"db_ver = {db_ver}, model_ver = {model_ver}")

View File

@@ -1,4 +1,4 @@
The templating system converts one or template statements, written in osxphotos templating language, to one or more rendered values using information from the photo being processed.
The templating system converts one or template statements, written in osxphotos metadata templating language, to one or more rendered values using information from the photo being processed.
In its simplest form, a template statement has the form: `"{template_field}"`, for example `"{title}"` which would resolve to the title of the photo.
@@ -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 -->

View File

@@ -1,22 +1,41 @@
""" Custom template system for osxphotos, implements osxphotos template language (OTL) """
""" Custom template system for osxphotos, implements metadata template language (MTL) """
import datetime
import json
import locale
import logging
import os
import pathlib
import shlex
import sys
from dataclasses import dataclass
from typing import Optional
from textx import TextXSyntaxError, metamodel_from_file
from ._constants import _UNKNOWN_PERSON
from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD
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 .text_detection import detect_text
from .utils import expand_and_validate_filepath, load_function
__all__ = [
"RenderOptions",
"PhotoTemplateParser",
"PhotoTemplate",
"parse_default_kv",
"get_template_help",
"format_str_value",
]
# 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, "")
OTL_GRAMMAR_MODEL = str(pathlib.Path(__file__).parent / "phototemplate.tx")
MTL_GRAMMAR_MODEL = str(pathlib.Path(__file__).parent / "phototemplate.tx")
"""TextX metamodel for osxphotos template language """
@@ -120,6 +139,29 @@ TEMPLATE_SUBSTITUTIONS = {
"{exif.camera_model}": "Camera model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s'",
"{exif.lens_model}": "Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'",
"{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'",
"{id}": "A unique number for the photo based on its primary key in the Photos database. "
+ "A sequential integer, e.g. 1, 2, 3...etc. Each asset associated with a photo (e.g. an image and Live Photo preview) will share the same id. "
+ "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in "
+ "00001, 00002, 00003...etc. ",
"{album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album. "
+ "Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. "
+ 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. '
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. "
+ "For example, to start counting at 1 instead of 0: '{album_seq.1}'. "
+ "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in "
+ "00000, 00001, 00002...etc. "
+ "This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'.",
"{folder_album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. "
+ "Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. "
+ 'For example \'--directory "{folder_album}" --filename "{folder_album_seq}_{original_name}"\'. '
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. "
+ "For example, to start counting at 1 instead of 0: '{folder_album_seq.1}' "
+ "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in "
+ "00000, 00001, 00002...etc. "
+ "This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'.",
"{comma}": "A comma: ','",
"{semicolon}": "A semicolon: ';'",
"{questionmark}": "A question mark: '?'",
@@ -134,12 +176,22 @@ TEMPLATE_SUBSTITUTIONS = {
"{lf}": r"A line feed: '\n', alias for {newline}",
"{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",
}
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)
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{album}": "Album(s) photo is contained in",
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
"{project}": "Project(s) photo is contained in (such as greeting cards, calendars, slideshows)",
"{album_project}": "Album(s) and project(s) photo is contained in; treats projects as regular albums",
"{folder_album_project}": "Folder path + album (includes projects as albums) photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
"{keyword}": "Keyword(s) assigned to photo",
"{person}": "Person(s) / face(s) in a photo",
"{label}": "Image categorization label associated with a photo (Photos 5+ only). "
@@ -160,6 +212,15 @@ 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.",
"{detected_text}": "List of text strings found in the image after performing text detection. "
+ "Using '{detected_text}' will cause osxphotos to perform text detection on your photos using the built-in macOS text detection algorithms which will slow down your export. "
+ "The results for each photo will be cached in the export database so that future exports with '--update' do not need to reprocess each photo. "
+ "You may pass a confidence threshold value between 0.0 and 1.0 after a colon as in '{detected_text:0.5}'; "
+ f"The default confidence threshold is {TEXT_DETECTION_CONFIDENCE_THRESHOLD}. "
+ "'{detected_text}' works only on macOS Catalina (10.15) or later. "
+ "Note: this feature is not the same thing as Live Text in macOS Monterey, which osxphotos does not yet support.",
"{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.",
"{strip}": "Use in form '{strip,TEMPLATE}'; strips whitespace from begining and end of rendered TEMPLATE value(s).",
"{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. "
@@ -175,7 +236,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
@@ -183,13 +245,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 = ","
@@ -213,36 +280,76 @@ 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
dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
quote: quote path templates for execution in the shell
"""
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
dest_path: 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
self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False)
self.metamodel = metamodel_from_file(MTL_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)
def fields(self, template_statement):
"""Return list of fields found in a template statement; does not verify that fields are valid"""
model = self.parse(template_statement)
return [ts.template.field for ts in model.template_strings if ts.template]
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.
@@ -258,49 +365,56 @@ 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.options = options
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
self.dest_path = options.dest_path
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.options = options
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.dest_path = options.dest_path
self.filepath = options.filepath
self.quote = options.quote
self.dest_path = options.dest_path
try:
model = self.parser.parse(template)
except TextXSyntaxError as e:
@@ -310,53 +424,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
]
@@ -366,16 +456,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 []
@@ -383,7 +468,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
@@ -410,12 +496,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:
@@ -431,12 +512,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:
@@ -453,12 +529,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:
@@ -470,14 +541,16 @@ class PhotoTemplate:
conditional_value = []
vals = []
if field in SINGLE_VALUE_SUBSTITUTIONS:
if (
field in SINGLE_VALUE_SUBSTITUTIONS
or field.split(".")[0] in SINGLE_VALUE_SUBSTITUTIONS
):
vals = self.get_template_value(
field,
default=default,
delim=delim or inplace_sep,
path_sep=path_sep,
filename=filename,
dirname=dirname,
subfield=subfield,
# delim=delim or self.inplace_sep,
# path_sep=path_sep,
)
elif field == "exiftool":
if subfield is None:
@@ -485,7 +558,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:
@@ -493,21 +566,23 @@ 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, subfield, 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
vals = [sep.join(sorted(vals))]
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))] if vals else []
for filter_ in filters:
vals = self.get_template_value_filter(filter_, vals)
@@ -527,7 +602,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:
@@ -542,18 +617,14 @@ 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}"
)
try:
match = (
True
if test_function(
float(vals[0]), float(conditional_value[0])
)
else False
match = bool(
test_function(float(vals[0]), float(conditional_value[0]))
)
if (match and not negation) or (negation and not match):
return ["True"]
@@ -603,7 +674,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 ""
@@ -628,31 +699,30 @@ class PhotoTemplate:
self,
field,
default,
bool_val=None,
delim=None,
path_sep=None,
filename=False,
dirname=False,
subfield=None,
# 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
subfield: subfield (value after : in field)
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
if field not in FIELD_NAMES:
raise ValueError(f"SyntaxError: Unknown field: {field}")
if self.photo.uuid is None:
return []
# initialize today with current date/time if needed
if self.today is None:
@@ -906,15 +976,73 @@ class PhotoTemplate:
value = self.photo.exif_info.lens_model if self.photo.exif_info else None
elif field == "uuid":
value = self.photo.uuid
elif field == "id":
value = format_str_value(self.photo._info["pk"], subfield)
elif field.startswith("album_seq") or field.startswith("folder_album_seq"):
dest_path = self.dest_path
if not dest_path:
value = None
else:
if field.startswith("album_seq"):
album = pathlib.Path(dest_path).name
album_info = _get_album_by_name(self.photo, album)
else:
album_info = _get_album_by_path(self.photo, dest_path)
value = album_info.photo_index(self.photo) if album_info else None
if value is not None:
try:
start_id = field.split(".", 1)
value = int(value) + int(start_id[1])
except IndexError:
pass
value = format_str_value(value, subfield)
elif field in PUNCTUATION:
value = PUNCTUATION[field]
elif field == "osxphotos_version":
value = __version__
elif field == "osxphotos_cmd_line":
value = " ".join(sys.argv)
else:
# 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)
# ensure no empty strings in value (see #512)
value = None if value == "" else 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]
@@ -960,20 +1088,26 @@ 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, subfield, path_sep, default):
"""lookup value for template field (multi-value template substitutions)
Args:
field: template field to find value for.
subfield: the template subfield value
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 [].
@@ -982,9 +1116,18 @@ 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
elif field == "project":
values = [p.title for p in self.photo.project_info]
elif field == "album_project":
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
values += [p.title for p in self.photo.project_info]
elif field == "keyword":
values = self.photo.keywords
elif field == "person":
@@ -995,17 +1138,19 @@ class PhotoTemplate:
values = self.photo.labels
elif field == "label_normalized":
values = self.photo.labels_normalized
elif field == "folder_album":
elif field in ["folder_album", "folder_album_project"]:
values = []
# photos must be in an album to be in a folder
if self.photo.burst:
album_info = self.photo.burst_album_info
else:
album_info = self.photo.album_info
if field == "folder_album_project":
album_info += self.photo.project_info
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
@@ -1015,12 +1160,10 @@ class PhotoTemplate:
folder = path_sep.join(album.folder_names)
folder += path_sep + album.title
values.append(folder)
elif self.dirname:
values.append(sanitize_dirname(album.title))
else:
# album not in folder
if dirname:
values.append(sanitize_dirname(album.title))
else:
values.append(album.title)
values.append(album.title)
elif field == "comment":
values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments
@@ -1035,6 +1178,10 @@ 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 == "strip":
values = [v.strip() for v in default]
elif field.startswith("photo"):
# provide access to PhotoInfo object
properties = field.split(".")
@@ -1060,14 +1207,16 @@ class PhotoTemplate:
elif isinstance(obj, (str, int, float)):
values = [str(obj)]
else:
values = [val for val in obj]
values = list(obj)
elif field == "detected_text":
values = _get_detected_text(self.photo, confidence=subfield)
else:
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 not in ["folder_album", "folder_album_project"]:
# skip folder_album because it would have been handled above
values = [sanitize_dirname(value) for value in values]
@@ -1075,9 +1224,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 []
@@ -1090,17 +1245,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(
@@ -1109,11 +1267,12 @@ 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)
values = template_func(self.photo)
template_func = load_function(filename_validated, funcname)
values = template_func(self.photo, options=self.options)
if not isinstance(values, (str, list)):
raise TypeError(
@@ -1123,17 +1282,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(
@@ -1142,10 +1302,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]
@@ -1158,9 +1319,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"]
@@ -1168,7 +1328,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:
@@ -1202,7 +1362,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'
@@ -1227,9 +1387,85 @@ 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}")
def format_str_value(value, format_str):
"""Format value based on format code in field in format id:02d"""
if not format_str:
return str(value)
format_str = "{0:" + f"{format_str}" + "}"
return format_str.format(value)
def _get_album_by_name(photo, album):
"""Finds first album named album that photo is in and returns the AlbumInfo object, otherwise returns None"""
for album_info in photo.album_info:
if album_info.title == album:
return album_info
return None
def _get_album_by_path(photo, folder_album_path):
"""finds the first album whose folder_album path matches and folder_album_path and returns the AlbumInfo object, otherwise, returns None"""
for album_info in photo.album_info:
# following code is how {folder_album} builds the folder path
folder = "/".join(sanitize_dirname(f) for f in album_info.folder_names)
folder += "/" + sanitize_dirname(album_info.title)
if folder_album_path.endswith(folder):
return album_info
return None
def _get_detected_text(photo, confidence=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
"""Returns the detected text for a photo
{detected_text} uses this instead of PhotoInfo.detected_text() to cache the text for all confidence values
"""
if not photo.isphoto:
return []
confidence = (
float(confidence)
if confidence is not None
else TEXT_DETECTION_CONFIDENCE_THRESHOLD
)
# _detected_text caches the text detection results in an extended attribute
# so the first time this gets called is slow but repeated accesses are fast
detected_text = photo._detected_text()
return [text for text, conf in detected_text if conf >= confidence]

View File

@@ -1,4 +1,4 @@
// OSXPhotos Template Language (OTL)
// OSXPhotos Metadata Template Language (MTL)
// a TemplateString has format:
// pre{delim+template_field:subfield|filter(path_sep)[find,replace] conditional?bool_value,default}post
// a TemplateStatement may contain zero or more TemplateStrings
@@ -63,7 +63,8 @@ SubField:
;
SUBFIELD_WORD:
/[\.\w:\/]+/
/[\.\w:\/\-\~\'\"\%\@\#\^\]+/
/\\\s/?
;
Filter:
@@ -98,7 +99,7 @@ OPERATOR:
PathSep:
(
"("
(value=/[^\(\)\{\}]{0,1}/)?
(value=/[^\(\)\{\}]+/)?
")"
)?
;

View File

@@ -14,6 +14,16 @@ from bpylist import archiver
from ._constants import UNICODE_FORMAT
from .utils import normalize_unicode
__all__ = [
"PLRevGeoLocationInfo",
"PLRevGeoMapItem",
"PLRevGeoMapItemAdditionalPlaceInfo",
"CNPostalAddress",
"PlaceInfo",
"PlaceInfo4",
"PlaceInfo5",
]
# postal address information, returned by PlaceInfo.address
PostalAddress = namedtuple(
"PostalAddress",
@@ -65,7 +75,7 @@ PlaceNames = namedtuple(
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
# These classes are used by bpylist.archiver to unarchive the serialized objects
class PLRevGeoLocationInfo:
""" The top level reverse geolocation object """
"""The top level reverse geolocation object"""
def __init__(
self,
@@ -147,7 +157,7 @@ class PLRevGeoLocationInfo:
class PLRevGeoMapItem:
""" Stores the list of place names, organized by area """
"""Stores the list of place names, organized by area"""
def __init__(self, sortedPlaceInfos, finalPlaceInfos):
self.sortedPlaceInfos = sortedPlaceInfos
@@ -182,7 +192,7 @@ class PLRevGeoMapItem:
class PLRevGeoMapItemAdditionalPlaceInfo:
""" Additional info about individual places """
"""Additional info about individual places"""
def __init__(self, area, name, placeType, dominantOrderType):
self.area = area
@@ -221,7 +231,7 @@ class PLRevGeoMapItemAdditionalPlaceInfo:
class CNPostalAddress:
""" postal address for the reverse geolocation info """
"""postal address for the reverse geolocation info"""
def __init__(
self,
@@ -354,17 +364,17 @@ class PlaceInfo(ABC):
class PlaceInfo4(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos <= 4) """
"""Reverse geolocation place info for a photo (Photos <= 4)"""
def __init__(self, place_names, country_code):
""" place_names: list of place name tuples in ascending order by area
tuple fields are: modelID, place name, place type, area, e.g.
[(5, "St James's Park", 45, 0),
(4, 'Westminster', 16, 22097376),
(3, 'London', 4, 1596146816),
(2, 'England', 2, 180406091776),
(1, 'United Kingdom', 1, 414681432064)]
country_code: two letter country code for the country
"""place_names: list of place name tuples in ascending order by area
tuple fields are: modelID, place name, place type, area, e.g.
[(5, "St James's Park", 45, 0),
(4, 'Westminster', 16, 22097376),
(3, 'London', 4, 1596146816),
(2, 'England', 2, 180406091776),
(1, 'United Kingdom', 1, 414681432064)]
country_code: two letter country code for the country
"""
self._place_names = place_names
self._country_code = country_code
@@ -404,7 +414,7 @@ class PlaceInfo4(PlaceInfo):
)
def _process_place_info(self):
""" Process place_names to set self._name and self._names """
"""Process place_names to set self._name and self._names"""
places = self._place_names
# build a dictionary where key is placetype
@@ -500,38 +510,38 @@ class PlaceInfo4(PlaceInfo):
class PlaceInfo5(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos >= 5) """
"""Reverse geolocation place info for a photo (Photos >= 5)"""
def __init__(self, revgeoloc_bplist):
""" revgeoloc_bplist: a binary plist blob containing
a serialized PLRevGeoLocationInfo object """
"""revgeoloc_bplist: a binary plist blob containing
a serialized PLRevGeoLocationInfo object"""
self._bplist = revgeoloc_bplist
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
self._process_place_info()
@property
def address_str(self):
""" returns the postal address as a string """
"""returns the postal address as a string"""
return self._plrevgeoloc.addressString
@property
def country_code(self):
""" returns the country code """
"""returns the country code"""
return self._plrevgeoloc.countryCode
@property
def ishome(self):
""" returns True if place is user's home address """
"""returns True if place is user's home address"""
return self._plrevgeoloc.isHome
@property
def name(self):
""" returns local place name """
"""returns local place name"""
return self._name
@property
def names(self):
""" returns PlaceNames tuple with detailed reverse geolocation place names """
"""returns PlaceNames tuple with detailed reverse geolocation place names"""
return self._names
@property
@@ -556,7 +566,7 @@ class PlaceInfo5(PlaceInfo):
return postal_address
def _process_place_info(self):
""" Process sortedPlaceInfos to set self._name and self._names """
"""Process sortedPlaceInfos to set self._name and self._names"""
places = self._plrevgeoloc.mapItem.sortedPlaceInfos
# build a dictionary where key is placetype

132
osxphotos/pyrepl.py Normal file
View File

@@ -0,0 +1,132 @@
__all__ = ["PyReplQuitter", "embed_repl"]
""" Custom Python REPL based on ptpython that allows quitting with custom keywords instead of `quit()` """
""" This file is distributed under the same license as the ptpython package:
Copyright (c) 2015, Jonathan Slenders (ptpython), (c) 2021 Rhet Turnbull (this file)
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
* Neither the name of the {organization} nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import sys
from typing import Callable, List, Optional
from ptpython.repl import (
ContextManager,
DummyContext,
PythonRepl,
builtins,
patch_stdout_context,
)
class PyReplQuitter(PythonRepl):
"""Custom pypython repl that allows quitting REPL with custom commands"""
def __init__(self, *args, quit_words: Optional[List[str]] = None, **kwargs):
super().__init__(*args, **kwargs)
self.quit_words = quit_words or ["quit", "q"]
def eval(self, line: str) -> object:
if line.strip() in self.quit_words:
sys.exit(0)
return super().eval(line)
def embed_repl(
globals=None,
locals=None,
configure: Optional[Callable[[PythonRepl], None]] = None,
vi_mode: bool = False,
history_filename: Optional[str] = None,
title: Optional[str] = None,
startup_paths=None,
patch_stdout: bool = False,
return_asyncio_coroutine: bool = False,
quit_words: Optional[List[str]] = None,
) -> None:
"""
Call this to embed Python shell at the current point in your program.
It's similar to `IPython.embed` and `bpython.embed`. ::
from prompt_toolkit.contrib.repl import embed
embed(globals(), locals())
:param vi_mode: Boolean. Use Vi instead of Emacs key bindings.
:param configure: Callable that will be called with the `PythonRepl` as a first
argument, to trigger configuration.
:param title: Title to be displayed in the terminal titlebar. (None or string.)
:param patch_stdout: When true, patch `sys.stdout` so that background
threads that are printing will print nicely above the prompt.
"""
# Default globals/locals
if globals is None:
globals = {
"__name__": "__main__",
"__package__": None,
"__doc__": None,
"__builtins__": builtins,
}
locals = locals or globals
def get_globals():
return globals
def get_locals():
return locals
# Create REPL.
repl = PyReplQuitter(
get_globals=get_globals,
get_locals=get_locals,
vi_mode=vi_mode,
history_filename=history_filename,
startup_paths=startup_paths,
quit_words=quit_words,
)
if title:
repl.terminal_title = title
if configure:
configure(repl)
# Start repl.
patch_context: ContextManager = (
patch_stdout_context() if patch_stdout else DummyContext()
)
if return_asyncio_coroutine:
async def coroutine():
with patch_context:
await repl.run_async()
return coroutine()
else:
with patch_context:
repl.run()

View File

@@ -0,0 +1,5 @@
# Query templates
This folder contains sql query templates for getting various photo properties
The query templates must be rendered with mako (see query_builder.py)

View File

@@ -0,0 +1,4 @@
-- Get owner name for shared iCloud album
SELECT ZGENERICALBUM.ZCLOUDOWNERFULLNAME AS OWNER_FULLNAME
FROM ZGENERICALBUM
WHERE ZGENERICALBUM.ZUUID = '${uuid}'

View File

@@ -0,0 +1,23 @@
-- Get the owner name of person who owns a photo in a shared album
--
-- Case where someone has invited you to a shared album
-- Need to get the owner of the shared album
SELECT DISTINCT
ZGENERICALBUM.ZCLOUDOWNERFULLNAME as OWNER_FULLNAME
FROM ZGENERICALBUM
JOIN ${asset_table} ON ${asset_table}.ZCLOUDOWNERHASHEDPERSONID = ZGENERICALBUM.ZCLOUDOWNERHASHEDPERSONID
WHERE ${asset_table}.ZUUID = "${uuid}"
AND ZGENERICALBUM.ZCLOUDOWNERHASHEDPERSONID IS NOT NULL
AND ZGENERICALBUM.ZCLOUDOWNERHASHEDPERSONID != ""
AND OWNER_FULLNAME != "(null) (null)"
UNION
-- Case where you have invited someone to a shared album
-- Need to get the data for person who was invited to the album
SELECT DISTINCT
ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEFULLNAME AS OWNER_FULLNAME
FROM ZCLOUDSHAREDALBUMINVITATIONRECORD
JOIN ${asset_table} ON ${asset_table}.ZCLOUDOWNERHASHEDPERSONID = ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEHASHEDPERSONID
WHERE ${asset_table}.ZUUID = "${uuid}"
AND ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEHASHEDPERSONID IS NOT NULL
AND ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEHASHEDPERSONID != ""
AND OWNER_FULLNAME != "(null) (null)"

View File

@@ -0,0 +1,6 @@
-- Get title of a photo with given UUID
SELECT
ZADDITIONALASSETATTRIBUTES.ZTITLE
FROM ZADDITIONALASSETATTRIBUTES
JOIN ${asset_table} ON ${asset_table}.Z_PK = ZADDITIONALASSETATTRIBUTES.ZASSET
WHERE ${asset_table}.ZUUID = "${uuid}"

View File

@@ -0,0 +1,38 @@
"""Build sql queries from template to retrieve info from the database"""
import os.path
import pathlib
from functools import lru_cache
from mako.template import Template
from ._constants import _DB_TABLE_NAMES
__all__ = ["get_query"]
QUERY_DIR = os.path.join(os.path.dirname(__file__), "queries")
def get_query(query_name, photos_ver, **kwargs):
"""Return sqlite query string for an attribute and a given database version"""
# there can be a single query for multiple database versions or separate queries for each version
# try generic version first (most common case), if that fails, look for version specific query
query_string = _get_query_string(query_name, photos_ver)
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
query_template = Template(query_string)
return query_template.render(asset_table=asset_table, **kwargs)
@lru_cache(maxsize=None)
def _get_query_string(query_name, photos_ver):
"""Return sqlite query string for an attribute and a given database version"""
query_file = pathlib.Path(QUERY_DIR) / f"{query_name}.sql.mako"
if not query_file.is_file():
query_file = pathlib.Path(QUERY_DIR) / f"{query_name}_{photos_ver}.sql.mako"
if not query_file.is_file():
raise FileNotFoundError(f"Query file '{query_file}' not found")
with open(query_file, "r") as f:
query_string = f.read()
return query_string

View File

@@ -1,10 +1,13 @@
""" 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
__all__ = ["QueryOptions"]
@dataclass
class QueryOptions:
@@ -30,7 +33,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 +81,12 @@ 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
selected: Optional[bool] = None
exif: Optional[Iterable[Tuple[str, str]]] = None
def asdict(self):
return asdict(self)

40
osxphotos/scoreinfo.py Normal file
View File

@@ -0,0 +1,40 @@
""" ScoreInfo class to expose computed score info from the library """
from dataclasses import dataclass
from ._constants import _PHOTOS_4_VERSION
__all__ = ["ScoreInfo"]
@dataclass(frozen=True)
class ScoreInfo:
"""Computed photo score info associated with a photo from the Photos library"""
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float

View File

@@ -1,98 +1,41 @@
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
Adds the following properties to PhotoInfo (valid only for Photos 5):
search_info: returns a SearchInfo object
search_info_normalized: returns a SearchInfo object with properties that produce normalized results
labels: returns list of labels
labels_normalized: returns list of normalized labels
""" class for PhotoInfo exposing SearchInfo data such as labels
"""
from .._constants import (
from ._constants import (
_PHOTOS_4_VERSION,
SEARCH_CATEGORY_ACTIVITY,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_BODY_OF_WATER,
SEARCH_CATEGORY_CITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_HOLIDAY,
SEARCH_CATEGORY_LABEL,
SEARCH_CATEGORY_MEDIA_TYPES,
SEARCH_CATEGORY_MONTH,
SEARCH_CATEGORY_NEIGHBORHOOD,
SEARCH_CATEGORY_PLACE_NAME,
SEARCH_CATEGORY_STREET,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_SEASON,
SEARCH_CATEGORY_STATE,
SEARCH_CATEGORY_STATE_ABBREVIATION,
SEARCH_CATEGORY_BODY_OF_WATER,
SEARCH_CATEGORY_MONTH,
SEARCH_CATEGORY_YEAR,
SEARCH_CATEGORY_HOLIDAY,
SEARCH_CATEGORY_ACTIVITY,
SEARCH_CATEGORY_SEASON,
SEARCH_CATEGORY_STREET,
SEARCH_CATEGORY_VENUE,
SEARCH_CATEGORY_VENUE_TYPE,
SEARCH_CATEGORY_MEDIA_TYPES,
SEARCH_CATEGORY_YEAR,
)
@property
def search_info(self):
""" returns SearchInfo object for photo
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info
except AttributeError:
self._search_info = SearchInfo(self)
return self._search_info
@property
def search_info_normalized(self):
""" returns SearchInfo object for photo that produces normalized results
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info_normalized
except AttributeError:
self._search_info_normalized = SearchInfo(self, normalized=True)
return self._search_info_normalized
@property
def labels(self):
""" returns list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels
@property
def labels_normalized(self):
""" returns normalized list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info_normalized.labels
__all__ = ["SearchInfo"]
class SearchInfo:
""" Info about search terms such as machine learning labels that Photos knows about a photo """
"""Info about search terms such as machine learning labels that Photos knows about a photo"""
def __init__(self, photo, normalized=False):
""" photo: PhotoInfo object
normalized: if True, all properties return normalized (lower case) results """
"""photo: PhotoInfo object
normalized: if True, all properties return normalized (lower case) results"""
if photo._db._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
"search info not implemented for this database version"
)
self._photo = photo
@@ -107,27 +50,27 @@ class SearchInfo:
@property
def labels(self):
""" return list of labels associated with Photo """
"""return list of labels associated with Photo"""
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
@property
def place_names(self):
""" returns list of place names """
"""returns list of place names"""
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
@property
def streets(self):
""" returns list of street names """
"""returns list of street names"""
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
@property
def neighborhoods(self):
""" returns list of neighborhoods """
"""returns list of neighborhoods"""
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
@property
def locality_names(self):
""" returns list of other locality names """
"""returns list of other locality names"""
locality = []
for category in SEARCH_CATEGORY_ALL_LOCALITY:
locality += self._get_text_for_category(category)
@@ -135,74 +78,74 @@ class SearchInfo:
@property
def city(self):
""" returns city/town """
"""returns city/town"""
city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
return city[0] if city else ""
@property
def state(self):
""" returns state name """
"""returns state name"""
state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
return state[0] if state else ""
@property
def state_abbreviation(self):
""" returns state abbreviation """
"""returns state abbreviation"""
abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
return abbrev[0] if abbrev else ""
@property
def country(self):
""" returns country name """
"""returns country name"""
country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
return country[0] if country else ""
@property
def month(self):
""" returns month name """
"""returns month name"""
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
return month[0] if month else ""
@property
def year(self):
""" returns year """
"""returns year"""
year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
return year[0] if year else ""
@property
def bodies_of_water(self):
""" returns list of body of water names """
"""returns list of body of water names"""
return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
@property
def holidays(self):
""" returns list of holiday names """
"""returns list of holiday names"""
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
@property
def activities(self):
""" returns list of activity names """
"""returns list of activity names"""
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
@property
def season(self):
""" returns season name """
"""returns season name"""
season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
return season[0] if season else ""
@property
def venues(self):
""" returns list of venue names """
"""returns list of venue names"""
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
@property
def venue_types(self):
""" returns list of venue types """
"""returns list of venue types"""
return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
@property
def media_types(self):
""" returns list of media types (photo, video, panorama, etc) """
"""returns list of media types (photo, video, panorama, etc)"""
types = []
for category in SEARCH_CATEGORY_MEDIA_TYPES:
types += self._get_text_for_category(category)
@@ -210,7 +153,7 @@ class SearchInfo:
@property
def all(self):
""" return all search info properties in a single list """
"""return all search info properties in a single list"""
all = (
self.labels
+ self.place_names
@@ -242,7 +185,7 @@ class SearchInfo:
return all
def asdict(self):
""" return dict of search info """
"""return dict of search info"""
return {
"labels": self.labels,
"place_names": self.place_names,
@@ -265,13 +208,15 @@ class SearchInfo:
}
def _get_text_for_category(self, category):
""" return list of text for a specified category ID """
"""return list of text for a specified category ID"""
if self._db_searchinfo:
content = "normalized_string" if self._normalized else "content_string"
return [
rec[content]
for rec in self._db_searchinfo
if rec["category"] == category
]
return sorted(
[
rec[content]
for rec in self._db_searchinfo
if rec["category"] == category
]
)
else:
return []

57
osxphotos/sqlgrep.py Normal file
View File

@@ -0,0 +1,57 @@
"""Search through a sqlite database file for a given string"""
import re
import sqlite3
from typing import Generator, List
__all__ = ["sqlgrep"]
def sqlgrep(
filename: str,
pattern: str,
ignore_case: bool = False,
print_filename: bool = True,
rich_markup: bool = False,
) -> Generator[List[str], None, None]:
"""grep through a sqlite database file for a given string
Args:
filename (str): The filename of the sqlite database file
pattern (str): The pattern to search for
ignore_case (bool, optional): Ignore case when searching. Defaults to False.
print_filename (bool, optional): include the filename of the file with table name. Defaults to True.
rich_markup (bool, optional): Add rich markup to mark found text in bold. Defaults to False.
Returns:
Generator which yields list of [table, column, row_id, value]
"""
flags = re.IGNORECASE if ignore_case else 0
try:
with sqlite3.connect(f"file:{filename}?mode=ro", uri=True) as conn:
regex = re.compile(r"(" + pattern + r")", flags=flags)
filename_header = f"{filename}: " if print_filename else ""
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
for tablerow in cursor.fetchall():
table = tablerow[0]
cursor.execute("SELECT * FROM {t}".format(t=table))
for row_num, row in enumerate(cursor):
for field in row.keys():
field_value = row[field]
if not field_value or type(field_value) == bytes:
# don't search binary blobs
next
field_value = str(field_value)
if re.search(pattern, field_value, flags=flags):
if rich_markup:
field_value = regex.sub(r"[bold]\1[/bold]", field_value)
yield [
f"{filename_header}{table}",
field,
str(row_num),
field_value,
]
except sqlite3.DatabaseError as e:
raise sqlite3.DatabaseError(f"{filename}: {e}")

View File

@@ -103,6 +103,8 @@
% if photo.face_info:
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:AppliedToDimensions rdf:parseType="Resource">
<stDim:h>${photo.width if photo.orientation in [5, 6, 7, 8] else photo.height}</stDim:h>
<stDim:w>${photo.height if photo.orientation in [5, 6, 7, 8] else photo.width}</stDim:w>
<stDim:unit>pixel</stDim:unit>
</mwg-rs:AppliedToDimensions>
<mwg-rs:RegionList>

Some files were not shown because too many files have changed in this diff Show More