Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d38885416 | ||
|
|
653b7e6600 | ||
|
|
9429ea8ace | ||
|
|
2202f1b1e9 | ||
|
|
a509ef18d3 | ||
|
|
0492f94060 | ||
|
|
cf7fab4c7a | ||
|
|
c7c5320587 | ||
|
|
cd710771cd | ||
|
|
663e33bc17 | ||
|
|
3660b6360a | ||
|
|
11459d1da4 | ||
|
|
fd14242022 | ||
|
|
6ac311199e | ||
|
|
13df6a2395 | ||
|
|
28dce72a67 | ||
|
|
e5548ed160 | ||
|
|
5714509765 | ||
|
|
46b62af4e2 | ||
|
|
01ea88fe57 | ||
|
|
e6d043ab65 | ||
|
|
5b1174db5d | ||
|
|
9cff8e89c6 | ||
|
|
1553563629 | ||
|
|
db262f58b0 | ||
|
|
0cce234a8c | ||
|
|
c5dba8c89b | ||
|
|
603dabb8f4 | ||
|
|
091f1d9bb4 | ||
|
|
d16932d0fd | ||
|
|
23de6b5890 | ||
|
|
4fe58bf2af | ||
|
|
d87b8f30a4 | ||
|
|
667c89e32c | ||
|
|
f9cac05f0d | ||
|
|
48f29e138e | ||
|
|
7f2701f6ee | ||
|
|
8551981f68 | ||
|
|
a416de29e4 | ||
|
|
a960468887 | ||
|
|
ea68229dda | ||
|
|
a95193aaa4 | ||
|
|
71ef5e5195 | ||
|
|
53b2498e59 | ||
|
|
15e0914af6 | ||
|
|
3b3eb1625e | ||
|
|
338b1501d0 | ||
|
|
bda3a029de | ||
|
|
ff0fdffa9b | ||
|
|
1332e7b45a | ||
|
|
41b23991df |
106
.all-contributorsrc
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"projectName": "osxphotos",
|
||||
"projectOwner": "RhetTbull",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": true,
|
||||
"commitConvention": "none",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "britiscurious",
|
||||
"name": "britiscurious",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/25646439?v=4",
|
||||
"profile": "https://github.com/britiscurious",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mwort",
|
||||
"name": "Michel Wortmann",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8170417?v=4",
|
||||
"profile": "https://github.com/mwort",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "PabloKohan",
|
||||
"name": "Pablo 'merKur' Kohan",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8790976?v=4",
|
||||
"profile": "https://github.com/PabloKohan",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hshore29",
|
||||
"name": "hshore29",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/7023497?v=4",
|
||||
"profile": "https://github.com/hshore29",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dmd",
|
||||
"name": "Daniel M. Drucker",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/41439?v=4",
|
||||
"profile": "http://3e.org/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jystervinou",
|
||||
"name": "Jean-Yves Stervinou",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/132356?v=4",
|
||||
"profile": "https://github.com/jystervinou",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dethi",
|
||||
"name": "Thibault Deutsch",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/1011520?v=4",
|
||||
"profile": "https://dethi.me/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "grundsch",
|
||||
"name": "grundsch",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/3874928?v=4",
|
||||
"profile": "https://github.com/grundsch",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "agprimatic",
|
||||
"name": "Ag Primatic",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/4685054?v=4",
|
||||
"profile": "https://github.com/agprimatic",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hhoeck",
|
||||
"name": "Horst Höck",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/6313998?v=4",
|
||||
"profile": "https://github.com/hhoeck",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7
|
||||
}
|
||||
93
CHANGELOG.md
@@ -4,6 +4,99 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.36.8](https://github.com/RhetTbull/osxphotos/compare/v0.36.7...v0.36.8)
|
||||
|
||||
> 5 November 2020
|
||||
|
||||
- Refactored exiftool.py [`2202f1b`](https://github.com/RhetTbull/osxphotos/commit/2202f1b1e9c4f83558ef48e58cb94af6b3a38cdd)
|
||||
- README.md update [`a509ef1`](https://github.com/RhetTbull/osxphotos/commit/a509ef18d3db2ac15a661e763a7254974cf8d84a)
|
||||
|
||||
#### [v0.36.7](https://github.com/RhetTbull/osxphotos/compare/v0.36.6...v0.36.7)
|
||||
|
||||
> 4 November 2020
|
||||
|
||||
- Implemented context manager for ExifTool, closes #250 [`#250`](https://github.com/RhetTbull/osxphotos/issues/250)
|
||||
|
||||
#### [v0.36.6](https://github.com/RhetTbull/osxphotos/compare/v0.36.5...v0.36.6)
|
||||
|
||||
> 2 November 2020
|
||||
|
||||
- Fix for issue #39 [`c7c5320`](https://github.com/RhetTbull/osxphotos/commit/c7c5320587e31070b55cc8c7e74f30b0f9e61379)
|
||||
|
||||
#### [v0.36.5](https://github.com/RhetTbull/osxphotos/compare/v0.36.4...v0.36.5)
|
||||
|
||||
> 1 November 2020
|
||||
|
||||
- Added --ignore-date-modified flag, issue #247 [`663e33b`](https://github.com/RhetTbull/osxphotos/commit/663e33bc1709f767e1a08242f6bfe86a3fc78552)
|
||||
|
||||
#### [v0.36.4](https://github.com/RhetTbull/osxphotos/compare/v0.36.2...v0.36.4)
|
||||
|
||||
> 1 November 2020
|
||||
|
||||
- Updated --exiftool to set dates/times as Photos does, issue #247 [`11459d1`](https://github.com/RhetTbull/osxphotos/commit/11459d1da4d7d13e36e9db4bdc940b74baad9d11)
|
||||
- Partial fix for issue #247 on Mojave [`6ac3111`](https://github.com/RhetTbull/osxphotos/commit/6ac311199e9f7afe6170cbbd68ceaa1bb9f0682b)
|
||||
- Add @mwort as a contributor [`9cff8e8`](https://github.com/RhetTbull/osxphotos/commit/9cff8e89c6e939d3d371a4f60649f6e5595a55b9)
|
||||
|
||||
#### [v0.36.2](https://github.com/RhetTbull/osxphotos/compare/v0.36.1...v0.36.2)
|
||||
|
||||
> 31 October 2020
|
||||
|
||||
- Fixed handling of date_modified for Catalina, issue #247 [`0cce234`](https://github.com/RhetTbull/osxphotos/commit/0cce234a8cbba63dc1cba439c06fe9de078ff480)
|
||||
|
||||
#### [v0.36.1](https://github.com/RhetTbull/osxphotos/compare/v0.36.0...v0.36.1)
|
||||
|
||||
> 30 October 2020
|
||||
|
||||
- Added --has-comment/--has-likes to CLI, issue #240 [`c5dba8c`](https://github.com/RhetTbull/osxphotos/commit/c5dba8c89bba35d7a77e087b180b2a3d7b94280a)
|
||||
- Cleaned up as_dict/asdict, issue #144, #188 [`603dabb`](https://github.com/RhetTbull/osxphotos/commit/603dabb8f420a89e993d5aadcd3a5614bbb262dd)
|
||||
- Updated README.md [`d16932d`](https://github.com/RhetTbull/osxphotos/commit/d16932d0fd8d160ccf44e9842329d5933dc25b36)
|
||||
|
||||
#### [v0.36.0](https://github.com/RhetTbull/osxphotos/compare/v0.35.7...v0.36.0)
|
||||
|
||||
> 26 October 2020
|
||||
|
||||
- Added verbose to PhotosDB(), partial fix for #110 [`d87b8f3`](https://github.com/RhetTbull/osxphotos/commit/d87b8f30a45cbb6fdb315a12f8585e2bdc21be6b)
|
||||
- Added comments/likes, implements #214 [`23de6b5`](https://github.com/RhetTbull/osxphotos/commit/23de6b58908371d9ca55d1d1999c6d56de454180)
|
||||
- Cleaned up constructor for PhotosDB [`667c89e`](https://github.com/RhetTbull/osxphotos/commit/667c89e32c3f96baeafebc03e83517ea05693b00)
|
||||
|
||||
#### [v0.35.7](https://github.com/RhetTbull/osxphotos/compare/v0.35.6...v0.35.7)
|
||||
|
||||
> 24 October 2020
|
||||
|
||||
- Fix for issue #238 [`48f29e1`](https://github.com/RhetTbull/osxphotos/commit/48f29e138e4e9da3eba78f3681ee9b8cb28910df)
|
||||
|
||||
#### [v0.35.6](https://github.com/RhetTbull/osxphotos/compare/v0.35.5...v0.35.6)
|
||||
|
||||
> 24 October 2020
|
||||
|
||||
- Fixed shared, not_shared in cli [`8551981`](https://github.com/RhetTbull/osxphotos/commit/8551981f68f0cd2a3a081cc21ae287ff981b9b4b)
|
||||
|
||||
#### [v0.35.5](https://github.com/RhetTbull/osxphotos/compare/v0.35.4...v0.35.5)
|
||||
|
||||
> 22 October 2020
|
||||
|
||||
- Added get_shared_photo_comments.py to examples [`15e0914`](https://github.com/RhetTbull/osxphotos/commit/15e0914af6301a945bc751173aef6718487d9637)
|
||||
- Fix for issue #237 [`a416de2`](https://github.com/RhetTbull/osxphotos/commit/a416de29e4ac39a5c323f7913b05a8c38ad205be)
|
||||
- Added test for issue #235 [`ea68229`](https://github.com/RhetTbull/osxphotos/commit/ea68229ddac2e2301ac2d5607451cf7d00207d5d)
|
||||
|
||||
#### [v0.35.4](https://github.com/RhetTbull/osxphotos/compare/v0.35.3...v0.35.4)
|
||||
|
||||
> 18 October 2020
|
||||
|
||||
- refactored template code to fix #213 [`#213`](https://github.com/RhetTbull/osxphotos/issues/213)
|
||||
|
||||
#### [v0.35.3](https://github.com/RhetTbull/osxphotos/compare/v0.35.2...v0.35.3)
|
||||
|
||||
> 15 October 2020
|
||||
|
||||
- Fix for issue #235, #236 [`41b2399`](https://github.com/RhetTbull/osxphotos/commit/41b23991df3d1d553b70889ede237f83b6874519)
|
||||
|
||||
#### [v0.35.2](https://github.com/RhetTbull/osxphotos/compare/v0.35.1...v0.35.2)
|
||||
|
||||
> 12 October 2020
|
||||
|
||||
- Fix for issue #234 [`da100f9`](https://github.com/RhetTbull/osxphotos/commit/da100f93a9b849ca4750336d7f90e9023e39dd07)
|
||||
|
||||
#### [v0.35.1](https://github.com/RhetTbull/osxphotos/compare/v0.35.0...v0.35.1)
|
||||
|
||||
> 12 October 2020
|
||||
|
||||
200
README.md
@@ -1,8 +1,10 @@
|
||||
# OSXPhotos
|
||||
|
||||
[](https://github.com/python/black)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||

|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
- [OSXPhotos](#osxphotos)
|
||||
* [What is osxphotos?](#what-is-osxphotos)
|
||||
@@ -21,6 +23,8 @@
|
||||
+ [ScoreInfo](#scoreinfo)
|
||||
+ [PersonInfo](#personinfo)
|
||||
+ [FaceInfo](#faceinfo)
|
||||
+ [CommentInfo](#commentinfo)
|
||||
+ [LikeInfo](#likeinfo)
|
||||
+ [Raw Photos](#raw-photos)
|
||||
+ [Template Substitutions](#template-substitutions)
|
||||
+ [Utility Functions](#utility-functions)
|
||||
@@ -57,19 +61,24 @@ OSXPhotos uses setuptools, thus simply run:
|
||||
You can also install directly from [pypi](https://pypi.org/project/osxphotos/):
|
||||
|
||||
pip install osxphotos
|
||||
|
||||
**WARNING** The git repo for this project is very large (> 1GB) because it contains multiple Photos libraries used for testing on different versions of MacOS. If you just want to use the osxphotos package in your own code, I recommend you install the latest version from [PyPI](https://pypi.org/project/osxphotos/). If you just want to use the command line utility, you can download a pre-built executable of the latest [release](https://github.com/RhetTbull/osxphotos/releases) or you can install via `pip` which also installs the command line app. If you aren't comfortable with running python on your Mac, start with the pre-built executable.
|
||||
|
||||
I recommend you create a [virtual environment](https://docs.python.org/3/tutorial/venv.html) before installing osxphotos.
|
||||
|
||||
If you aren't familiar with installing python applications, I recommend you install `osxphotos` with [pipx](https://github.com/pipxproject/pipx). If you use `pipx`, you will not need to create a virtual environment as `pipx` takes care of this. The easiest way to do this on a Mac is to use [homebrew](https://brew.sh/):
|
||||
|
||||
- Open `Terminal` (search for `Terminal` in Spotlight or look in `Applications/Utilities`)
|
||||
- Install `homebrew` according to instructions at [https://brew.sh/](https://brew.sh/)
|
||||
- Type the following into Terminal: `brew install pipx`
|
||||
- Then type this: `pipx install osxphotos`
|
||||
- Now you should be able to run `osxphotos` by typing: `osxphotos`
|
||||
|
||||
**WARNING** The git repo for this project is very large (> 1GB) because it contains multiple Photos libraries used for testing on different versions of MacOS. If you just want to use the osxphotos package in your own code, I recommend you install the latest version from [PyPI](https://pypi.org/project/osxphotos/) which does not include all the test libraries. If you just want to use the command line utility, you can download a pre-built executable of the latest [release](https://github.com/RhetTbull/osxphotos/releases) or you can install via `pip` which also installs the command line app. If you aren't comfortable with running python on your Mac, start with the pre-built executable or `pipx` as described above.
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
|
||||
|
||||
If you only care about the command line tool, you can download an executable of the latest [release](https://github.com/RhetTbull/osxphotos/releases). Alternatively, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
|
||||
|
||||
After installing pipx:
|
||||
`pipx install osxphotos`
|
||||
|
||||
Then you should be able to run `osxphotos` on the command line:
|
||||
After installing per instructions above, you should be able to run `osxphotos` on the command line:
|
||||
|
||||
```
|
||||
> osxphotos
|
||||
@@ -214,6 +223,10 @@ Options:
|
||||
2000-01-12T12:00:00,
|
||||
2001-01-12T12:00:00-07:00, or 2000-12-31
|
||||
(ISO 8601).
|
||||
--has-comment Search for photos that have comments.
|
||||
--no-comment Search for photos with no comments.
|
||||
--has-likes Search for photos that have likes.
|
||||
--no-likes Search for photos with no likes.
|
||||
--deleted Include photos from the 'Recently Deleted'
|
||||
folder.
|
||||
--deleted-only Include only photos from the 'Recently
|
||||
@@ -316,6 +329,11 @@ Options:
|
||||
exiftool may be installed from
|
||||
https://exiftool.org/. Cannot be used with
|
||||
--export-as-hardlink.
|
||||
--ignore-date-modified If used with --exiftool or --sidecar, will
|
||||
ignore the photo modification date and set
|
||||
EXIF:ModifyDate to EXIF:DateTimeOriginal;
|
||||
this is consistent with how Photos handles
|
||||
the EXIF:ModifyDate tag.
|
||||
--directory DIRECTORY Optional template for specifying name of
|
||||
output directory in the form
|
||||
'{name,DEFAULT}'. See below for additional
|
||||
@@ -416,23 +434,24 @@ Substitution Description
|
||||
{descr} Description of the photo
|
||||
{created.date} Photo's creation date in ISO format, e.g.
|
||||
'2020-03-22'
|
||||
{created.year} 4-digit year of file creation time
|
||||
{created.yy} 2-digit year of file creation time
|
||||
{created.mm} 2-digit month of the file creation time
|
||||
{created.year} 4-digit year of photo creation time
|
||||
{created.yy} 2-digit year of photo creation time
|
||||
{created.mm} 2-digit month of the photo creation time
|
||||
(zero padded)
|
||||
{created.month} Month name in user's locale of the file
|
||||
{created.month} Month name in user's locale of the photo
|
||||
creation time
|
||||
{created.mon} Month abbreviation in the user's locale of
|
||||
the file creation time
|
||||
the photo creation time
|
||||
{created.dd} 2-digit day of the month (zero padded) of
|
||||
file creation time
|
||||
{created.dow} Day of week in user's locale of the file
|
||||
photo creation time
|
||||
{created.dow} Day of week in user's locale of the photo
|
||||
creation time
|
||||
{created.doy} 3-digit day of year (e.g Julian day) of file
|
||||
creation time, starting from 1 (zero padded)
|
||||
{created.hour} 2-digit hour of the file creation time
|
||||
{created.min} 2-digit minute of the file creation time
|
||||
{created.sec} 2-digit second of the file creation time
|
||||
{created.doy} 3-digit day of year (e.g Julian day) of
|
||||
photo creation time, starting from 1 (zero
|
||||
padded)
|
||||
{created.hour} 2-digit hour of the photo creation time
|
||||
{created.min} 2-digit minute of the photo creation time
|
||||
{created.sec} 2-digit second of the photo creation time
|
||||
{created.strftime} Apply strftime template to file creation
|
||||
date/time. Should be used in form
|
||||
{created.strftime,TEMPLATE} where TEMPLATE
|
||||
@@ -444,22 +463,26 @@ Substitution Description
|
||||
templates.
|
||||
{modified.date} Photo's modification date in ISO format,
|
||||
e.g. '2020-03-22'
|
||||
{modified.year} 4-digit year of file modification time
|
||||
{modified.yy} 2-digit year of file modification time
|
||||
{modified.mm} 2-digit month of the file modification time
|
||||
{modified.year} 4-digit year of photo modification time
|
||||
{modified.yy} 2-digit year of photo modification time
|
||||
{modified.mm} 2-digit month of the photo modification time
|
||||
(zero padded)
|
||||
{modified.month} Month name in user's locale of the file
|
||||
{modified.month} Month name in user's locale of the photo
|
||||
modification time
|
||||
{modified.mon} Month abbreviation in the user's locale of
|
||||
the file modification time
|
||||
the photo modification time
|
||||
{modified.dd} 2-digit day of the month (zero padded) of
|
||||
the file modification time
|
||||
{modified.doy} 3-digit day of year (e.g Julian day) of file
|
||||
modification time, starting from 1 (zero
|
||||
padded)
|
||||
{modified.hour} 2-digit hour of the file modification time
|
||||
{modified.min} 2-digit minute of the file modification time
|
||||
{modified.sec} 2-digit second of the file modification time
|
||||
the photo modification time
|
||||
{modified.dow} Day of week in user's locale of the photo
|
||||
modification time
|
||||
{modified.doy} 3-digit day of year (e.g Julian day) of
|
||||
photo modification time, starting from 1
|
||||
(zero padded)
|
||||
{modified.hour} 2-digit hour of the photo modification time
|
||||
{modified.min} 2-digit minute of the photo modification
|
||||
time
|
||||
{modified.sec} 2-digit second of the photo modification
|
||||
time
|
||||
{today.date} Current date in iso format, e.g.
|
||||
'2020-03-22'
|
||||
{today.year} 4-digit year of current date
|
||||
@@ -534,6 +557,8 @@ Substitution Description
|
||||
{label} Image categorization label associated with a photo
|
||||
(Photos 5 only)
|
||||
{label_normalized} All lower case version of 'label' (Photos 5 only)
|
||||
{comment} Comment(s) on shared Photos; format is 'Person name:
|
||||
comment text' (Photos 5 only)
|
||||
```
|
||||
|
||||
Example: export all photos to ~/Desktop/export group in folders by date created
|
||||
@@ -692,7 +717,7 @@ osxphotos.PhotosDB(dbfile=path)
|
||||
|
||||
Reads the Photos library database and returns a PhotosDB object.
|
||||
|
||||
Pass the path to a Photos library or to a specific database file (e.g. "/Users/smith/Pictures/Photos Library.photoslibrary" or "/Users/smith/Pictures/Photos Library.photoslibrary/database/photos.db"). Normally, it's recommended you pass the path the .photoslibrary folder, not the actual database path. The latter option is provided for debugging -- e.g. for reading a database file if you don't have the entire library. Path to photos library may be passed **either** as first argument **or** as named argument `dbfile`. **Note**: In Photos, users may specify a different library to open by holding down the *option* key while opening Photos.app. See also [get_last_library_path](#get_last_library_path) and [get_system_library_path](#get_system_library_path)
|
||||
Pass the path to a Photos library or to a specific database file (e.g. "/Users/smith/Pictures/Photos Library.photoslibrary" or "/Users/smith/Pictures/Photos Library.photoslibrary/database/photos.db"). Normally, it's recommended you pass the path the .photoslibrary folder, not the actual database path. **Note**: In Photos, users may specify a different library to open by holding down the *option* key while opening Photos.app. See also [get_last_library_path](#get_last_library_path) and [get_system_library_path](#get_system_library_path)
|
||||
|
||||
If an invalid path is passed, PhotosDB will raise `FileNotFoundError` exception.
|
||||
|
||||
@@ -1152,7 +1177,17 @@ Returns a [PlaceInfo](#PlaceInfo) object with reverse geolocation data or None i
|
||||
#### `shared`
|
||||
Returns True if photo is in a shared album, otherwise False.
|
||||
|
||||
**Note**: *Only valid on Photos 5 / MacOS 10.15*; on Photos <= 4, returns None instead of True/False.
|
||||
**Note**: *Only valid on Photos 5 / MacOS 10.15+; on Photos <= 4, returns None instead of True/False.
|
||||
|
||||
#### `comments`
|
||||
Returns list of [CommentInfo](#commentinfo) objects for comments on shared photos or empty list if no comments.
|
||||
|
||||
**Note**: *Only valid on Photos 5 / MacOS 10.15+; on Photos <= 4, returns empty list.
|
||||
|
||||
#### `likes`
|
||||
Returns list of [LikeInfo](#likeinfo) objects for likes on shared photos or empty list if no likes.
|
||||
|
||||
**Note**: *Only valid on Photos 5 / MacOS 10.15+; on Photos <= 4, returns empty list.
|
||||
|
||||
#### `isphoto`
|
||||
Returns True if type is photo/still image, otherwise False
|
||||
@@ -1234,6 +1269,9 @@ Returns True if photo is a panorama, otherwise False.
|
||||
|
||||
**Note**: The result of `PhotoInfo.panorama` will differ from the "Panoramas" Media Types smart album in that it will also identify panorama photos from older phones that Photos does not recognize as panoramas.
|
||||
|
||||
#### `slow_mo`
|
||||
Returns True if photo is a slow motion video, otherwise False
|
||||
|
||||
#### `labels`
|
||||
Returns image categorization labels associated with the photo as list of str.
|
||||
|
||||
@@ -1276,7 +1314,8 @@ exiftool must be installed in the path for this to work. If exiftool cannot be
|
||||
|
||||
`ExifTool` provides the following methods:
|
||||
|
||||
- `as_dict()`: returns all EXIF metadata found in the file as a dictionary in following form (Note: this shows just a subset of available metadata). See [exiftool](https://exiftool.org/) documentation to understand which metadata keys are available.
|
||||
- `asdict()`: returns all EXIF metadata found in the file as a dictionary in following form (Note: this shows just a subset of available metadata). See [exiftool](https://exiftool.org/) documentation to understand which metadata keys are available.
|
||||
|
||||
```python
|
||||
{'Composite:Aperture': 2.2,
|
||||
'Composite:GPSPosition': '-34.9188916666667 138.596861111111',
|
||||
@@ -1289,7 +1328,7 @@ exiftool must be installed in the path for this to work. If exiftool cannot be
|
||||
}
|
||||
```
|
||||
|
||||
- `json()`: returns same information as `as_dict()` but as a serialized JSON string.
|
||||
- `json()`: returns same information as `asdict()` but as a serialized JSON string.
|
||||
|
||||
- `setvalue(tag, value)`: write to the EXIF data in the photo file. To delete a tag, use setvalue with value = `None`. For example:
|
||||
```python
|
||||
@@ -1300,7 +1339,7 @@ photo.exiftool.setvalue("XMP:Title", "Title of photo")
|
||||
photo.exiftool.addvalues("IPTC:Keywords", "vacation", "beach")
|
||||
```
|
||||
|
||||
**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`.
|
||||
**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.asdict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`.
|
||||
|
||||
#### `score`
|
||||
Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the computed aesthetic scores for each photo.
|
||||
@@ -1308,7 +1347,10 @@ Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the
|
||||
**Note**: Valid only for Photos 5; returns None for earlier Photos versions.
|
||||
|
||||
#### `json()`
|
||||
Returns a JSON representation of all photo info
|
||||
Returns a JSON representation of all photo info.
|
||||
|
||||
#### `asdict()`
|
||||
Returns a dictionary representation of all photo info.
|
||||
|
||||
#### `export()`
|
||||
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||
@@ -1352,21 +1394,24 @@ If overwrite=False and increment=False, export will fail if destination file alr
|
||||
|
||||
#### <a name="rendertemplate">`render_template()`</a>
|
||||
|
||||
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None)`
|
||||
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None, filename=False, dirname=False, replacement=":",)`
|
||||
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
|
||||
- `template_str`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
|
||||
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
|
||||
- `path_sep`: optional character 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
|
||||
- `replacement`: str, value to replace any illegal file path characters with; default = ":"
|
||||
|
||||
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
|
||||
|
||||
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
|
||||
e.g. `render_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
|
||||
|
||||
If you want to include "{" or "}" in the output, use "{{" or "}}"
|
||||
|
||||
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
|
||||
e.g. `render_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
|
||||
|
||||
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
|
||||
|
||||
@@ -1487,6 +1532,11 @@ Returns the title or name of the folder.
|
||||
#### `album_info`
|
||||
Returns a list of [AlbumInfo](#AlbumInfo) objects representing each album contained in the folder.
|
||||
|
||||
#### `album_info_shared`
|
||||
Returns a list of [AlbumInfo](#AlbumInfo) objects for each shared album in the photos database.
|
||||
|
||||
**Note**: Only valid for Photos 5+; on Photos <= 4, prints warning and returns empty list.
|
||||
|
||||
#### `subfolders`
|
||||
Returns a list of [FolderInfo](#FolderInfo) objects representing the sub-folders of the folder.
|
||||
|
||||
@@ -1648,6 +1698,9 @@ Returns a list of [FaceInfo](#faceinfo) objects associated with this person sort
|
||||
#### `json()`
|
||||
Returns a json string representation of the PersonInfo instance.
|
||||
|
||||
#### `asdict()`
|
||||
Returns a dictionary representation of the PersonInfo instance.
|
||||
|
||||
### FaceInfo
|
||||
[PhotoInfo.face_info](#photofaceinfo) return a list of FaceInfo objects representing detected faces in a photo. The FaceInfo class has the following properties and methods.
|
||||
|
||||
@@ -1733,6 +1786,21 @@ Returns a dictionary representation of the FaceInfo instance.
|
||||
#### `json()`
|
||||
Returns a JSON representation of the FaceInfo instance.
|
||||
|
||||
### CommentInfo
|
||||
[PhotoInfo.comments](#comments) returns a list of CommentInfo objects for comments on shared photos. (Photos 5/MacOS 10.15+ only). The list of CommentInfo objects will be sorted in ascending order by date comment was made. CommentInfo contains the following fields:
|
||||
|
||||
- `datetime`: `datetime.datetime`, date/time comment was made
|
||||
- `user`: `str`, name of user who made the comment
|
||||
- `ismine`: `bool`, True if comment was made by person who owns the Photos library being operated on
|
||||
- `text`: `str`, text of the actual comment
|
||||
|
||||
### LikeInfo
|
||||
[PhotoInfo.likes](#likes) returns a list of LikeInfo objects for "likes" on shared photos. (Photos 5/MacOS 10.15+ only). The list of LikeInfo objects will be sorted in ascending order by date like was made. LikeInfo contains the following fields:
|
||||
|
||||
- `datetime`: `datetime.datetime`, date/time like was made
|
||||
- `user`: `str`, name of user who made the like
|
||||
- `ismine`: `bool`, True if like was made by person who owns the Photos library being operated on
|
||||
|
||||
### Raw Photos
|
||||
Handling raw photos in `osxphotos` requires a bit of extra work. Raw photos in Photos can be imported in two different ways: 1) a single raw photo with no associated JPEG image is imported 2) a raw+JPEG pair is imported -- two separate images with same file stem (e.g. `IMG_0001.CR2` and `IMG_001.JPG`) are imported.
|
||||
|
||||
@@ -1794,6 +1862,7 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|
||||
|{modified.month}|Month name in user's locale of the file modification time|
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the file modification time|
|
||||
|{modified.dd}|2-digit day of the month (zero padded) of the file modification time|
|
||||
|{modified.dow}|Day of week in user's locale of the photo modification time|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)|
|
||||
|{modified.hour}|2-digit hour of the file modification time|
|
||||
|{modified.min}|2-digit minute of the file modification time|
|
||||
@@ -1830,6 +1899,7 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|
||||
|{person}|Person(s) / face(s) in a photo|
|
||||
|{label}|Image categorization label associated with a photo (Photos 5 only)|
|
||||
|{label_normalized}|All lower case version of 'label' (Photos 5 only)|
|
||||
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)|
|
||||
|
||||
### Utility Functions
|
||||
|
||||
@@ -1912,6 +1982,7 @@ if __name__ == "__main__":
|
||||
- [rhettbull/photosmeta](https://github.com/rhettbull/photosmeta): uses osxphotos and [exiftool](https://exiftool.org/) to apply metadata from Photos as exif data in the photo files. Can also export photos while preserving metadata and also apply Photos keywords as spotlight tags to make it easier to search for photos using spotlight. This is mostly made obsolete by osxphotos. The one feature that photosmeta has that osxphotos does not is ability to update the metadata of the actual photo files in the Photos library without exporting them. (Use with caution!)
|
||||
- [rhettbull/PhotoScript](https://github.com/RhetTbull/PhotoScript): python wrapper around Photos' applescript API allowing automation of Photos (including creation/deletion of items) from python.
|
||||
- [patrikhson/photo-export](https://github.com/patrikhson/photo-export): Exports older versions of Photos databases. Provided the inspiration for osxphotos.
|
||||
- [doersino/apple-photos-export](https://github.com/doersino/apple-photos-export): Photos export script for Mojave.
|
||||
- [orangeturtle739/photos-export](https://github.com/orangeturtle739/photos-export): Set of scripts to export Photos libraries.
|
||||
- [ndbroadbent/icloud_photos_downloader](https://github.com/ndbroadbent/icloud_photos_downloader): Download photos from iCloud. Currently unmaintained.
|
||||
- [AaronVanGeffen/ExportPhotosLibrary](https://github.com/AaronVanGeffen/ExportPhotosLibrary): Another python script for exporting older versions of Photos libraries.
|
||||
@@ -1928,21 +1999,37 @@ If you have an interesting example that shows usage of this package, submit an i
|
||||
|
||||
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank-you to the following people who have contributed to improving osxphotos! If I've inadvertently left you off, please open an issue or send me a note.
|
||||
### Contributors ✨
|
||||
|
||||
- [britiscurious](https://github.com/britiscurious)
|
||||
- [Michel Wortmann](https://github.com/mwort)
|
||||
- [hshore29](https://github.com/hshore29)
|
||||
- [Pablo 'merKur' Kohan](https://github.com/PabloKohan)
|
||||
- [Jean-Yves Stervinou](https://github.com/jystervinou)
|
||||
- [Thibault Deutsch](https://github.com/dethi)
|
||||
- [grundsch](https://github.com/grundsch)
|
||||
- [Ag Primatic](https://github.com/agprimatic)
|
||||
- [Daniel M. Drucker](https://github.com/dmd)
|
||||
- [Horst Höck](https://github.com/hhoeck)
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/britiscurious"><img src="https://avatars1.githubusercontent.com/u/25646439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>britiscurious</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Documentation">📖</a> <a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/mwort"><img src="https://avatars3.githubusercontent.com/u/8170417?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michel Wortmann</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=mwort" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/PabloKohan"><img src="https://avatars3.githubusercontent.com/u/8790976?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Pablo 'merKur' Kohan</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=PabloKohan" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/hshore29"><img src="https://avatars2.githubusercontent.com/u/7023497?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hshore29</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hshore29" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://3e.org/"><img src="https://avatars0.githubusercontent.com/u/41439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel M. Drucker</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dmd" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/jystervinou"><img src="https://avatars3.githubusercontent.com/u/132356?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jean-Yves Stervinou</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jystervinou" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://dethi.me/"><img src="https://avatars2.githubusercontent.com/u/1011520?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thibault Deutsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dethi" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
## Known Bugs
|
||||
|
||||
@@ -1975,5 +2062,4 @@ For additional details about how osxphotos is implemented or if you would like t
|
||||
## Acknowledgements
|
||||
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
|
||||
|
||||
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
|
||||
|
||||
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
|
||||
156
examples/get_shared_photo_comments.py
Normal file
@@ -0,0 +1,156 @@
|
||||
""" get shared comments associated with a photo """
|
||||
|
||||
import datetime
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import TIME_DELTA
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comment:
|
||||
""" Class for shared photo comments """
|
||||
|
||||
uuid: str
|
||||
sort_fok: int
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Like:
|
||||
""" Class for shared photo likes """
|
||||
|
||||
uuid: str
|
||||
sort_fok: int
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
|
||||
|
||||
def get_shared_person_info(photosdb, hashed_person_id):
|
||||
""" returns tuple of (first name, last name, full name)
|
||||
for person invited to shared album with
|
||||
ZINVITEEHASHEDPERSONID = hashed_person_id
|
||||
|
||||
Args:
|
||||
photosdb: a osxphotos.PhotosDB object
|
||||
hashed_person_id: str, value of ZINVITEEHASHEDPERSONID to lookup
|
||||
"""
|
||||
|
||||
conn, _ = photosdb.get_db_connection()
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
ZINVITEEHASHEDPERSONID,
|
||||
ZINVITEEFIRSTNAME,
|
||||
ZINVITEELASTNAME,
|
||||
ZINVITEEFULLNAME
|
||||
FROM
|
||||
ZCLOUDSHAREDALBUMINVITATIONRECORD
|
||||
WHERE
|
||||
ZINVITEEHASHEDPERSONID = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
([hashed_person_id]),
|
||||
).fetchall()
|
||||
|
||||
if results:
|
||||
row = results[0]
|
||||
return (row[1], row[2], row[3])
|
||||
else:
|
||||
return (None, None, None)
|
||||
|
||||
|
||||
def get_comments(photosdb, uuid):
|
||||
""" return comments and likes, if any, for photo with uuid
|
||||
|
||||
Args:
|
||||
photosdb: a osxphotos.PhotosDB object
|
||||
uuid: uuid of the photo
|
||||
|
||||
Returns:
|
||||
tuple of (list of comments as Comment objects or [] if no comments, list of likes as Like objects or [] if no likes)
|
||||
"""
|
||||
conn, _ = photosdb.get_db_connection()
|
||||
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
ZGENERICASSET.ZUUID, --0: UUID of the photo
|
||||
ZCLOUDSHAREDCOMMENT.ZISLIKE, --1: comment is actually a "like"
|
||||
ZCLOUDSHAREDCOMMENT.Z_FOK_COMMENTEDASSET, --2: sort order for comments on a photo
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, --3: date of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, --4: text of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, --5: hashed ID of person who made comment/like
|
||||
ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT --6: is my (this user's) comment
|
||||
FROM ZCLOUDSHAREDCOMMENT
|
||||
JOIN ZGENERICASSET ON
|
||||
ZGENERICASSET.Z_PK = ZCLOUDSHAREDCOMMENT.ZCOMMENTEDASSET
|
||||
OR
|
||||
ZGENERICASSET.Z_PK = ZCLOUDSHAREDCOMMENT.ZLIKEDASSET
|
||||
WHERE ZGENERICASSET.ZUUID = ?
|
||||
""",
|
||||
([uuid]),
|
||||
).fetchall()
|
||||
|
||||
comments = []
|
||||
likes = []
|
||||
for row in results:
|
||||
photo_uuid = row[0]
|
||||
sort_fok = row[2] or 0 # sort_fok is Null/None for likes
|
||||
is_like = bool(row[1])
|
||||
text = row[4]
|
||||
user_info = get_shared_person_info(photosdb, row[5])
|
||||
try:
|
||||
dt = datetime.datetime.fromtimestamp(row[3] + TIME_DELTA)
|
||||
except:
|
||||
dt = datetime.datetime(1970, 1, 1)
|
||||
ismine = bool(row[6])
|
||||
if is_like:
|
||||
# it's a like
|
||||
likes.append(Like(photo_uuid, sort_fok, dt, user_info[2], ismine))
|
||||
elif text:
|
||||
# comment
|
||||
comments.append(
|
||||
Comment(photo_uuid, sort_fok, dt, user_info[2], ismine, text)
|
||||
)
|
||||
if likes:
|
||||
likes.sort(key=lambda x: x.datetime)
|
||||
if comments:
|
||||
comments.sort(key=lambda x: x.sort_fok)
|
||||
return (comments, likes)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1:
|
||||
# library as first argument
|
||||
photosdb = osxphotos.PhotosDB(dbfile=sys.argv[1])
|
||||
else:
|
||||
# open default library
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
|
||||
# shared albums
|
||||
shared_albums = photosdb.album_info_shared
|
||||
for album in shared_albums:
|
||||
print(f"Processing album {album.title}")
|
||||
# only shared albums can have comments
|
||||
for photo in album.photos:
|
||||
comments, likes = get_comments(photosdb, photo.uuid)
|
||||
if comments or likes:
|
||||
print(f"{photo.uuid}, {photo.original_filename}: ")
|
||||
if likes:
|
||||
print("Likes:")
|
||||
for like in likes:
|
||||
print(like)
|
||||
if comments:
|
||||
print("Comments:")
|
||||
for comment in comments:
|
||||
print(comment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -42,7 +42,7 @@ def main():
|
||||
if db:
|
||||
print("loading database")
|
||||
tic = time.perf_counter()
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=print)
|
||||
toc = time.perf_counter()
|
||||
print(f"done: took {toc-tic} seconds")
|
||||
return photosdb
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
|
||||
from ._version import __version__
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
|
||||
from .phototemplate import PhotoTemplate
|
||||
from .utils import _debug, _get_logger, _set_debug
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
""" command line interface for osxphotos """
|
||||
import csv
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
@@ -14,12 +12,6 @@ import unicodedata
|
||||
|
||||
import click
|
||||
import yaml
|
||||
from pathvalidate import (
|
||||
is_valid_filename,
|
||||
is_valid_filepath,
|
||||
sanitize_filename,
|
||||
sanitize_filepath,
|
||||
)
|
||||
|
||||
import osxphotos
|
||||
|
||||
@@ -29,11 +21,12 @@ from ._constants import (
|
||||
_UNKNOWN_PLACE,
|
||||
UNICODE_FORMAT,
|
||||
)
|
||||
from .export_db import ExportDB, ExportDBInMemory
|
||||
from ._version import __version__
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from .exiftool import get_exiftool_path
|
||||
from .export_db import ExportDB, ExportDBInMemory
|
||||
from .fileutil import FileUtil, FileUtilNoOp
|
||||
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||
from .photoinfo import ExportResults
|
||||
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||
|
||||
@@ -215,7 +208,11 @@ class ExportCommand(click.Command):
|
||||
+ "has no value, '_' (underscore) will be used as the default value. For example, in the "
|
||||
+ "above example, this would result in '2020/_/photoname.jpg' if address was null."
|
||||
)
|
||||
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
'You may specify a null default (e.g. "" or empty string) by omitting the value after '
|
||||
+ 'the comma, e.g. {title,} which would render to "" if title had no value.'
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
|
||||
@@ -496,6 +493,10 @@ def query_options(f):
|
||||
help="Search by end item date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601).",
|
||||
type=DateTimeISO8601(),
|
||||
),
|
||||
o("--has-comment", is_flag=True, help="Search for photos that have comments."),
|
||||
o("--no-comment", is_flag=True, help="Search for photos with no comments."),
|
||||
o("--has-likes", is_flag=True, help="Search for photos that have likes."),
|
||||
o("--no-likes", is_flag=True, help="Search for photos with no likes."),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
f = o(f)
|
||||
@@ -528,10 +529,15 @@ def cli(ctx, db, json_, debug):
|
||||
help="Use with '--dump photos' to dump only certain UUIDs",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid):
|
||||
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_):
|
||||
""" Print out debug info """
|
||||
|
||||
global VERBOSE
|
||||
VERBOSE = bool(verbose_)
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
if db is None:
|
||||
click.echo(cli.commands["debug-dump"].get_help(ctx), err=True)
|
||||
@@ -541,7 +547,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid):
|
||||
|
||||
start_t = time.perf_counter()
|
||||
print(f"Opening database: {db}")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
|
||||
stop_t = time.perf_counter()
|
||||
print(f"Done; took {(stop_t-start_t):.2f} seconds")
|
||||
|
||||
@@ -984,6 +990,10 @@ def query(
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
has_comment,
|
||||
no_comment,
|
||||
has_likes,
|
||||
no_likes,
|
||||
):
|
||||
""" Query the Photos database using 1 or more search options;
|
||||
if more than one option is provided, they are treated as "AND"
|
||||
@@ -1027,6 +1037,9 @@ def query(
|
||||
(panorama, not_panorama),
|
||||
(any(place), no_place),
|
||||
(deleted, deleted_only),
|
||||
(shared, not_shared),
|
||||
(has_comment, no_comment),
|
||||
(has_likes, no_likes),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
if any(all(bb) for bb in exclusive) or not any(
|
||||
@@ -1113,6 +1126,10 @@ def query(
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
has_comment=has_comment,
|
||||
no_comment=no_comment,
|
||||
has_likes=has_likes,
|
||||
no_likes=no_likes,
|
||||
)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
@@ -1240,10 +1257,10 @@ def query(
|
||||
"--jpeg-quality",
|
||||
type=click.FloatRange(0.0, 1.0),
|
||||
default=1.0,
|
||||
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
|
||||
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
|
||||
"A value of 1.0 specifies best quality, "
|
||||
"a value of 0.0 specifies maximum compression. "
|
||||
"Defaults to 1.0."
|
||||
"Defaults to 1.0.",
|
||||
)
|
||||
@click.option(
|
||||
"--sidecar",
|
||||
@@ -1277,6 +1294,13 @@ def query(
|
||||
"exiftool may be installed from https://exiftool.org/. "
|
||||
"Cannot be used with --export-as-hardlink.",
|
||||
)
|
||||
@click.option(
|
||||
"--ignore-date-modified",
|
||||
is_flag=True,
|
||||
help="If used with --exiftool or --sidecar, will ignore the photo "
|
||||
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
|
||||
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
|
||||
)
|
||||
@click.option(
|
||||
"--directory",
|
||||
metavar="DIRECTORY",
|
||||
@@ -1376,6 +1400,7 @@ def export(
|
||||
download_missing,
|
||||
dest,
|
||||
exiftool,
|
||||
ignore_date_modified,
|
||||
portrait,
|
||||
not_portrait,
|
||||
screenshot,
|
||||
@@ -1396,6 +1421,10 @@ def export(
|
||||
edited_suffix,
|
||||
place,
|
||||
no_place,
|
||||
has_comment,
|
||||
no_comment,
|
||||
has_likes,
|
||||
no_likes,
|
||||
no_extended_attributes,
|
||||
label,
|
||||
deleted,
|
||||
@@ -1442,6 +1471,9 @@ def export(
|
||||
(deleted, deleted_only),
|
||||
(skip_edited, skip_original_if_edited),
|
||||
(export_as_hardlink, convert_to_jpeg),
|
||||
(shared, not_shared),
|
||||
(has_comment, no_comment),
|
||||
(has_likes, no_likes),
|
||||
]
|
||||
if any(all(bb) for bb in exclusive):
|
||||
click.echo("Incompatible export options", err=True)
|
||||
@@ -1580,6 +1612,10 @@ def export(
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
has_comment=has_comment,
|
||||
no_comment=no_comment,
|
||||
has_likes=has_likes,
|
||||
no_likes=no_likes,
|
||||
)
|
||||
|
||||
if photos:
|
||||
@@ -1639,6 +1675,7 @@ def export(
|
||||
use_photos_export=use_photos_export,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
results_exported.extend(results.exported)
|
||||
results_new.extend(results.new)
|
||||
@@ -1688,6 +1725,7 @@ def export(
|
||||
use_photos_export=use_photos_export,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
results_exported.extend(results.exported)
|
||||
results_new.extend(results.new)
|
||||
@@ -1899,13 +1937,17 @@ def _query(
|
||||
label=None,
|
||||
deleted=False,
|
||||
deleted_only=False,
|
||||
has_comment=False,
|
||||
no_comment=False,
|
||||
has_likes=False,
|
||||
no_likes=False,
|
||||
):
|
||||
""" run a query against PhotosDB to extract the photos based on user supply criteria
|
||||
used by query and export commands
|
||||
arguments must be passed in same order as query and export
|
||||
if either is modified, need to ensure all three functions are updated """
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
|
||||
if deleted or deleted_only:
|
||||
photos = photosdb.photos(
|
||||
uuid=uuid,
|
||||
@@ -2118,6 +2160,16 @@ def _query(
|
||||
if has_raw:
|
||||
photos = [p for p in photos if p.has_raw]
|
||||
|
||||
if has_comment:
|
||||
photos = [p for p in photos if p.comments]
|
||||
elif no_comment:
|
||||
photos = [p for p in photos if not p.comments]
|
||||
|
||||
if has_likes:
|
||||
photos = [p for p in photos if p.likes]
|
||||
elif no_likes:
|
||||
photos = [p for p in photos if not p.likes]
|
||||
|
||||
return photos
|
||||
|
||||
|
||||
@@ -2180,6 +2232,7 @@ def export_photo(
|
||||
use_photos_export=False,
|
||||
convert_to_jpeg=False,
|
||||
jpeg_quality=1.0,
|
||||
ignore_date_modified=False,
|
||||
):
|
||||
""" Helper function for export that does the actual export
|
||||
|
||||
@@ -2213,6 +2266,7 @@ def export_photo(
|
||||
use_photos_export: boolean; if True forces the use of AppleScript to export even if photo not missing
|
||||
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
|
||||
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
|
||||
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
@@ -2223,6 +2277,8 @@ def export_photo(
|
||||
global VERBOSE
|
||||
VERBOSE = bool(verbose_)
|
||||
|
||||
# TODO: if --skip-original-if-edited, it's possible edited version is on disk but
|
||||
# original is missing, in which case we should download the edited version
|
||||
if not download_missing:
|
||||
if photo.ismissing:
|
||||
space = " " if not verbose_ else ""
|
||||
@@ -2235,7 +2291,7 @@ def export_photo(
|
||||
f"skipping {photo.original_filename}"
|
||||
)
|
||||
return ExportResults([], [], [], [], [], [])
|
||||
elif photo.ismissing and not photo.iscloudasset or not photo.incloud:
|
||||
elif photo.ismissing and not photo.iscloudasset and not photo.incloud:
|
||||
verbose(
|
||||
f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud"
|
||||
)
|
||||
@@ -2249,6 +2305,16 @@ def export_photo(
|
||||
results_touched = []
|
||||
|
||||
export_original = not (skip_original_if_edited and photo.hasadjustments)
|
||||
# slow_mo photos will always have hasadjustments=True even if not edited
|
||||
if photo.path_edited is None:
|
||||
if photo.slow_mo:
|
||||
export_original = True
|
||||
export_edited = False
|
||||
elif not download_missing:
|
||||
# requested edited version but it's missing, download original
|
||||
export_original = True
|
||||
export_edited = False
|
||||
verbose(f"Edited file for {photo.original_filename} is missing, downloading original")
|
||||
|
||||
filenames = get_filenames_from_template(photo, filename_template, original_name)
|
||||
for filename in filenames:
|
||||
@@ -2267,10 +2333,13 @@ def export_photo(
|
||||
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
use_photos_export = (
|
||||
download_missing and (photo.ismissing or not os.path.exists(photo.path))
|
||||
if not use_photos_export
|
||||
else True
|
||||
use_photos_export = use_photos_export or (
|
||||
download_missing
|
||||
and (
|
||||
photo.ismissing
|
||||
or not os.path.exists(photo.path)
|
||||
or (export_edited and photo.path_edited is None)
|
||||
)
|
||||
)
|
||||
|
||||
# export the photo to each path in dest_paths
|
||||
@@ -2301,6 +2370,7 @@ def export_photo(
|
||||
touch_file=touch_file,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results.exported)
|
||||
@@ -2362,6 +2432,7 @@ def export_photo(
|
||||
touch_file=touch_file,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results_edited.exported)
|
||||
@@ -2409,7 +2480,9 @@ def get_filenames_from_template(photo, filename_template, original_name):
|
||||
"""
|
||||
if filename_template:
|
||||
photo_ext = pathlib.Path(photo.original_filename).suffix
|
||||
filenames, unmatched = photo.render_template(filename_template, path_sep="_")
|
||||
filenames, unmatched = photo.render_template(
|
||||
filename_template, path_sep="_", filename=True
|
||||
)
|
||||
if not filenames or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"filename_template",
|
||||
@@ -2418,6 +2491,8 @@ def get_filenames_from_template(photo, filename_template, original_name):
|
||||
filenames = [f"{file_}{photo_ext}" for file_ in filenames]
|
||||
else:
|
||||
filenames = [photo.original_filename] if original_name else [photo.filename]
|
||||
|
||||
filenames = [sanitize_filename(filename) for filename in filenames]
|
||||
return filenames
|
||||
|
||||
|
||||
@@ -2448,22 +2523,18 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
||||
dest_paths = [dest_path]
|
||||
elif directory:
|
||||
# got a directory template, render it and check results are valid
|
||||
dirnames, unmatched = photo.render_template(directory)
|
||||
if not dirnames:
|
||||
raise click.BadOptionUsage(
|
||||
"directory",
|
||||
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
|
||||
)
|
||||
elif unmatched:
|
||||
dirnames, unmatched = photo.render_template(directory, dirname=True)
|
||||
if not dirnames or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"directory",
|
||||
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
|
||||
)
|
||||
|
||||
dest_paths = []
|
||||
for dirname in dirnames:
|
||||
dirname = sanitize_filepath(dirname, platform="auto")
|
||||
dirname = sanitize_filepath(dirname)
|
||||
dest_path = os.path.join(dest, dirname)
|
||||
if not is_valid_filepath(dest_path, platform="auto"):
|
||||
if not is_valid_filepath(dest_path):
|
||||
raise ValueError(f"Invalid file path: '{dest_path}'")
|
||||
if not dry_run and not os.path.isdir(dest_path):
|
||||
os.makedirs(dest_path)
|
||||
@@ -2491,7 +2562,7 @@ def find_files_in_branch(pathname, filename):
|
||||
files = []
|
||||
|
||||
# walk down the tree
|
||||
for root, directories, filenames in os.walk(pathname):
|
||||
for root, _, filenames in os.walk(pathname):
|
||||
# for directory in directories:
|
||||
# print(os.path.join(root, directory))
|
||||
for fname in filenames:
|
||||
|
||||
@@ -102,3 +102,10 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
|
||||
|
||||
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
|
||||
SEARCH_CATEGORY_LABEL = 2024
|
||||
|
||||
# Max filename length on MacOS
|
||||
MAX_FILENAME_LEN = 255
|
||||
|
||||
# Max directory name length on MacOS
|
||||
MAX_DIRNAME_LEN = 255
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.35.2"
|
||||
__version__ = "0.36.9"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
I rolled my own for following reasons:
|
||||
1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
|
||||
2. I wanted singleton behavior so only a single exiftool process was ever running
|
||||
3. When used as a context manager, I wanted the operations to batch until exiting the context (improved performance)
|
||||
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 """
|
||||
|
||||
@@ -10,10 +11,8 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
|
||||
from .utils import _debug
|
||||
|
||||
# exiftool -stay_open commands outputs this EOF marker after command is run
|
||||
EXIFTOOL_STAYOPEN_EOF = "{ready}"
|
||||
@@ -23,9 +22,7 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
exiftool_path = shutil.which('exiftool')
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
exiftool_path = shutil.which("exiftool")
|
||||
if exiftool_path:
|
||||
return exiftool_path.rstrip()
|
||||
else:
|
||||
@@ -59,7 +56,7 @@ class _ExifToolProc:
|
||||
)
|
||||
return
|
||||
|
||||
self._exiftool = exiftool if exiftool else get_exiftool_path()
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@@ -98,12 +95,12 @@ class _ExifToolProc:
|
||||
"-", # read from stdin
|
||||
"-common_args", # specifies args common to all commands subsequently run
|
||||
"-n", # no print conversion (e.g. print tag values in machine readable format)
|
||||
"-P", # Preserve file modification date/time (possible interfere w/ --touch-file)
|
||||
"-P", # Preserve file modification date/time
|
||||
"-G", # print group name for each tag
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
self._process_running = True
|
||||
|
||||
@@ -136,38 +133,72 @@ class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True):
|
||||
""" Return ExifTool object
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False """
|
||||
""" Create ExifTool object
|
||||
|
||||
Args:
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False
|
||||
Returns:
|
||||
ExifTool instance
|
||||
"""
|
||||
self.file = filepath
|
||||
self.overwrite = overwrite
|
||||
self.data = {}
|
||||
self.error = None
|
||||
# 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()
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
""" Set tag to value(s)
|
||||
if value is None, will delete tag """
|
||||
""" Set tag to value(s); if value is None, will delete tag
|
||||
|
||||
Args:
|
||||
tag: str; name of tag to set
|
||||
value: str; value to set tag to
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = [f"-{tag}={value}"]
|
||||
if self.overwrite:
|
||||
if self.overwrite and not self._context_mgr:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
if self._context_mgr:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, self.error = self.run_commands(*command)
|
||||
return self.error is None
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
If more than one value is passed, each value will be added to the tag
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
|
||||
Args:
|
||||
tag: str; tag to set
|
||||
*values: str; one or more values to set
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("Must pass at least one value")
|
||||
@@ -178,23 +209,41 @@ class ExifTool:
|
||||
raise ValueError("Can't add None value to tag")
|
||||
command.append(f"-{tag}+={value}")
|
||||
|
||||
if self.overwrite:
|
||||
if self.overwrite and not self._context_mgr:
|
||||
command.append("-overwrite_original")
|
||||
|
||||
if command:
|
||||
self.run_commands(*command)
|
||||
if self._context_mgr:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, self.error = self.run_commands(*command)
|
||||
return self.error is None
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
""" run commands in the exiftool process and return result
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename """
|
||||
""" Run commands in the exiftool process and return result.
|
||||
|
||||
Args:
|
||||
*commands: exiftool commands to run
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename
|
||||
Returns:
|
||||
(output, errror)
|
||||
output: bytes is containing output of exiftool commands
|
||||
error: if exiftool generated an error, bytes containing error string otherwise None
|
||||
|
||||
Note: Also sets self.error if error generated.
|
||||
"""
|
||||
if not (hasattr(self, "_process") and self._process):
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
if not commands:
|
||||
raise TypeError("must provide one or more command to run")
|
||||
|
||||
if self._context_mgr and self.overwrite:
|
||||
commands = list(commands)
|
||||
commands.append("-overwrite_original")
|
||||
|
||||
filename = os.fsencode(self.file) if not no_file else b""
|
||||
command_str = (
|
||||
b"\n".join([c.encode("utf-8") for c in commands])
|
||||
@@ -204,18 +253,22 @@ class ExifTool:
|
||||
+ b"-execute\n"
|
||||
)
|
||||
|
||||
if _debug():
|
||||
logging.debug(command_str)
|
||||
|
||||
# send the command
|
||||
self._process.stdin.write(command_str)
|
||||
self._process.stdin.flush()
|
||||
|
||||
# read the output
|
||||
output = b""
|
||||
error = b""
|
||||
while EXIFTOOL_STAYOPEN_EOF not in str(output):
|
||||
output += self._process.stdout.readline().strip()
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
|
||||
line = self._process.stdout.readline()
|
||||
if line.startswith(b"Warning"):
|
||||
error += line
|
||||
else:
|
||||
output += line.strip()
|
||||
error = None if error == b"" else error
|
||||
self.error = error
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], error
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
@@ -225,14 +278,14 @@ class ExifTool:
|
||||
@property
|
||||
def version(self):
|
||||
""" returns exiftool version """
|
||||
ver = self.run_commands("-ver", no_file=True)
|
||||
ver, _ = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def as_dict(self):
|
||||
def asdict(self):
|
||||
""" return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
"""
|
||||
json_str = self.run_commands("-json")
|
||||
json_str, _ = self.run_commands("-json")
|
||||
if json_str:
|
||||
exifdict = json.loads(json_str)
|
||||
return exifdict[0]
|
||||
@@ -241,12 +294,24 @@ class ExifTool:
|
||||
|
||||
def json(self):
|
||||
""" returns JSON string containing all EXIF tags and values from exiftool """
|
||||
return self.run_commands("-json")
|
||||
json, _ = self.run_commands("-json")
|
||||
return json
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
data = self.as_dict()
|
||||
data = self.asdict()
|
||||
self.data = {k: v for k, v in data.items()}
|
||||
|
||||
def __str__(self):
|
||||
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
|
||||
def __enter__(self):
|
||||
self._context_mgr = True
|
||||
self._commands = []
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if exc_type:
|
||||
return False
|
||||
elif self._commands:
|
||||
_, self.error = self.run_commands(*self._commands)
|
||||
|
||||
78
osxphotos/path_utils.py
Normal file
@@ -0,0 +1,78 @@
|
||||
""" utility functions for validating/sanitizing path components """
|
||||
|
||||
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
|
||||
import pathvalidate
|
||||
|
||||
|
||||
def sanitize_filepath(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 """
|
||||
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
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
if filename:
|
||||
filename = filename.replace("/", replacement)
|
||||
if len(filename) > MAX_FILENAME_LEN:
|
||||
parts = filename.split(".")
|
||||
drop = len(filename) - MAX_FILENAME_LEN
|
||||
if len(parts) > 1:
|
||||
# has an extension
|
||||
ext = parts.pop(-1)
|
||||
stem = ".".join(parts)
|
||||
if drop > len(stem):
|
||||
ext = ext[:-drop]
|
||||
else:
|
||||
stem = stem[:-drop]
|
||||
filename = f"{stem}.{ext}"
|
||||
else:
|
||||
filename = filename[:-drop]
|
||||
return filename
|
||||
|
||||
|
||||
def sanitize_dirname(dirname, replacement=":"):
|
||||
""" 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 = ":"
|
||||
|
||||
Returns:
|
||||
dirname with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
if dirname:
|
||||
dirname = sanitize_pathpart(dirname, replacement=replacement)
|
||||
return dirname
|
||||
|
||||
|
||||
def sanitize_pathpart(pathpart, replacement=":"):
|
||||
""" 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 = ":"
|
||||
|
||||
Returns:
|
||||
pathpart with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
if pathpart:
|
||||
pathpart = pathpart.replace("/", replacement)
|
||||
if len(pathpart) > MAX_DIRNAME_LEN:
|
||||
drop = len(pathpart) - MAX_DIRNAME_LEN
|
||||
pathpart = pathpart[:-drop]
|
||||
return pathpart
|
||||
|
||||
@@ -66,10 +66,10 @@ class PersonInfo:
|
||||
# no faces
|
||||
return []
|
||||
|
||||
def json(self):
|
||||
""" Returns JSON representation of class instance """
|
||||
def asdict(self):
|
||||
""" Returns dictionary representation of class instance """
|
||||
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
|
||||
person = {
|
||||
return {
|
||||
"uuid": self.uuid,
|
||||
"name": self.name,
|
||||
"displayname": self.display_name,
|
||||
@@ -77,7 +77,10 @@ class PersonInfo:
|
||||
"facecount": self.facecount,
|
||||
"keyphoto": keyphoto,
|
||||
}
|
||||
return json.dumps(person)
|
||||
|
||||
def json(self):
|
||||
""" Returns JSON representation of class instance """
|
||||
return json.dumps(self.asdict())
|
||||
|
||||
def __str__(self):
|
||||
return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})"
|
||||
|
||||
17
osxphotos/photoinfo/_photoinfo_comments.py
Normal file
@@ -0,0 +1,17 @@
|
||||
""" 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 []
|
||||
@@ -5,6 +5,7 @@
|
||||
_export_photo
|
||||
_write_exif_data
|
||||
_exiftool_json_sidecar
|
||||
_exiftool_dict
|
||||
_xmp_sidecar
|
||||
_write_sidecar
|
||||
"""
|
||||
@@ -308,6 +309,7 @@ def export2(
|
||||
touch_file=False,
|
||||
convert_to_jpeg=False,
|
||||
jpeg_quality=1.0,
|
||||
ignore_date_modified=False,
|
||||
):
|
||||
""" export photo, like export but with update and dry_run options
|
||||
dest: must be valid destination path or exception raised
|
||||
@@ -350,6 +352,7 @@ def export2(
|
||||
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
|
||||
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
|
||||
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
|
||||
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||
|
||||
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
|
||||
where each field is a list of file paths
|
||||
@@ -592,9 +595,8 @@ def export2(
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
convert_to_jpeg,
|
||||
False,
|
||||
fileutil=fileutil,
|
||||
jpeg_quality=jpeg_quality,
|
||||
)
|
||||
exported_files.extend(results.exported)
|
||||
update_new_files.extend(results.new)
|
||||
@@ -637,8 +639,11 @@ def export2(
|
||||
exported = []
|
||||
# export live_photo .mov file?
|
||||
live_photo = True if live_photo and self.live_photo else False
|
||||
if edited:
|
||||
if edited or self.shared:
|
||||
# exported edited version and not original
|
||||
# shared photos (in shared albums) show up as not having adjustments (not edited)
|
||||
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
|
||||
# so tell Photos to export the current version in this case
|
||||
if filename:
|
||||
# use filename stem provided
|
||||
filestem = dest.stem
|
||||
@@ -672,7 +677,6 @@ def export2(
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if exported:
|
||||
if touch_file:
|
||||
for exported_file in exported:
|
||||
@@ -697,6 +701,7 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
if not dry_run:
|
||||
try:
|
||||
@@ -742,6 +747,7 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
)[0]
|
||||
if old_data != current_data:
|
||||
@@ -757,6 +763,7 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
@@ -765,6 +772,7 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(
|
||||
@@ -780,6 +788,7 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
|
||||
export_db.set_exifdata_for_file(
|
||||
@@ -789,6 +798,7 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(
|
||||
@@ -996,62 +1006,78 @@ def _write_exif_data(
|
||||
use_persons_as_keywords=False,
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
ignore_date_modified=False,
|
||||
):
|
||||
""" write exif data to image file at filepath
|
||||
filepath: full path to the image file """
|
||||
|
||||
Args:
|
||||
filepath: full path to the image file
|
||||
use_albums_as_keywords: treat album names as keywords
|
||||
use_persons_as_keywords: treat person names as keywords
|
||||
keyword_template: (list of strings); list of template strings to render as keywords
|
||||
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Could not find file {filepath}")
|
||||
exiftool = ExifTool(filepath)
|
||||
exif_info = json.loads(
|
||||
self._exiftool_json_sidecar(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
)
|
||||
)[0]
|
||||
for exiftag, val in exif_info.items():
|
||||
if type(val) == list:
|
||||
# more than one, set first value the add additional values
|
||||
exiftool.setvalue(exiftag, val.pop(0))
|
||||
if val:
|
||||
# add any remaining items
|
||||
exiftool.addvalues(exiftag, *val)
|
||||
else:
|
||||
exiftool.setvalue(exiftag, val)
|
||||
exif_info = self._exiftool_dict(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
|
||||
with ExifTool(filepath) as exiftool:
|
||||
for exiftag, val in exif_info.items():
|
||||
if exiftag == "_CreatedBy":
|
||||
continue
|
||||
elif type(val) == list:
|
||||
for v in val:
|
||||
exiftool.setvalue(exiftag, v)
|
||||
else:
|
||||
exiftool.setvalue(exiftag, val)
|
||||
|
||||
|
||||
def _exiftool_json_sidecar(
|
||||
def _exiftool_dict(
|
||||
self,
|
||||
use_albums_as_keywords=False,
|
||||
use_persons_as_keywords=False,
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
ignore_date_modified=False,
|
||||
):
|
||||
""" return json string of EXIF details in exiftool sidecar format
|
||||
Does not include all the EXIF fields as those are likely already in the image
|
||||
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
|
||||
Args:
|
||||
use_albums_as_keywords: treat album names as keywords
|
||||
use_persons_as_keywords: treat person names as keywords
|
||||
keyword_template: (list of strings); list of template strings to render as keywords
|
||||
Exports the following:
|
||||
FileName
|
||||
ImageDescription
|
||||
Description
|
||||
Title
|
||||
TagsList
|
||||
Keywords (may include album name, person name, or template)
|
||||
Subject
|
||||
PersonInImage
|
||||
GPSLatitude, GPSLongitude
|
||||
GPSPosition
|
||||
GPSLatitudeRef, GPSLongitudeRef
|
||||
DateTimeOriginal
|
||||
OffsetTimeOriginal
|
||||
ModifyDate """
|
||||
description_template: (list of strings); list of template strings to render for the description
|
||||
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||
|
||||
Returns: dict with exiftool tags / values
|
||||
|
||||
Exports the following:
|
||||
EXIF:ImageDescription
|
||||
XMP:Description (may include template)
|
||||
XMP:Title
|
||||
XMP:TagsList
|
||||
IPTC:Keywords (may include album name, person name, or template)
|
||||
XMP:Subject
|
||||
XMP:PersonInImage
|
||||
EXIF:GPSLatitude, EXIF:GPSLongitude
|
||||
EXIF:GPSPosition
|
||||
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
||||
EXIF:DateTimeOriginal
|
||||
EXIF:OffsetTimeOriginal
|
||||
EXIF:ModifyDate
|
||||
IPTC:DigitalCreationDate
|
||||
IPTC:DateCreated
|
||||
"""
|
||||
|
||||
exif = {}
|
||||
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
|
||||
|
||||
if description_template is not None:
|
||||
description = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
@@ -1113,46 +1139,110 @@ def _exiftool_json_sidecar(
|
||||
keyword_list.extend(rendered_keywords)
|
||||
|
||||
if keyword_list:
|
||||
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list
|
||||
exif["XMP:TagsList"] = keyword_list.copy()
|
||||
exif["IPTC:Keywords"] = keyword_list.copy()
|
||||
|
||||
if person_list:
|
||||
exif["XMP:PersonInImage"] = person_list
|
||||
exif["XMP:PersonInImage"] = person_list.copy()
|
||||
|
||||
if self.keywords or person_list:
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
# only use Photos' keywords for subject
|
||||
exif["XMP:Subject"] = list(self.keywords) + person_list
|
||||
# only use Photos' keywords for subject (e.g. don't include template values)
|
||||
exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
|
||||
|
||||
# if self.favorite():
|
||||
# exif["Rating"] = 5
|
||||
|
||||
(lat, lon) = self.location
|
||||
if lat is not None and lon is not None:
|
||||
lat_str, lon_str = dd_to_dms_str(lat, lon)
|
||||
exif["EXIF:GPSLatitude"] = lat_str
|
||||
exif["EXIF:GPSLongitude"] = lon_str
|
||||
lat_ref = "North" if lat >= 0 else "South"
|
||||
lon_ref = "East" if lon >= 0 else "West"
|
||||
exif["EXIF:GPSLatitude"] = lat
|
||||
exif["EXIF:GPSLongitude"] = lon
|
||||
lat_ref = "N" if lat >= 0 else "S"
|
||||
lon_ref = "E" if lon >= 0 else "W"
|
||||
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
||||
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
||||
|
||||
# process date/time and timezone offset
|
||||
# Photos exports the following fields and sets modify date to creation date
|
||||
# [EXIF] Modify Date : 2020:10:30 00:00:00
|
||||
# [EXIF] Date/Time Original : 2020:10:30 00:00:00
|
||||
# [EXIF] Create Date : 2020:10:30 00:00:00
|
||||
# [IPTC] Digital Creation Date : 2020:10:30
|
||||
# [IPTC] Date Created : 2020:10:30
|
||||
#
|
||||
# This code deviates from Photos in one regard:
|
||||
# if photo has modification date, use it otherwise use creation date
|
||||
date = self.date
|
||||
|
||||
# exiftool expects format to "2015:01:18 12:00:00"
|
||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||
exif["EXIF:CreateDate"] = datetimeoriginal
|
||||
|
||||
offsettime = date.strftime("%z")
|
||||
# find timezone offset in format "-04:00"
|
||||
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
|
||||
offset = offset[0] # findall returns list of tuples
|
||||
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
|
||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||
exif["EXIF:OffsetTimeOriginal"] = offsettime
|
||||
|
||||
if self.date_modified is not None:
|
||||
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
|
||||
dateoriginal = date.strftime("%Y:%m:%d")
|
||||
exif["IPTC:DigitalCreationDate"] = dateoriginal
|
||||
exif["IPTC:DateCreated"] = dateoriginal
|
||||
|
||||
json_str = json.dumps([exif])
|
||||
return json_str
|
||||
if self.date_modified is not None and not ignore_date_modified:
|
||||
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
|
||||
else:
|
||||
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
|
||||
return exif
|
||||
|
||||
|
||||
def _exiftool_json_sidecar(
|
||||
self,
|
||||
use_albums_as_keywords=False,
|
||||
use_persons_as_keywords=False,
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
ignore_date_modified=False,
|
||||
):
|
||||
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
|
||||
Args:
|
||||
use_albums_as_keywords: treat album names as keywords
|
||||
use_persons_as_keywords: treat person names as keywords
|
||||
keyword_template: (list of strings); list of template strings to render as keywords
|
||||
description_template: (list of strings); list of template strings to render for the description
|
||||
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||
|
||||
Returns: dict with exiftool tags / values
|
||||
|
||||
Exports the following:
|
||||
EXIF:ImageDescription
|
||||
XMP:Description (may include template)
|
||||
XMP:Title
|
||||
XMP:TagsList
|
||||
IPTC:Keywords (may include album name, person name, or template)
|
||||
XMP:Subject
|
||||
XMP:PersonInImage
|
||||
EXIF:GPSLatitude, EXIF:GPSLongitude
|
||||
EXIF:GPSPosition
|
||||
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
||||
EXIF:DateTimeOriginal
|
||||
EXIF:OffsetTimeOriginal
|
||||
EXIF:ModifyDate
|
||||
IPTC:DigitalCreationDate
|
||||
IPTC:DateCreated
|
||||
"""
|
||||
exif = self._exiftool_dict(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
)
|
||||
return json.dumps([exif])
|
||||
|
||||
|
||||
def _xmp_sidecar(
|
||||
|
||||
@@ -5,6 +5,7 @@ PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -52,6 +53,7 @@ class PhotoInfo:
|
||||
export,
|
||||
export2,
|
||||
_export_photo,
|
||||
_exiftool_dict,
|
||||
_exiftool_json_sidecar,
|
||||
_write_exif_data,
|
||||
_write_sidecar,
|
||||
@@ -59,6 +61,7 @@ class PhotoInfo:
|
||||
ExportResults,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import score, ScoreInfo
|
||||
from ._photoinfo_comments import comments, likes
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
@@ -68,7 +71,11 @@ class PhotoInfo:
|
||||
@property
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
and self.raw_original
|
||||
):
|
||||
# return the JPEG version as that's what Photos 5+ does
|
||||
return self._info["raw_pair_info"]["filename"]
|
||||
else:
|
||||
@@ -78,7 +85,11 @@ class PhotoInfo:
|
||||
def original_filename(self):
|
||||
""" original filename of the picture
|
||||
Photos 5 mangles filenames upon import """
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
and self.raw_original
|
||||
):
|
||||
# return the JPEG version as that's what Photos 5+ does
|
||||
return self._info["raw_pair_info"]["originalFilename"]
|
||||
else:
|
||||
@@ -93,12 +104,20 @@ class PhotoInfo:
|
||||
def date_modified(self):
|
||||
""" image modification date as timezone aware datetime object
|
||||
or None if no modification date set """
|
||||
imagedate = self._info["lastmodifieddate"]
|
||||
if imagedate:
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
return imagedate.astimezone(tz=tz)
|
||||
|
||||
# Photos <= 4 provides no way to get date of adjustment and will update
|
||||
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
|
||||
# only report lastmodified date for Photos <=4 if photo is edited;
|
||||
# even in this case, the date could be incorrect
|
||||
if self.hasadjustments or self._db._db_version > _PHOTOS_4_VERSION:
|
||||
imagedate = self._info["lastmodifieddate"]
|
||||
if imagedate:
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
return imagedate.astimezone(tz=tz)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -177,18 +196,15 @@ class PhotoInfo:
|
||||
""" absolute path on disk of the edited picture """
|
||||
""" None if photo has not been edited """
|
||||
|
||||
# TODO: break this code into a _path_edited_4 and _path_edited_5
|
||||
# version to simplify the big if/then; same for path_live_photo
|
||||
|
||||
try:
|
||||
return self._path_edited
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._path_edited = self._path_edited_4()
|
||||
return self._path_edited
|
||||
else:
|
||||
self._path_edited = self._path_edited_5()
|
||||
return self._path_edited
|
||||
|
||||
return self._path_edited
|
||||
|
||||
def _path_edited_5(self):
|
||||
""" return path_edited for Photos >= 5 """
|
||||
@@ -246,8 +262,6 @@ class PhotoInfo:
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
|
||||
# logging.debug(photopath)
|
||||
|
||||
return photopath
|
||||
|
||||
def _path_edited_4(self):
|
||||
@@ -280,7 +294,7 @@ class PhotoInfo:
|
||||
# could be elsewhere--I haven't figured out this logic yet
|
||||
# first see if it's in 00
|
||||
photopath = os.path.join(
|
||||
library, "resources", "media", "version", folder_id, "00", filename,
|
||||
library, "resources", "media", "version", folder_id, "00", filename
|
||||
)
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
@@ -545,6 +559,9 @@ class PhotoInfo:
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
||||
return self._info["raw_pair_info"]["UTI"]
|
||||
elif self.shared:
|
||||
# TODO: need reliable way to get original UTI for shared
|
||||
return self.uti
|
||||
else:
|
||||
return self._info["UTI_original"]
|
||||
|
||||
@@ -805,6 +822,9 @@ class PhotoInfo:
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
replacement=":",
|
||||
):
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
@@ -817,6 +837,9 @@ class PhotoInfo:
|
||||
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
|
||||
replacement: str, value to replace any illegal file path characters with; default = ":"
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
@@ -828,6 +851,9 @@ class PhotoInfo:
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
replacement=replacement,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -937,22 +963,23 @@ class PhotoInfo:
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
def json(self):
|
||||
""" return JSON representation """
|
||||
def asdict(self):
|
||||
""" return dict representation """
|
||||
|
||||
date_modified_iso = (
|
||||
self.date_modified.isoformat() if self.date_modified else None
|
||||
)
|
||||
folders = {album.title: album.folder_names for album in self.album_info}
|
||||
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
|
||||
place = self.place.as_dict() if self.place else {}
|
||||
place = self.place.asdict() if self.place else {}
|
||||
score = dataclasses.asdict(self.score) if self.score else {}
|
||||
comments = [comment.asdict() for comment in self.comments]
|
||||
likes = [like.asdict() for like in self.likes]
|
||||
faces = [face.asdict() for face in self.face_info]
|
||||
|
||||
pic = {
|
||||
return {
|
||||
"library": self._db._library_path,
|
||||
"uuid": self.uuid,
|
||||
"filename": self.filename,
|
||||
"original_filename": self.original_filename,
|
||||
"date": self.date.isoformat(),
|
||||
"date": self.date,
|
||||
"description": self.description,
|
||||
"title": self.title,
|
||||
"keywords": self.keywords,
|
||||
@@ -961,6 +988,7 @@ class PhotoInfo:
|
||||
"albums": self.albums,
|
||||
"folders": folders,
|
||||
"persons": self.persons,
|
||||
"faces": faces,
|
||||
"path": self.path,
|
||||
"ismissing": self.ismissing,
|
||||
"hasadjustments": self.hasadjustments,
|
||||
@@ -974,12 +1002,13 @@ class PhotoInfo:
|
||||
"isphoto": self.isphoto,
|
||||
"ismovie": self.ismovie,
|
||||
"uti": self.uti,
|
||||
"uti_original": self.uti_original,
|
||||
"burst": self.burst,
|
||||
"live_photo": self.live_photo,
|
||||
"path_live_photo": self.path_live_photo,
|
||||
"iscloudasset": self.iscloudasset,
|
||||
"incloud": self.incloud,
|
||||
"date_modified": date_modified_iso,
|
||||
"date_modified": self.date_modified,
|
||||
"portrait": self.portrait,
|
||||
"screenshot": self.screenshot,
|
||||
"slow_mo": self.slow_mo,
|
||||
@@ -988,6 +1017,8 @@ class PhotoInfo:
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"has_raw": self.has_raw,
|
||||
"israw": self.israw,
|
||||
"raw_original": self.raw_original,
|
||||
"uti_raw": self.uti_raw,
|
||||
"path_raw": self.path_raw,
|
||||
"place": place,
|
||||
@@ -1001,8 +1032,18 @@ class PhotoInfo:
|
||||
"original_width": self.original_width,
|
||||
"original_orientation": self.original_orientation,
|
||||
"original_filesize": self.original_filesize,
|
||||
"comments": comments,
|
||||
"likes": likes,
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
def json(self):
|
||||
""" Return JSON representation """
|
||||
|
||||
def default(o):
|
||||
if isinstance(o, (datetime.date, datetime.datetime)):
|
||||
return o.isoformat()
|
||||
|
||||
return json.dumps(self.asdict(), sort_keys=True, default=default)
|
||||
|
||||
def __eq__(self, other):
|
||||
""" Compare two PhotoInfo objects for equality """
|
||||
|
||||
157
osxphotos/photosdb/_photosdb_process_comments.py
Normal file
@@ -0,0 +1,157 @@
|
||||
""" PhotosDB method for processing comments and likes on shared photos.
|
||||
Do not import this module directly """
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION, TIME_DELTA
|
||||
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
|
||||
"""
|
||||
self._db_hashed_person_id = {}
|
||||
self._db_comments_uuid = {}
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
_process_comments_4(self)
|
||||
else:
|
||||
_process_comments_5(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommentInfo:
|
||||
""" Class for shared photo comments """
|
||||
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
text: str
|
||||
|
||||
def asdict(self):
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LikeInfo:
|
||||
""" Class for shared photo likes """
|
||||
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
|
||||
def asdict(self):
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
# 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 """
|
||||
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 """
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
ZINVITEEHASHEDPERSONID,
|
||||
ZINVITEEFIRSTNAME,
|
||||
ZINVITEELASTNAME,
|
||||
ZINVITEEFULLNAME
|
||||
FROM
|
||||
ZCLOUDSHAREDALBUMINVITATIONRECORD
|
||||
"""
|
||||
)
|
||||
|
||||
# order of results
|
||||
# 0: ZINVITEEHASHEDPERSONID,
|
||||
# 1: ZINVITEEFIRSTNAME,
|
||||
# 2: ZINVITEELASTNAME,
|
||||
# 3: ZINVITEEFULLNAME
|
||||
|
||||
photosdb._db_hashed_person_id = {}
|
||||
for row in results.fetchall():
|
||||
person_id = row[0]
|
||||
photosdb._db_hashed_person_id[person_id] = {
|
||||
"first_name": normalize_unicode(row[1]),
|
||||
"last_name": normalize_unicode(row[2]),
|
||||
"full_name": normalize_unicode(row[3]),
|
||||
}
|
||||
|
||||
results = conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
{asset_table}.ZUUID, -- UUID of the photo
|
||||
ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like"
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like
|
||||
ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment
|
||||
FROM ZCLOUDSHAREDCOMMENT
|
||||
JOIN {asset_table} ON
|
||||
{asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZCOMMENTEDASSET
|
||||
OR
|
||||
{asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZLIKEDASSET
|
||||
"""
|
||||
)
|
||||
|
||||
# order of results
|
||||
# 0: ZGENERICASSET.ZUUID, -- UUID of the photo
|
||||
# 1: ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like"
|
||||
# 2: ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment
|
||||
# 3: ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment
|
||||
# 4: ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like
|
||||
# 5: ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment
|
||||
|
||||
photosdb._db_comments_uuid = {}
|
||||
for row in results:
|
||||
uuid = row[0]
|
||||
is_like = bool(row[1])
|
||||
text = normalize_unicode(row[3])
|
||||
try:
|
||||
user_name = photosdb._db_hashed_person_id[row[4]]["full_name"]
|
||||
except KeyError:
|
||||
user_name = None
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.fromtimestamp(row[2] + TIME_DELTA)
|
||||
except:
|
||||
dt = datetime.datetime(1970, 1, 1)
|
||||
|
||||
ismine = bool(row[5])
|
||||
|
||||
try:
|
||||
db_comments = photosdb._db_comments_uuid[uuid]
|
||||
except KeyError:
|
||||
photosdb._db_comments_uuid[uuid] = {"likes": [], "comments": []}
|
||||
db_comments = photosdb._db_comments_uuid[uuid]
|
||||
|
||||
if is_like:
|
||||
db_comments["likes"].append(LikeInfo(dt, user_name, ismine))
|
||||
elif text:
|
||||
db_comments["comments"].append(CommentInfo(dt, user_name, ismine, text))
|
||||
|
||||
# sort results
|
||||
for uuid in photosdb._db_comments_uuid:
|
||||
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)
|
||||
|
||||
conn.close()
|
||||
@@ -44,6 +44,7 @@ from ..utils import (
|
||||
_get_os_version,
|
||||
_open_sql_file,
|
||||
get_last_library_path,
|
||||
noop,
|
||||
normalize_unicode,
|
||||
)
|
||||
from .photosdb_utils import get_db_model_version, get_db_version
|
||||
@@ -67,12 +68,19 @@ class PhotosDB:
|
||||
labels_normalized_as_dict,
|
||||
)
|
||||
from ._photosdb_process_scoreinfo import _process_scoreinfo
|
||||
from ._photosdb_process_comments import _process_comments
|
||||
|
||||
def __init__(self, *dbfile_, dbfile=None):
|
||||
""" create a new PhotosDB object
|
||||
path to photos library or database may be specified EITHER as first argument or as named argument dbfile=path
|
||||
specify full path to photos library or photos.db as first argument
|
||||
specify path to photos library or photos.db using named argument dbfile=path """
|
||||
def __init__(self, dbfile=None, verbose=None):
|
||||
""" Create a new PhotosDB object.
|
||||
|
||||
Args:
|
||||
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
|
||||
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if dbfile is not a valid Photos library.
|
||||
TypeError if verbose is not None and not callable.
|
||||
"""
|
||||
|
||||
# Check OS version
|
||||
system = platform.system()
|
||||
@@ -84,6 +92,12 @@ class PhotosDB:
|
||||
f"you have {system}, OS version: {major}"
|
||||
)
|
||||
|
||||
if verbose is None:
|
||||
verbose = noop
|
||||
elif not callable(verbose):
|
||||
raise TypeError("verbose must be callable")
|
||||
self._verbose = verbose
|
||||
|
||||
# create a temporary directory
|
||||
# tempfile.TemporaryDirectory gets cleaned up when the object does
|
||||
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -216,25 +230,7 @@ class PhotosDB:
|
||||
if _debug():
|
||||
logging.debug(f"dbfile = {dbfile}")
|
||||
|
||||
# get the path to photos library database
|
||||
if dbfile_:
|
||||
# got a library path as argument
|
||||
if dbfile:
|
||||
# shouldn't pass via both *args and dbfile=
|
||||
raise TypeError(
|
||||
f"photos database path must be specified as argument or "
|
||||
f"named parameter dbfile but not both: args: {dbfile_}, dbfile: {dbfile}",
|
||||
dbfile_,
|
||||
dbfile,
|
||||
)
|
||||
elif len(dbfile_) == 1:
|
||||
dbfile = dbfile_[0]
|
||||
else:
|
||||
raise TypeError(
|
||||
f"__init__ takes only a single argument (photos database path): {dbfile_}",
|
||||
dbfile_,
|
||||
)
|
||||
elif dbfile is None:
|
||||
if dbfile is None:
|
||||
dbfile = get_last_library_path()
|
||||
if dbfile is None:
|
||||
# get_last_library_path must have failed to find library
|
||||
@@ -262,11 +258,14 @@ class PhotosDB:
|
||||
# or photosanalysisd
|
||||
self._dbfile = self._dbfile_actual = self._tmp_db = os.path.abspath(dbfile)
|
||||
|
||||
verbose(f"Processing database {self._dbfile}")
|
||||
|
||||
# if database is exclusively locked, make a copy of it and use the copy
|
||||
# Photos maintains an exclusive lock on the database file while Photos is open
|
||||
# photoanalysisd sometimes maintains this lock even after Photos is closed
|
||||
# In those cases, make a temp copy of the file for sqlite3 to read
|
||||
if _db_is_locked(self._dbfile):
|
||||
verbose(f"Database locked, creating temporary copy.")
|
||||
self._tmp_db = self._copy_db_file(self._dbfile)
|
||||
|
||||
self._db_version = get_db_version(self._tmp_db)
|
||||
@@ -279,8 +278,10 @@ class PhotosDB:
|
||||
raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
|
||||
else:
|
||||
self._dbfile_actual = self._tmp_db = dbfile
|
||||
verbose(f"Processing database {self._dbfile_actual}")
|
||||
# if database is exclusively locked, make a copy of it and use the copy
|
||||
if _db_is_locked(self._dbfile_actual):
|
||||
verbose(f"Database locked, creating temporary copy.")
|
||||
self._tmp_db = self._copy_db_file(self._dbfile_actual)
|
||||
|
||||
if _debug():
|
||||
@@ -549,10 +550,15 @@ class PhotosDB:
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version <= 4.0 """
|
||||
|
||||
verbose = self._verbose
|
||||
verbose("Processing database.")
|
||||
verbose(f"Database version: {self._db_version}.")
|
||||
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
verbose("Processing persons in photos.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
@@ -618,6 +624,7 @@ class PhotosDB:
|
||||
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
|
||||
|
||||
# get information on detected faces
|
||||
verbose("Processing detected faces in photos.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
@@ -655,6 +662,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# Get info on albums
|
||||
verbose("Processing albums.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKAlbum.uuid,
|
||||
@@ -797,6 +805,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfolder_details))
|
||||
|
||||
# Get info on keywords
|
||||
verbose("Processing keywords.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKKeyword.name,
|
||||
@@ -824,6 +833,7 @@ class PhotosDB:
|
||||
self._dbvolumes[vol[0]] = vol[1]
|
||||
|
||||
# Get photo details
|
||||
verbose("Processing photo details.")
|
||||
if self._db_version < _PHOTOS_3_VERSION:
|
||||
# Photos < 3.0 doesn't have RKVersion.selfPortrait (selfie)
|
||||
c.execute(
|
||||
@@ -1113,6 +1123,7 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["fok_import_session"] = None
|
||||
|
||||
# get additional details from RKMaster, needed for RAW processing
|
||||
verbose("Processing additional photo details.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKMaster.uuid,
|
||||
@@ -1286,6 +1297,7 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["incloud"] = True if row[2] == 1 else False
|
||||
|
||||
# get location data
|
||||
verbose("Processing location data.")
|
||||
# get the country codes
|
||||
country_codes = c.execute(
|
||||
"SELECT modelID, countryCode "
|
||||
@@ -1372,6 +1384,7 @@ class PhotosDB:
|
||||
conn.close()
|
||||
|
||||
# process faces
|
||||
verbose("Processing face details.")
|
||||
self._process_faceinfo()
|
||||
|
||||
# add faces and keywords to photo data
|
||||
@@ -1408,6 +1421,7 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["volume"] = None
|
||||
|
||||
# done processing, dump debug data if requested
|
||||
verbose("Done processing details from Photos library.")
|
||||
if _debug():
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
@@ -1483,12 +1497,14 @@ class PhotosDB:
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"_process_database5")
|
||||
|
||||
verbose = self._verbose
|
||||
verbose(f"Processing database.")
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# some of the tables/columns have different names in different versions of Photos
|
||||
photos_ver = get_db_model_version(self._tmp_db)
|
||||
self._photos_ver = photos_ver
|
||||
verbose(f"Database version: {self._db_version}, {photos_ver}.")
|
||||
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
|
||||
keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
|
||||
album_join = _DB_TABLE_NAMES[photos_ver]["ALBUM_JOIN"]
|
||||
@@ -1502,6 +1518,7 @@ class PhotosDB:
|
||||
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
verbose("Processing persons in photos.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
@@ -1567,6 +1584,7 @@ class PhotosDB:
|
||||
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
|
||||
|
||||
# get information on detected faces
|
||||
verbose("Processing detected faces in photos.")
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
@@ -1601,6 +1619,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# get details about albums
|
||||
verbose("Processing albums.")
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
ZGENERICALBUM.ZUUID,
|
||||
@@ -1719,6 +1738,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbalbum_folders))
|
||||
|
||||
# get details on keywords
|
||||
verbose("Processing keywords.")
|
||||
c.execute(
|
||||
f"""SELECT ZKEYWORD.ZTITLE, {asset_table}.ZUUID
|
||||
FROM {asset_table}
|
||||
@@ -1750,6 +1770,7 @@ class PhotosDB:
|
||||
logging.debug(self._dbvolumes)
|
||||
|
||||
# get details about photos
|
||||
verbose("Processing photo details.")
|
||||
logging.debug(f"Getting information about photos")
|
||||
c.execute(
|
||||
f"""SELECT {asset_table}.ZUUID,
|
||||
@@ -1788,7 +1809,8 @@ class PhotosDB:
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE,
|
||||
{depth_state}
|
||||
{depth_state},
|
||||
{asset_table}.ZADJUSTMENTTIMESTAMP
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
ORDER BY {asset_table}.ZUUID """
|
||||
@@ -1832,6 +1854,7 @@ class PhotosDB:
|
||||
# 34 ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
|
||||
# 35 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
|
||||
# 36 ZGENERICASSET.ZDEPTHSTATES / ZASSET.ZDEPTHTYPE
|
||||
# 37 ZGENERICASSET.ZADJUSTMENTTIMESTAMP -- when was photo edited?
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -1845,9 +1868,9 @@ class PhotosDB:
|
||||
# There are sometimes negative values for lastmodifieddate in the database
|
||||
# I don't know what these mean but they will raise exception in datetime if
|
||||
# not accounted for
|
||||
info["lastmodifieddate_timestamp"] = row[4]
|
||||
info["lastmodifieddate_timestamp"] = row[37]
|
||||
try:
|
||||
info["lastmodifieddate"] = datetime.fromtimestamp(row[4] + TIME_DELTA)
|
||||
info["lastmodifieddate"] = datetime.fromtimestamp(row[37] + TIME_DELTA)
|
||||
except ValueError:
|
||||
info["lastmodifieddate"] = None
|
||||
except TypeError:
|
||||
@@ -1908,6 +1931,7 @@ class PhotosDB:
|
||||
info["type"] = None
|
||||
|
||||
info["UTI"] = row[18]
|
||||
info["UTI_original"] = None # filled in later
|
||||
|
||||
# handle burst photos
|
||||
# if burst photo, determine whether or not it's a selected burst photo
|
||||
@@ -2039,6 +2063,7 @@ class PhotosDB:
|
||||
# 1 ZGENERICASSET.ZIMPORTSESSION
|
||||
# 2 ZGENERICASSET.Z_FOK_IMPORTSESSION
|
||||
# 3 ZGENERICALBUM.ZUUID,
|
||||
verbose("Processing import sessions.")
|
||||
c.execute(
|
||||
f"""SELECT
|
||||
{asset_table}.ZUUID,
|
||||
@@ -2061,6 +2086,7 @@ class PhotosDB:
|
||||
logging.debug(f"No info record for uuid {uuid} for import session")
|
||||
|
||||
# Get extended description
|
||||
verbose("Processing additional photo details.")
|
||||
c.execute(
|
||||
f"""SELECT {asset_table}.ZUUID,
|
||||
ZASSETDESCRIPTION.ZLONGDESCRIPTION
|
||||
@@ -2240,18 +2266,27 @@ class PhotosDB:
|
||||
conn.close()
|
||||
|
||||
# process face info
|
||||
verbose("Processing face details.")
|
||||
self._process_faceinfo()
|
||||
|
||||
# process search info
|
||||
verbose("Processing photo labels.")
|
||||
self._process_searchinfo()
|
||||
|
||||
# process exif info
|
||||
verbose("Processing EXIF details.")
|
||||
self._process_exifinfo()
|
||||
|
||||
# process computed scores
|
||||
verbose("Processing computed aesthetic scores.")
|
||||
self._process_scoreinfo()
|
||||
|
||||
# process shared comments/likes
|
||||
verbose("Processing comments and likes for shared photos.")
|
||||
self._process_comments()
|
||||
|
||||
# done processing, dump debug data if requested
|
||||
verbose("Done processing details from Photos library.")
|
||||
if _debug():
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
import datetime
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import pathlib
|
||||
import re
|
||||
from functools import partial
|
||||
|
||||
from ._constants import _UNKNOWN_PERSON
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
|
||||
|
||||
# ensure locale set to user's locale
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
@@ -28,33 +30,34 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{title}": "Title of the photo",
|
||||
"{descr}": "Description of the photo",
|
||||
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
|
||||
"{created.year}": "4-digit year of file creation time",
|
||||
"{created.yy}": "2-digit year of file creation time",
|
||||
"{created.mm}": "2-digit month of the file creation time (zero padded)",
|
||||
"{created.month}": "Month name in user's locale of the file creation time",
|
||||
"{created.mon}": "Month abbreviation in the user's locale of the file creation time",
|
||||
"{created.dd}": "2-digit day of the month (zero padded) of file creation time",
|
||||
"{created.dow}": "Day of week in user's locale of the file creation time",
|
||||
"{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)",
|
||||
"{created.hour}": "2-digit hour of the file creation time",
|
||||
"{created.min}": "2-digit minute of the file creation time",
|
||||
"{created.sec}": "2-digit second of the file creation time",
|
||||
"{created.year}": "4-digit year of photo creation time",
|
||||
"{created.yy}": "2-digit year of photo creation time",
|
||||
"{created.mm}": "2-digit month of the photo creation time (zero padded)",
|
||||
"{created.month}": "Month name in user's locale of the photo creation time",
|
||||
"{created.mon}": "Month abbreviation in the user's locale of the photo creation time",
|
||||
"{created.dd}": "2-digit day of the month (zero padded) of photo creation time",
|
||||
"{created.dow}": "Day of week in user's locale of the photo creation time",
|
||||
"{created.doy}": "3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded)",
|
||||
"{created.hour}": "2-digit hour of the photo creation time",
|
||||
"{created.min}": "2-digit minute of the photo creation time",
|
||||
"{created.sec}": "2-digit second of the photo creation time",
|
||||
"{created.strftime}": "Apply strftime template to file creation date/time. Should be used in form "
|
||||
+ "{created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
|
||||
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
+ "If used with no template will return null value. "
|
||||
+ "See https://strftime.org/ for help on strftime templates.",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
|
||||
"{modified.year}": "4-digit year of file modification time",
|
||||
"{modified.yy}": "2-digit year of file modification time",
|
||||
"{modified.mm}": "2-digit month of the file modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the file modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
|
||||
"{modified.dd}": "2-digit day of the month (zero padded) of the file modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
|
||||
"{modified.hour}": "2-digit hour of the file modification time",
|
||||
"{modified.min}": "2-digit minute of the file modification time",
|
||||
"{modified.sec}": "2-digit second of the file modification time",
|
||||
"{modified.year}": "4-digit year of photo modification time",
|
||||
"{modified.yy}": "2-digit year of photo modification time",
|
||||
"{modified.mm}": "2-digit month of the photo modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the photo modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time",
|
||||
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time",
|
||||
"{modified.dow}": "Day of week in user's locale of the photo modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)",
|
||||
"{modified.hour}": "2-digit hour of the photo modification time",
|
||||
"{modified.min}": "2-digit minute of the photo modification time",
|
||||
"{modified.sec}": "2-digit second of the photo modification time",
|
||||
# "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
|
||||
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
|
||||
# + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
@@ -100,6 +103,7 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
"{person}": "Person(s) / face(s) in a photo",
|
||||
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
|
||||
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
|
||||
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)",
|
||||
}
|
||||
|
||||
# Just the multi-valued substitution names without the braces
|
||||
@@ -131,6 +135,9 @@ class PhotoTemplate:
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
replacement=":",
|
||||
):
|
||||
""" Render a filename or directory template
|
||||
|
||||
@@ -142,6 +149,9 @@ class PhotoTemplate:
|
||||
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
|
||||
replacement: str, value to replace any illegal file path characters with; default = ":"
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
@@ -164,35 +174,51 @@ class PhotoTemplate:
|
||||
# there would be 6 possible renderings (2 albums x 3 persons)
|
||||
|
||||
# regex to find {template_field,optional_default} in strings
|
||||
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
||||
# for explanation of regex see https://regex101.com/r/MbOlJV/4
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-\%. ]+))?)(?=\}(?!\}))\}"
|
||||
regex = r"(?<!\{)\{([^}]*\+)?([^\\,}+]+)(,{0,1}([\w\-\%. ]+)?)(?=\}(?!\}))\}"
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
def make_subst_function(self, none_str, get_func=self.get_template_value):
|
||||
# used by make_subst_function to get the value for a template substitution
|
||||
get_func = partial(
|
||||
self.get_template_value,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
replacement=replacement,
|
||||
)
|
||||
|
||||
def make_subst_function(self, none_str, get_func=get_func):
|
||||
""" returns: substitution function for use in re.sub
|
||||
none_str: value to use if substitution lookup is None and no default provided
|
||||
get_func: function that gets the substitution value for a given template field
|
||||
default is get_template_value which handles the single-value fields """
|
||||
|
||||
# closure to capture photo, none_str in subst
|
||||
# closure to capture photo, none_str, filename, dirname in subst
|
||||
def subst(matchobj):
|
||||
groups = len(matchobj.groups())
|
||||
if groups == 4:
|
||||
delim = matchobj.group(1)
|
||||
field = matchobj.group(2)
|
||||
default = matchobj.group(3)
|
||||
default_val = matchobj.group(4)
|
||||
try:
|
||||
val = get_func(matchobj.group(1), matchobj.group(3))
|
||||
val = get_func(field, default_val)
|
||||
except ValueError:
|
||||
return matchobj.group(0)
|
||||
|
||||
if val is None:
|
||||
return (
|
||||
matchobj.group(3)
|
||||
if matchobj.group(3) is not None
|
||||
else none_str
|
||||
)
|
||||
else:
|
||||
return val
|
||||
# field valid but didn't match a value
|
||||
if default == ",":
|
||||
val = ""
|
||||
else:
|
||||
val = (
|
||||
default_val
|
||||
if default_val is not None
|
||||
else none_str
|
||||
)
|
||||
|
||||
return val
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected number of groups: expected 4, got {groups}"
|
||||
@@ -228,18 +254,24 @@ class PhotoTemplate:
|
||||
# '2011/Album2/keyword1/person1',
|
||||
# '2011/Album2/keyword2/person1',]
|
||||
|
||||
rendered_strings = set([rendered])
|
||||
rendered_strings = [rendered]
|
||||
for field in MULTI_VALUE_SUBSTITUTIONS:
|
||||
# Build a regex that matches only the field being processed
|
||||
re_str = r"(?<!\\)\{(" + field + r")(,(([\w\-\%. ]{0,})))?\}"
|
||||
re_str = r"(?<!\{)\{([^}]*\+)?(" + field + r")(,{0,1}([\w\-\%. ]+)?)(?=\}(?!\}))\}"
|
||||
regex_multi = re.compile(re_str)
|
||||
|
||||
# holds each of the new rendered_strings, set() to avoid duplicates
|
||||
new_strings = set()
|
||||
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
|
||||
new_strings = {}
|
||||
|
||||
for str_template in rendered_strings:
|
||||
if regex_multi.search(str_template):
|
||||
values = self.get_template_value_multi(field, path_sep)
|
||||
values = self.get_template_value_multi(
|
||||
field,
|
||||
path_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
replacement=replacement,
|
||||
)
|
||||
if expand_inplace:
|
||||
# instead of returning multiple strings, join values into a single string
|
||||
val = (
|
||||
@@ -248,11 +280,11 @@ class PhotoTemplate:
|
||||
else None
|
||||
)
|
||||
|
||||
def lookup_template_value_multi(lookup_value, default):
|
||||
def lookup_template_value_multi(lookup_value, _):
|
||||
""" Closure passed to make_subst_function get_func
|
||||
Capture val and field in the closure
|
||||
Allows make_subst_function to be re-used w/o modification
|
||||
default is not used but required so signature matches get_template_value """
|
||||
_ is not used but required so signature matches get_template_value """
|
||||
if lookup_value == field:
|
||||
return val
|
||||
else:
|
||||
@@ -269,11 +301,11 @@ class PhotoTemplate:
|
||||
# create a new template string for each value
|
||||
for val in values:
|
||||
|
||||
def lookup_template_value_multi(lookup_value, default):
|
||||
def lookup_template_value_multi(lookup_value, _):
|
||||
""" Closure passed to make_subst_function get_func
|
||||
Capture val and field in the closure
|
||||
Allows make_subst_function to be re-used w/o modification
|
||||
default is not used but required so signature matches get_template_value """
|
||||
_ is not used but required so signature matches get_template_value """
|
||||
if lookup_value == field:
|
||||
return val
|
||||
else:
|
||||
@@ -285,19 +317,19 @@ class PhotoTemplate:
|
||||
self, none_str, get_func=lookup_template_value_multi
|
||||
)
|
||||
new_string = regex_multi.sub(subst, str_template)
|
||||
new_strings.add(new_string)
|
||||
new_strings[new_string] = 1
|
||||
|
||||
# update rendered_strings for the next field to process
|
||||
rendered_strings = new_strings
|
||||
rendered_strings = list(new_strings.keys())
|
||||
|
||||
# find any {fields} that weren't replaced
|
||||
unmatched = []
|
||||
for rendered_str in rendered_strings:
|
||||
unmatched.extend(
|
||||
[
|
||||
no_match[0]
|
||||
no_match[1]
|
||||
for no_match in re.findall(regex, rendered_str)
|
||||
if no_match[0] not in unmatched
|
||||
if no_match[1] not in unmatched
|
||||
]
|
||||
)
|
||||
|
||||
@@ -307,14 +339,24 @@ class PhotoTemplate:
|
||||
for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
if filename:
|
||||
rendered_strings = [
|
||||
sanitize_filename(rendered_str) for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
return rendered_strings, unmatched
|
||||
|
||||
def get_template_value(self, field, default):
|
||||
def get_template_value(
|
||||
self, field, default, filename=False, dirname=False, replacement=":"
|
||||
):
|
||||
"""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
|
||||
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
|
||||
replacement: str, value to replace any illegal file path characters with; default = ":"
|
||||
|
||||
Returns:
|
||||
The matching template value (which may be None).
|
||||
@@ -327,289 +369,242 @@ class PhotoTemplate:
|
||||
if self.today is None:
|
||||
self.today = datetime.datetime.now()
|
||||
|
||||
# must be a valid keyword
|
||||
value = None
|
||||
|
||||
# wouldn't a switch/case statement be nice...
|
||||
if field == "name":
|
||||
return pathlib.Path(self.photo.filename).stem
|
||||
|
||||
if field == "original_name":
|
||||
return pathlib.Path(self.photo.original_filename).stem
|
||||
|
||||
if field == "title":
|
||||
return self.photo.title
|
||||
|
||||
if field == "descr":
|
||||
return self.photo.description
|
||||
|
||||
if field == "created.date":
|
||||
return DateTimeFormatter(self.photo.date).date
|
||||
|
||||
if field == "created.year":
|
||||
return DateTimeFormatter(self.photo.date).year
|
||||
|
||||
if field == "created.yy":
|
||||
return DateTimeFormatter(self.photo.date).yy
|
||||
|
||||
if field == "created.mm":
|
||||
return DateTimeFormatter(self.photo.date).mm
|
||||
|
||||
if field == "created.month":
|
||||
return DateTimeFormatter(self.photo.date).month
|
||||
|
||||
if field == "created.mon":
|
||||
return DateTimeFormatter(self.photo.date).mon
|
||||
|
||||
if field == "created.dd":
|
||||
return DateTimeFormatter(self.photo.date).dd
|
||||
|
||||
if field == "created.dow":
|
||||
return DateTimeFormatter(self.photo.date).dow
|
||||
|
||||
if field == "created.doy":
|
||||
return DateTimeFormatter(self.photo.date).doy
|
||||
|
||||
if field == "created.hour":
|
||||
return DateTimeFormatter(self.photo.date).hour
|
||||
|
||||
if field == "created.min":
|
||||
return DateTimeFormatter(self.photo.date).min
|
||||
|
||||
if field == "created.sec":
|
||||
return DateTimeFormatter(self.photo.date).sec
|
||||
|
||||
if field == "created.strftime":
|
||||
value = pathlib.Path(self.photo.filename).stem
|
||||
elif field == "original_name":
|
||||
value = pathlib.Path(self.photo.original_filename).stem
|
||||
elif field == "title":
|
||||
value = self.photo.title
|
||||
elif field == "descr":
|
||||
value = self.photo.description
|
||||
elif field == "created.date":
|
||||
value = DateTimeFormatter(self.photo.date).date
|
||||
elif field == "created.year":
|
||||
value = DateTimeFormatter(self.photo.date).year
|
||||
elif field == "created.yy":
|
||||
value = DateTimeFormatter(self.photo.date).yy
|
||||
elif field == "created.mm":
|
||||
value = DateTimeFormatter(self.photo.date).mm
|
||||
elif field == "created.month":
|
||||
value = DateTimeFormatter(self.photo.date).month
|
||||
elif field == "created.mon":
|
||||
value = DateTimeFormatter(self.photo.date).mon
|
||||
elif field == "created.dd":
|
||||
value = DateTimeFormatter(self.photo.date).dd
|
||||
elif field == "created.dow":
|
||||
value = DateTimeFormatter(self.photo.date).dow
|
||||
elif field == "created.doy":
|
||||
value = DateTimeFormatter(self.photo.date).doy
|
||||
elif field == "created.hour":
|
||||
value = DateTimeFormatter(self.photo.date).hour
|
||||
elif field == "created.min":
|
||||
value = DateTimeFormatter(self.photo.date).min
|
||||
elif field == "created.sec":
|
||||
value = DateTimeFormatter(self.photo.date).sec
|
||||
elif field == "created.strftime":
|
||||
if default:
|
||||
try:
|
||||
return self.photo.date.strftime(default)
|
||||
value = self.photo.date.strftime(default)
|
||||
except:
|
||||
raise ValueError(f"Invalid strftime template: '{default}'")
|
||||
else:
|
||||
return None
|
||||
|
||||
if field == "modified.date":
|
||||
return (
|
||||
value = None
|
||||
elif field == "modified.date":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).date
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.year":
|
||||
return (
|
||||
elif field == "modified.year":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).year
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.yy":
|
||||
return (
|
||||
elif field == "modified.yy":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).yy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.mm":
|
||||
return (
|
||||
elif field == "modified.mm":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).mm
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.month":
|
||||
return (
|
||||
elif field == "modified.month":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).month
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.mon":
|
||||
return (
|
||||
elif field == "modified.mon":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).mon
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.dd":
|
||||
return (
|
||||
elif field == "modified.dd":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).dd
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.doy":
|
||||
return (
|
||||
elif field == "modified.dow":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).dow
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
elif field == "modified.doy":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).doy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.hour":
|
||||
return (
|
||||
elif field == "modified.hour":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).hour
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.min":
|
||||
return (
|
||||
elif field == "modified.min":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).min
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.sec":
|
||||
return (
|
||||
elif field == "modified.sec":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).sec
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
# TODO: disabling modified.strftime for now because now clean way to pass
|
||||
# a default value if modified time is None
|
||||
# if field == "modified.strftime":
|
||||
# if default and self.photo.date_modified:
|
||||
# try:
|
||||
# return self.photo.date_modified.strftime(default)
|
||||
# except:
|
||||
# raise ValueError(f"Invalid strftime template: '{default}'")
|
||||
# else:
|
||||
# return None
|
||||
|
||||
if field == "today.date":
|
||||
return DateTimeFormatter(self.today).date
|
||||
|
||||
if field == "today.year":
|
||||
return DateTimeFormatter(self.today).year
|
||||
|
||||
if field == "today.yy":
|
||||
return DateTimeFormatter(self.today).yy
|
||||
|
||||
if field == "today.mm":
|
||||
return DateTimeFormatter(self.today).mm
|
||||
|
||||
if field == "today.month":
|
||||
return DateTimeFormatter(self.today).month
|
||||
|
||||
if field == "today.mon":
|
||||
return DateTimeFormatter(self.today).mon
|
||||
|
||||
if field == "today.dd":
|
||||
return DateTimeFormatter(self.today).dd
|
||||
|
||||
if field == "today.dow":
|
||||
return DateTimeFormatter(self.today).dow
|
||||
|
||||
if field == "today.doy":
|
||||
return DateTimeFormatter(self.today).doy
|
||||
|
||||
if field == "today.hour":
|
||||
return DateTimeFormatter(self.today).hour
|
||||
|
||||
if field == "today.min":
|
||||
return DateTimeFormatter(self.today).min
|
||||
|
||||
if field == "today.sec":
|
||||
return DateTimeFormatter(self.today).sec
|
||||
|
||||
if field == "today.strftime":
|
||||
elif field == "today.date":
|
||||
value = DateTimeFormatter(self.today).date
|
||||
elif field == "today.year":
|
||||
value = DateTimeFormatter(self.today).year
|
||||
elif field == "today.yy":
|
||||
value = DateTimeFormatter(self.today).yy
|
||||
elif field == "today.mm":
|
||||
value = DateTimeFormatter(self.today).mm
|
||||
elif field == "today.month":
|
||||
value = DateTimeFormatter(self.today).month
|
||||
elif field == "today.mon":
|
||||
value = DateTimeFormatter(self.today).mon
|
||||
elif field == "today.dd":
|
||||
value = DateTimeFormatter(self.today).dd
|
||||
elif field == "today.dow":
|
||||
value = DateTimeFormatter(self.today).dow
|
||||
elif field == "today.doy":
|
||||
value = DateTimeFormatter(self.today).doy
|
||||
elif field == "today.hour":
|
||||
value = DateTimeFormatter(self.today).hour
|
||||
elif field == "today.min":
|
||||
value = DateTimeFormatter(self.today).min
|
||||
elif field == "today.sec":
|
||||
value = DateTimeFormatter(self.today).sec
|
||||
elif field == "today.strftime":
|
||||
if default:
|
||||
try:
|
||||
return self.today.strftime(default)
|
||||
value = self.today.strftime(default)
|
||||
except:
|
||||
raise ValueError(f"Invalid strftime template: '{default}'")
|
||||
else:
|
||||
return None
|
||||
|
||||
if field == "place.name":
|
||||
return self.photo.place.name if self.photo.place else None
|
||||
|
||||
if field == "place.country_code":
|
||||
return self.photo.place.country_code if self.photo.place else None
|
||||
|
||||
if field == "place.name.country":
|
||||
return (
|
||||
value = None
|
||||
elif field == "place.name":
|
||||
value = self.photo.place.name if self.photo.place else None
|
||||
elif field == "place.country_code":
|
||||
value = self.photo.place.country_code if self.photo.place else None
|
||||
elif field == "place.name.country":
|
||||
value = (
|
||||
self.photo.place.names.country[0]
|
||||
if self.photo.place and self.photo.place.names.country
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.name.state_province":
|
||||
return (
|
||||
elif field == "place.name.state_province":
|
||||
value = (
|
||||
self.photo.place.names.state_province[0]
|
||||
if self.photo.place and self.photo.place.names.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.name.city":
|
||||
return (
|
||||
elif field == "place.name.city":
|
||||
value = (
|
||||
self.photo.place.names.city[0]
|
||||
if self.photo.place and self.photo.place.names.city
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.name.area_of_interest":
|
||||
return (
|
||||
elif field == "place.name.area_of_interest":
|
||||
value = (
|
||||
self.photo.place.names.area_of_interest[0]
|
||||
if self.photo.place and self.photo.place.names.area_of_interest
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address":
|
||||
return (
|
||||
elif field == "place.address":
|
||||
value = (
|
||||
self.photo.place.address_str
|
||||
if self.photo.place and self.photo.place.address_str
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.street":
|
||||
return (
|
||||
elif field == "place.address.street":
|
||||
value = (
|
||||
self.photo.place.address.street
|
||||
if self.photo.place and self.photo.place.address.street
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.city":
|
||||
return (
|
||||
elif field == "place.address.city":
|
||||
value = (
|
||||
self.photo.place.address.city
|
||||
if self.photo.place and self.photo.place.address.city
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.state_province":
|
||||
return (
|
||||
elif field == "place.address.state_province":
|
||||
value = (
|
||||
self.photo.place.address.state_province
|
||||
if self.photo.place and self.photo.place.address.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.postal_code":
|
||||
return (
|
||||
elif field == "place.address.postal_code":
|
||||
value = (
|
||||
self.photo.place.address.postal_code
|
||||
if self.photo.place and self.photo.place.address.postal_code
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.country":
|
||||
return (
|
||||
elif field == "place.address.country":
|
||||
value = (
|
||||
self.photo.place.address.country
|
||||
if self.photo.place and self.photo.place.address.country
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.country_code":
|
||||
return (
|
||||
elif field == "place.address.country_code":
|
||||
value = (
|
||||
self.photo.place.address.iso_country_code
|
||||
if self.photo.place and self.photo.place.address.iso_country_code
|
||||
else None
|
||||
)
|
||||
else:
|
||||
# if here, didn't get a match
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
# if here, didn't get a match
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
if filename:
|
||||
value = sanitize_pathpart(value, replacement=replacement)
|
||||
elif dirname:
|
||||
value = sanitize_dirname(value, replacement=replacement)
|
||||
return value
|
||||
|
||||
def get_template_value_multi(self, field, path_sep):
|
||||
def get_template_value_multi(
|
||||
self, field, path_sep, filename=False, dirname=False, replacement=":"
|
||||
):
|
||||
"""lookup value for template field (multi-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
path_sep: path separator to use for folder_album field
|
||||
dirname: if True, values will be sanitized to be valid directory names; default = False
|
||||
|
||||
Returns:
|
||||
List of the matching template values or [None].
|
||||
@@ -621,9 +616,6 @@ class PhotoTemplate:
|
||||
""" return list of values for a multi-valued template field """
|
||||
if field == "album":
|
||||
values = self.photo.albums
|
||||
values = [
|
||||
value.replace("/", ":") for value in values
|
||||
] # TODO: temp fix for issue #213
|
||||
elif field == "keyword":
|
||||
values = self.photo.keywords
|
||||
elif field == "person":
|
||||
@@ -640,17 +632,46 @@ class PhotoTemplate:
|
||||
for album in self.photo.album_info:
|
||||
if album.folder_names:
|
||||
# album in folder
|
||||
folder = path_sep.join(album.folder_names)
|
||||
folder += path_sep + album.title.replace(
|
||||
"/", ":"
|
||||
) # TODO: temp fix for issue #213
|
||||
if dirname:
|
||||
# being used as a filepath so sanitize each part
|
||||
folder = path_sep.join(
|
||||
sanitize_dirname(f, replacement=replacement)
|
||||
for f in album.folder_names
|
||||
)
|
||||
folder += path_sep + sanitize_dirname(
|
||||
album.title, replacement=replacement
|
||||
)
|
||||
else:
|
||||
folder = path_sep.join(album.folder_names)
|
||||
folder += path_sep + album.title
|
||||
values.append(folder)
|
||||
else:
|
||||
# album not in folder
|
||||
values.append(album.title.replace("/", ":"))
|
||||
if dirname:
|
||||
values.append(
|
||||
sanitize_dirname(album.title, replacement=replacement)
|
||||
)
|
||||
else:
|
||||
values.append(album.title)
|
||||
elif field == "comment":
|
||||
values = [
|
||||
f"{comment.user}: {comment.text}" for comment in self.photo.comments
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"Unhandleded template value: {field}")
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
# sanitize directory names if needed, folder_album handled differently above
|
||||
if filename:
|
||||
values = [
|
||||
sanitize_pathpart(value, replacement=replacement) for value in values
|
||||
]
|
||||
elif dirname and field != "folder_album":
|
||||
# skip folder_album because it would have been handled above
|
||||
values = [
|
||||
sanitize_dirname(value, replacement=replacement) for value in values
|
||||
]
|
||||
|
||||
# If no values, insert None so code below will substite none_str for None
|
||||
values = values or [None]
|
||||
return values
|
||||
|
||||
|
||||
@@ -491,7 +491,7 @@ class PlaceInfo4(PlaceInfo):
|
||||
}
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
def asdict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
@@ -634,7 +634,7 @@ class PlaceInfo5(PlaceInfo):
|
||||
}
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
def asdict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
|
||||
@@ -57,6 +57,9 @@ def _debug():
|
||||
""" returns True if debugging turned on (via _set_debug), otherwise, false """
|
||||
return _DEBUG
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
""" do nothing (no operation) """
|
||||
pass
|
||||
|
||||
def _get_os_version():
|
||||
# returns tuple containing OS version
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-10-09T16:14:42Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-10-10T05:21:03Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-10-04T23:43:17Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2020-10-10T05:22:36Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>2964</integer>
|
||||
<integer>36387</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-05-30T02:16:06Z</date>
|
||||
<date>2020-10-17T23:45:33Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-05-29T04:31:37Z</date>
|
||||
<date>2020-10-17T23:45:24Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:26Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NumberOfFacesProcessedOnLastRun</key>
|
||||
<integer>7</integer>
|
||||
<integer>11</integer>
|
||||
<key>ProcessedInQuiescentState</key>
|
||||
<true/>
|
||||
<key>SuggestedMeIdentifier</key>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FaceIDModelLastGenerationKey</key>
|
||||
<date>2020-05-29T03:44:04Z</date>
|
||||
<date>2020-10-17T23:45:32Z</date>
|
||||
<key>LastContactClassificationKey</key>
|
||||
<date>2020-05-29T04:31:40Z</date>
|
||||
<date>2020-10-17T23:45:54Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>5001</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal
Normal file
16
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>hostname</key>
|
||||
<string>Rhets-MacBook-Pro.local</string>
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>1797</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
<integer>501</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.7.photoslibrary/database/metaSchema.db
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/photos.db
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite-shm
Normal file
@@ -0,0 +1,188 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BlacklistedMeaningsByMeaning</key>
|
||||
<dict/>
|
||||
<key>MePersonUUID</key>
|
||||
<string>39488755-78C0-40B2-B378-EDA280E1823C</string>
|
||||
<key>SceneWhitelist</key>
|
||||
<array>
|
||||
<string>Graduation</string>
|
||||
<string>Aquarium</string>
|
||||
<string>Food</string>
|
||||
<string>Ice Skating</string>
|
||||
<string>Mountain</string>
|
||||
<string>Cliff</string>
|
||||
<string>Basketball</string>
|
||||
<string>Tennis</string>
|
||||
<string>Jewelry</string>
|
||||
<string>Cheese</string>
|
||||
<string>Softball</string>
|
||||
<string>Football</string>
|
||||
<string>Circus</string>
|
||||
<string>Jet Ski</string>
|
||||
<string>Playground</string>
|
||||
<string>Carousel</string>
|
||||
<string>Paint Ball</string>
|
||||
<string>Windsurfing</string>
|
||||
<string>Sailboat</string>
|
||||
<string>Sunbathing</string>
|
||||
<string>Dam</string>
|
||||
<string>Fireplace</string>
|
||||
<string>Flower</string>
|
||||
<string>Scuba</string>
|
||||
<string>Hiking</string>
|
||||
<string>Cetacean</string>
|
||||
<string>Pier</string>
|
||||
<string>Bowling</string>
|
||||
<string>Snowboarding</string>
|
||||
<string>Zoo</string>
|
||||
<string>Snowmobile</string>
|
||||
<string>Theater</string>
|
||||
<string>Boat</string>
|
||||
<string>Casino</string>
|
||||
<string>Car</string>
|
||||
<string>Diving</string>
|
||||
<string>Cycling</string>
|
||||
<string>Musical Instrument</string>
|
||||
<string>Board Game</string>
|
||||
<string>Castle</string>
|
||||
<string>Sunset Sunrise</string>
|
||||
<string>Martial Arts</string>
|
||||
<string>Motocross</string>
|
||||
<string>Submarine</string>
|
||||
<string>Cat</string>
|
||||
<string>Snow</string>
|
||||
<string>Kiteboarding</string>
|
||||
<string>Squash</string>
|
||||
<string>Geyser</string>
|
||||
<string>Music</string>
|
||||
<string>Archery</string>
|
||||
<string>Desert</string>
|
||||
<string>Blackjack</string>
|
||||
<string>Fireworks</string>
|
||||
<string>Sportscar</string>
|
||||
<string>Feline</string>
|
||||
<string>Soccer</string>
|
||||
<string>Museum</string>
|
||||
<string>Baby</string>
|
||||
<string>Fencing</string>
|
||||
<string>Railroad</string>
|
||||
<string>Nascar</string>
|
||||
<string>Sky Surfing</string>
|
||||
<string>Bird</string>
|
||||
<string>Games</string>
|
||||
<string>Baseball</string>
|
||||
<string>Dressage</string>
|
||||
<string>Snorkeling</string>
|
||||
<string>Pyramid</string>
|
||||
<string>Kite</string>
|
||||
<string>Rowboat</string>
|
||||
<string>Golf</string>
|
||||
<string>Watersports</string>
|
||||
<string>Lightning</string>
|
||||
<string>Canyon</string>
|
||||
<string>Auditorium</string>
|
||||
<string>Night Sky</string>
|
||||
<string>Karaoke</string>
|
||||
<string>Skiing</string>
|
||||
<string>Parade</string>
|
||||
<string>Forest</string>
|
||||
<string>Hot Air Balloon</string>
|
||||
<string>Dragon Parade</string>
|
||||
<string>Easter Egg</string>
|
||||
<string>Monument</string>
|
||||
<string>Jungle</string>
|
||||
<string>Thanksgiving</string>
|
||||
<string>Jockey Horse</string>
|
||||
<string>Stadium</string>
|
||||
<string>Airplane</string>
|
||||
<string>Ballet</string>
|
||||
<string>Yoga</string>
|
||||
<string>Coral Reef</string>
|
||||
<string>Skating</string>
|
||||
<string>Wrestling</string>
|
||||
<string>Bicycle</string>
|
||||
<string>Tattoo</string>
|
||||
<string>Amusement Park</string>
|
||||
<string>Canoe</string>
|
||||
<string>Cheerleading</string>
|
||||
<string>Ping Pong</string>
|
||||
<string>Fishing</string>
|
||||
<string>Magic</string>
|
||||
<string>Reptile</string>
|
||||
<string>Winter Sport</string>
|
||||
<string>Waterfall</string>
|
||||
<string>Train</string>
|
||||
<string>Bonsai</string>
|
||||
<string>Surfing</string>
|
||||
<string>Dog</string>
|
||||
<string>Cake</string>
|
||||
<string>Sledding</string>
|
||||
<string>Sandcastle</string>
|
||||
<string>Glacier</string>
|
||||
<string>Lighthouse</string>
|
||||
<string>Equestrian</string>
|
||||
<string>Rafting</string>
|
||||
<string>Shore</string>
|
||||
<string>Hockey</string>
|
||||
<string>Santa Claus</string>
|
||||
<string>Formula One Car</string>
|
||||
<string>Sport</string>
|
||||
<string>Vehicle</string>
|
||||
<string>Boxing</string>
|
||||
<string>Rollerskating</string>
|
||||
<string>Underwater</string>
|
||||
<string>Orchestra</string>
|
||||
<string>Carnival</string>
|
||||
<string>Rocket</string>
|
||||
<string>Skateboarding</string>
|
||||
<string>Helicopter</string>
|
||||
<string>Performance</string>
|
||||
<string>Oktoberfest</string>
|
||||
<string>Water Polo</string>
|
||||
<string>Skate Park</string>
|
||||
<string>Animal</string>
|
||||
<string>Nightclub</string>
|
||||
<string>String Instrument</string>
|
||||
<string>Dinosaur</string>
|
||||
<string>Gymnastics</string>
|
||||
<string>Cricket</string>
|
||||
<string>Volcano</string>
|
||||
<string>Lake</string>
|
||||
<string>Aurora</string>
|
||||
<string>Dancing</string>
|
||||
<string>Concert</string>
|
||||
<string>Rock Climbing</string>
|
||||
<string>Hang Glider</string>
|
||||
<string>Rodeo</string>
|
||||
<string>Fish</string>
|
||||
<string>Art</string>
|
||||
<string>Motorcycle</string>
|
||||
<string>Volleyball</string>
|
||||
<string>Wake Boarding</string>
|
||||
<string>Badminton</string>
|
||||
<string>Motor Sport</string>
|
||||
<string>Sumo</string>
|
||||
<string>Parasailing</string>
|
||||
<string>Skydiving</string>
|
||||
<string>Kickboxing</string>
|
||||
<string>Pinata</string>
|
||||
<string>Foosball</string>
|
||||
<string>Go Kart</string>
|
||||
<string>Poker</string>
|
||||
<string>Kayak</string>
|
||||
<string>Swimming</string>
|
||||
<string>Atv</string>
|
||||
<string>Beach</string>
|
||||
<string>Dartboard</string>
|
||||
<string>Athletics</string>
|
||||
<string>Camping</string>
|
||||
<string>Tornado</string>
|
||||
<string>Billiards</string>
|
||||
<string>Rugby</string>
|
||||
<string>Airshow</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>insertAlbum</key>
|
||||
<array/>
|
||||
<key>insertAsset</key>
|
||||
<array/>
|
||||
<key>insertHighlight</key>
|
||||
<array/>
|
||||
<key>insertMemory</key>
|
||||
<array/>
|
||||
<key>insertMoment</key>
|
||||
<array/>
|
||||
<key>removeAlbum</key>
|
||||
<array/>
|
||||
<key>removeAsset</key>
|
||||
<array/>
|
||||
<key>removeHighlight</key>
|
||||
<array/>
|
||||
<key>removeMemory</key>
|
||||
<array/>
|
||||
<key>removeMoment</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>embeddingVersion</key>
|
||||
<string>1</string>
|
||||
<key>localeIdentifier</key>
|
||||
<string>en_US</string>
|
||||
<key>sceneTaxonomySHA</key>
|
||||
<string>87914a047c69fbe8013fad2c70fa70c6c03b08b56190fe4054c880e6b9f57cc3</string>
|
||||
<key>searchIndexVersion</key>
|
||||
<string>10</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 541 KiB |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>MigrationService</key>
|
||||
<dict>
|
||||
<key>State</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>MigrationService.LastCompletedTask</key>
|
||||
<integer>12</integer>
|
||||
<key>MigrationService.ValidationCounts</key>
|
||||
<dict>
|
||||
<key>MigrationDetectedFaceprint</key>
|
||||
<integer>6</integer>
|
||||
<key>MigrationManagedAsset</key>
|
||||
<integer>0</integer>
|
||||
<key>MigrationSceneClassification</key>
|
||||
<integer>44</integer>
|
||||
<key>MigrationUnmanagedAdjustment</key>
|
||||
<integer>0</integer>
|
||||
<key>RDVersion.cloudLocalState.CPLIsNotPushed</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>92D68107-B6C7-453B-96D2-97B0F26D5B8B/L0/020</string>
|
||||
<string>88A5F8B8-5B9A-43C7-BB85-3952B81580EB/L0/020</string>
|
||||
<string>29EF7A97-7E76-4D5F-A5E0-CC0A93E8524C/L0/020</string>
|
||||
<string>2C2AF115-BD1D-4434-A747-D1C8BD8E2045/L0/020</string>
|
||||
<string>CB051A4C-2CB7-4B90-B59B-08CC4D0C2823/L0/020</string>
|
||||
</array>
|
||||
<key>Photos</key>
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
</array>
|
||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||
<dict>
|
||||
<key>kZoomLevelIdentifierAlbums</key>
|
||||
<integer>7</integer>
|
||||
<key>kZoomLevelIdentifierVersions</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
<key>lastAddToDestination</key>
|
||||
<dict>
|
||||
<key>key</key>
|
||||
<integer>1</integer>
|
||||
<key>lastKnownDisplayName</key>
|
||||
<string>September 28, 2018</string>
|
||||
<key>type</key>
|
||||
<string>album</string>
|
||||
<key>uuid</key>
|
||||
<string>DFFKmHt3Tk+AGzZLe2Xq+g</string>
|
||||
</dict>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
<key>other</key>
|
||||
<integer>0</integer>
|
||||
<key>photos</key>
|
||||
<integer>7</integer>
|
||||
<key>videos</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||