Compare commits

...

25 Commits

Author SHA1 Message Date
Rhet Turnbull
c5dba8c89b Added --has-comment/--has-likes to CLI, issue #240 2020-10-29 21:34:15 -07:00
Rhet Turnbull
603dabb8f4 Cleaned up as_dict/asdict, issue #144, #188 2020-10-27 06:54:42 -07:00
Rhet Turnbull
091f1d9bb4 Updated CHANGELOG.md 2020-10-25 22:25:18 -07:00
Rhet Turnbull
d16932d0fd Updated README.md 2020-10-25 22:24:47 -07:00
Rhet Turnbull
23de6b5890 Added comments/likes, implements #214 2020-10-25 22:12:02 -07:00
Rhet Turnbull
4fe58bf2af fixed test 2020-10-25 09:18:20 -07:00
Rhet Turnbull
d87b8f30a4 Added verbose to PhotosDB(), partial fix for #110 2020-10-25 08:16:54 -07:00
Rhet Turnbull
667c89e32c Cleaned up constructor for PhotosDB 2020-10-24 17:20:46 -07:00
Rhet Turnbull
f9cac05f0d Updated CHANGELOG.md 2020-10-24 13:52:27 -07:00
Rhet Turnbull
48f29e138e Fix for issue #238 2020-10-24 13:45:10 -07:00
Rhet Turnbull
7f2701f6ee Updated CHANGELOG.md 2020-10-24 09:17:16 -07:00
Rhet Turnbull
8551981f68 Fixed shared, not_shared in cli 2020-10-24 09:03:34 -07:00
Rhet Turnbull
a416de29e4 Fix for issue #237 2020-10-21 22:29:16 -07:00
Rhet Turnbull
a960468887 Updated related projects 2020-10-20 22:13:10 -07:00
Rhet Turnbull
ea68229dda Added test for issue #235 2020-10-18 21:34:50 -07:00
Rhet Turnbull
a95193aaa4 Updated README.md with better install instructions 2020-10-18 20:35:40 -07:00
Rhet Turnbull
71ef5e5195 Updated get_shared_photo_comments.py 2020-10-18 16:13:43 -07:00
Rhet Turnbull
53b2498e59 Updated get_shared_photo_comments.py 2020-10-18 16:11:45 -07:00
Rhet Turnbull
15e0914af6 Added get_shared_photo_comments.py to examples 2020-10-18 15:52:18 -07:00
Rhet Turnbull
3b3eb1625e Updated README.md 2020-10-18 14:09:40 -07:00
Rhet Turnbull
338b1501d0 Updated CHANGELOG.md 2020-10-17 23:31:47 -07:00
Rhet Turnbull
bda3a029de Updated README.md 2020-10-17 23:31:09 -07:00
Rhet Turnbull
ff0fdffa9b refactored template code to fix #213 2020-10-17 23:21:08 -07:00
Rhet Turnbull
1332e7b45a Updated CHANGELOG.md 2020-10-15 06:44:03 -07:00
Rhet Turnbull
41b23991df Fix for issue #235, #236 2020-10-15 06:31:13 -07:00
68 changed files with 1538 additions and 704 deletions

View File

@@ -4,6 +4,52 @@ 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.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

View File

@@ -21,6 +21,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 +59,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 +221,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
@@ -534,6 +545,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 +705,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 +1165,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
@@ -1276,7 +1299,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 +1313,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 +1324,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 +1332,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 +1379,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 +1517,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 +1683,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 +1771,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.
@@ -1830,6 +1883,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 +1966,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.

View 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()

View File

@@ -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

View File

@@ -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

View File

@@ -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
@@ -496,6 +489,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 +525,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 +543,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 +986,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 +1033,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 +1122,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 +1253,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",
@@ -1396,6 +1409,10 @@ def export(
edited_suffix,
place,
no_place,
has_comment,
no_comment,
has_likes,
no_likes,
no_extended_attributes,
label,
deleted,
@@ -1442,6 +1459,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 +1600,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:
@@ -1899,13 +1923,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 +2146,17 @@ 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
@@ -2235,7 +2274,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"
)
@@ -2409,7 +2448,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 +2459,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 +2491,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 +2530,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:

View File

@@ -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

View File

@@ -1,4 +1,4 @@
""" version info """
__version__ = "0.35.2"
__version__ = "0.36.1"

View File

@@ -228,7 +228,7 @@ class ExifTool:
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
"""
@@ -245,7 +245,7 @@ class ExifTool:
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):

78
osxphotos/path_utils.py Normal file
View 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

View File

@@ -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})"

View 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 []

View File

@@ -592,9 +592,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 +636,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 +674,6 @@ def export2(
burst=self.burst,
dry_run=dry_run,
)
if exported:
if touch_file:
for exported_file in exported:
@@ -1128,11 +1129,10 @@ def _exiftool_json_sidecar(
(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

View File

@@ -5,6 +5,7 @@ PhotosDB.photos() returns a list of PhotoInfo objects
"""
import dataclasses
import datetime
import json
import logging
import os
@@ -59,6 +60,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
@@ -545,6 +547,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 +810,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 +825,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 +839,9 @@ class PhotoInfo:
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
replacement=replacement
)
@property
@@ -937,22 +951,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 +976,7 @@ class PhotoInfo:
"albums": self.albums,
"folders": folders,
"persons": self.persons,
"faces": faces,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
@@ -974,12 +990,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 +1005,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 +1020,17 @@ 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 """

View 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()

View File

@@ -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,
@@ -1908,6 +1929,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 +2061,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 +2084,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 +2264,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))

View File

@@ -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, "")
@@ -100,6 +102,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 +134,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 +148,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
@@ -170,13 +179,21 @@ class PhotoTemplate:
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:
@@ -186,13 +203,13 @@ class PhotoTemplate:
return matchobj.group(0)
if val is None:
return (
val = (
matchobj.group(3)
if matchobj.group(3) is not None
else none_str
)
else:
return val
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
@@ -228,18 +245,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,})))?\}"
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 +271,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 +292,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,10 +308,10 @@ 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 = []
@@ -307,14 +330,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 +360,236 @@ 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.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 +601,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 +617,44 @@ 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

View File

@@ -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(),

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,13 @@ import os
import pytest
from click.testing import CliRunner
import osxphotos
from osxphotos.exiftool import get_exiftool_path
CLI_PHOTOS_DB = "tests/Test-10.15.1.photoslibrary"
LIVE_PHOTOS_DB = "tests/Test-Cloud-10.15.1.photoslibrary"
RAW_PHOTOS_DB = "tests/Test-RAW-10.15.1.photoslibrary"
COMMENTS_PHOTOS_DB = "tests/Test-Cloud-10.15.6.photoslibrary"
PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-10_15_1.photoslibrary"
PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary"
PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary"
@@ -229,8 +231,23 @@ CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_PATHSEP = [
"2019-10:11 Paris Clermont/IMG_4547.jpg",
]
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP = [
"foo:bar/foo:bar_IMG_3092.heic"
]
CLI_EXPORTED_FILENAME_TEMPLATE_LONG_DESCRIPTION = [
"Lorem ipsum dolor sit amet, consectetuer adipiscing elit. "
"Aenean commodo ligula eget dolor. Aenean massa. "
"Cum sociis natoque penatibus et magnis dis parturient montes, "
"nascetur ridiculus mus. Donec quam felis, ultricies nec, "
"pellentesque eu, pretium q.tif"
]
CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B"
CLI_EXPORT_UUID_STATUE = "3DD2C897-F19E-4CA6-8C22-B027D5A71907"
CLI_EXPORT_UUID_KEYWORD_PATHSEP = "7783E8E6-9CAC-40F3-BE22-81FB7051C266"
CLI_EXPORT_UUID_LONG_DESCRIPTION = "8846E3E6-8AC8-4857-8448-E3D025784410"
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
@@ -300,6 +317,10 @@ CLI_EXIFTOOL = {
"XMP:Description": "Girl holding pumpkin",
"XMP:PersonInImage": "Katie",
"XMP:Subject": ["Kids", "Katie"],
"EXIF:GPSLatitudeRef": "N",
"EXIF:GPSLongitudeRef": "W",
"EXIF:GPSLatitude": 41.256566,
"EXIF:GPSLongitude": 95.940257,
}
}
@@ -399,6 +420,29 @@ CLI_EXPORT_UUID_FROM_FILE_FILENAMES = [
"wedding_edited.jpeg",
]
UUID_HAS_COMMENTS = [
"4E4944A0-3E5C-4028-9600-A8709F2FA1DB",
"4AD7C8EF-2991-4519-9D3A-7F44A6F031BE",
"7572C53E-1D6A-410C-A2B1-18CCA3B5AD9F",
]
UUID_NO_COMMENTS = ["4F835581-5AB9-4DEC-9971-3E64A0894B04"]
UUID_HAS_LIKES = [
"C008048F-8767-4992-85B8-13E798F6DC3C",
"65BADBD7-A50C-4956-96BA-1BB61155DA17",
"4AD7C8EF-2991-4519-9D3A-7F44A6F031BE",
]
UUID_NO_LIKES = [
"45099D34-A414-464F-94A2-60D6823679C8",
"1C1C8F1F-826B-4A24-B1CB-56628946A834",
]
@pytest.fixture(autouse=True)
def reset_globals():
""" reset globals in __main__ that tests may have changed """
yield
osxphotos.__main__.VERBOSE = False
# determine if exiftool installed so exiftool tests can be skipped
try:
@@ -568,46 +612,96 @@ def test_query_uuid_from_file_1():
# build list of uuids we got from the output JSON
json_got = json.loads(result.output)
uuid_got = []
for photo in json_got:
uuid_got.append(photo["uuid"])
uuid_got = [photo["uuid"] for photo in json_got]
assert sorted(UUID_EXPECTED_FROM_FILE) == sorted(uuid_got)
def test_query_uuid_from_file_2():
""" Test query with --uuid-from-file and --uuid """
def test_query_has_comment():
""" Test query with --has-comment """
import json
import os
import os.path
import osxphotos
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--uuid-from-file",
UUID_FILE,
"--uuid",
UUID_NOT_FROM_FILE,
],
["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--has-comment"],
)
assert result.exit_code == 0
# build list of uuids we got from the output JSON
json_got = json.loads(result.output)
uuid_got = []
for photo in json_got:
uuid_got.append(photo["uuid"])
uuid_got = [photo["uuid"] for photo in json_got]
assert sorted(uuid_got) == sorted(UUID_HAS_COMMENTS)
uuid_expected = UUID_EXPECTED_FROM_FILE.copy()
uuid_expected.append(UUID_NOT_FROM_FILE)
assert sorted(uuid_expected) == sorted(uuid_got)
def test_query_no_comment():
""" Test query with --no-comment """
import json
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query, ["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--no-comment"]
)
assert result.exit_code == 0
# build list of uuids we got from the output JSON
json_got = json.loads(result.output)
uuid_got = [photo["uuid"] for photo in json_got]
for uuid in UUID_NO_COMMENTS:
assert uuid in uuid_got
for uuid in uuid_got:
assert uuid not in UUID_HAS_COMMENTS
def test_query_has_likes():
""" Test query with --has-likes"""
import json
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--has-likes"],
)
assert result.exit_code == 0
# build list of uuids we got from the output JSON
json_got = json.loads(result.output)
uuid_got = [photo["uuid"] for photo in json_got]
assert sorted(uuid_got) == sorted(UUID_HAS_LIKES)
def test_query_no_likes():
""" Test query with --no-likes"""
import json
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query, ["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--no-likes"]
)
assert result.exit_code == 0
# build list of uuids we got from the output JSON
json_got = json.loads(result.output)
uuid_got = [photo["uuid"] for photo in json_got]
for uuid in UUID_NO_LIKES:
assert uuid in uuid_got
for uuid in uuid_got:
assert uuid not in UUID_HAS_LIKES
def test_export():
@@ -810,7 +904,6 @@ def test_export_exiftool():
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
@@ -822,7 +915,7 @@ def test_export_exiftool():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_4),
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"-V",
"--exiftool",
@@ -834,7 +927,7 @@ def test_export_exiftool():
files = glob.glob("*")
assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]])
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).as_dict()
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL[uuid]:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@@ -1965,13 +2058,12 @@ def test_export_filename_template_2():
assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2)
def test_export_filename_template_pathsep_in_name():
def test_export_filename_template_pathsep_in_name_1():
""" export photos using filename template with folder_album and "/" in album name """
import locale
import os
import os.path
import pathlib
import osxphotos
from osxphotos.__main__ import export
locale.setlocale(locale.LC_ALL, "en_US")
@@ -1998,6 +2090,71 @@ def test_export_filename_template_pathsep_in_name():
assert pathlib.Path(fname).is_file()
def test_export_filename_template_pathsep_in_name_2():
""" export photos using filename template with keyword and "/" in keyword """
import locale
import os
import os.path
import pathlib
from osxphotos.__main__ import export
locale.setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"-V",
"--directory",
"{keyword}",
"--filename",
"{keyword}_{original_name}",
"--uuid",
CLI_EXPORT_UUID_KEYWORD_PATHSEP,
],
)
assert result.exit_code == 0
for fname in CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP:
assert pathlib.Path(fname).is_file()
def test_export_filename_template_long_description():
""" export photos using filename template with description that exceeds max length """
import locale
import os
import os.path
import pathlib
import osxphotos
from osxphotos.__main__ import export
locale.setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"-V",
"--filename",
"{descr}",
"--uuid",
CLI_EXPORT_UUID_LONG_DESCRIPTION,
],
)
assert result.exit_code == 0
for fname in CLI_EXPORTED_FILENAME_TEMPLATE_LONG_DESCRIPTION:
assert pathlib.Path(fname).is_file()
def test_export_filename_template_3():
""" test --filename with invalid template """
import glob
@@ -2489,9 +2646,8 @@ def test_export_sidecar_keyword_template():
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
)[0]
json_file = open("Pumkins2.jpg.json", "r")
json_got = json.load(json_file)[0]
json_file.close()
with open("Pumkins2.jpg.json", "r") as json_file:
json_got = json.load(json_file)[0]
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():

100
tests/test_comments.py Normal file
View File

@@ -0,0 +1,100 @@
""" Test comments and likes """
import datetime
import pytest
import osxphotos
from osxphotos import CommentInfo, LikeInfo
PHOTOS_DB = "tests/Test-Cloud-10.15.6.photoslibrary"
COMMENT_UUID_DICT = {
"4AD7C8EF-2991-4519-9D3A-7F44A6F031BE": [
CommentInfo(
datetime=datetime.datetime(2020, 9, 18, 10, 28, 41, 552000),
user=None,
ismine=False,
text="Nice photo!",
),
CommentInfo(
datetime=datetime.datetime(2020, 9, 19, 22, 52, 20, 12014),
user=None,
ismine=True,
text="Wish I was back here!",
),
],
"CCBE0EB9-AE9F-4479-BFFD-107042C75227": [],
"4E4944A0-3E5C-4028-9600-A8709F2FA1DB": [
CommentInfo(
datetime=datetime.datetime(2020, 9, 19, 22, 54, 12, 947978),
user=None,
ismine=True,
text="Nice trophy",
)
],
}
LIKE_UUID_DICT = {
"4AD7C8EF-2991-4519-9D3A-7F44A6F031BE": [
LikeInfo(
datetime=datetime.datetime(2020, 9, 18, 10, 28, 43, 335000),
user=None,
ismine=False,
)
],
"CCBE0EB9-AE9F-4479-BFFD-107042C75227": [],
"65BADBD7-A50C-4956-96BA-1BB61155DA17": [
LikeInfo(
datetime=datetime.datetime(2020, 9, 18, 10, 28, 52, 570000),
user=None,
ismine=False,
)
],
}
COMMENT_UUID_ASDICT = {
"4E4944A0-3E5C-4028-9600-A8709F2FA1DB": {
"datetime": datetime.datetime(2020, 9, 19, 22, 54, 12, 947978),
"user": None,
"ismine": True,
"text": "Nice trophy",
}
}
LIKE_UUID_ASDICT = {
"65BADBD7-A50C-4956-96BA-1BB61155DA17": {
"datetime": datetime.datetime(2020, 9, 18, 10, 28, 52, 570000),
"user": None,
"ismine": False,
}
}
@pytest.fixture(scope="module")
def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
def test_comments(photosdb):
for uuid in COMMENT_UUID_DICT:
photo = photosdb.get_photo(uuid)
assert photo.comments == COMMENT_UUID_DICT[uuid]
def test_likes(photosdb):
for uuid in LIKE_UUID_DICT:
photo = photosdb.get_photo(uuid)
assert photo.likes == LIKE_UUID_DICT[uuid]
def test_comments_as_dict(photosdb):
for uuid in COMMENT_UUID_ASDICT:
photo = photosdb.get_photo(uuid)
assert photo.comments[0].asdict() == COMMENT_UUID_ASDICT[uuid]
def test_likes_as_dict(photosdb):
for uuid in LIKE_UUID_ASDICT:
photo = photosdb.get_photo(uuid)
assert photo.likes[0].asdict() == LIKE_UUID_ASDICT[uuid]

View File

@@ -198,7 +198,7 @@ def test_as_dict():
import osxphotos.exiftool
exif1 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
exifdata = exif1.as_dict()
exifdata = exif1.asdict()
assert exifdata["XMP:TagsList"] == "wedding"
@@ -227,7 +227,7 @@ def test_photoinfo_exiftool():
for uuid in EXIF_UUID:
photo = photosdb.photos(uuid=[uuid])[0]
exiftool = photo.exiftool
exif_dict = exiftool.as_dict()
exif_dict = exiftool.asdict()
for key, val in EXIF_UUID[uuid].items():
assert exif_dict[key] == val

View File

@@ -66,6 +66,18 @@ UUID_DICT = {
XMP_FILENAME = "Pumkins1.jpg.xmp"
XMP_JPG_FILENAME = "Pumkins1.jpg"
EXIF_JSON_EXPECTED = (
'[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", '
'"XMP:Title": "St. James\'s Park", "XMP:TagsList": ["UK", "England", '
'"London", "United Kingdom", "London 2018", "St. James\'s Park"], '
'"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", '
'"St. James\'s Park"], "XMP:Subject": ["UK", "England", "London", "United Kingdom", '
'"London 2018", "St. James\'s Park"], "EXIF:GPSLatitude": 51.50357167, '
'"EXIF:GPSLongitude": -0.1318055, "EXIF:GPSLatitudeRef": "N", '
'"EXIF:GPSLongitudeRef": "W", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", '
'"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:08 14:06:44"}]'
)
def test_export_1():
# test basic export
@@ -456,21 +468,7 @@ def test_exiftool_json_sidecar():
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
json_expected = json.loads(
"""
[{"XMP:Title": "St. James\'s Park",
"XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"EXIF:OffsetTimeOriginal": "-04:00",
"EXIF:ModifyDate": "2019:12:08 14:06:44",
"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos"
}] """
)[0]
json_expected = json.loads(EXIF_JSON_EXPECTED)[0]
json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got)[0]

View File

@@ -20,6 +20,12 @@ NAMES_DICT = {
"heic": "7783E8E6-9CAC-40F3-BE22-81FB7051C266.jpeg",
}
UUID_LIVE_HEIC = "1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72"
NAMES_LIVE_HEIC = [
"1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72.jpeg",
"1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72.mov",
]
@pytest.fixture(scope="module")
def photosdb():
@@ -60,3 +66,33 @@ def test_export_convert_heic_to_jpeg(photosdb):
assert got_dest.is_file()
assert got_dest.suffix == ".jpeg"
assert got_dest.name == NAMES_DICT["heic"]
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running against author's personal library",
)
def test_export_convert_live_heic_to_jpeg():
# test export with convert_to_jpeg with live heic (issue #235)
# don't have a live HEIC in one of the test libraries so use one from
# my personal library
import os
import pathlib
import tempfile
import osxphotos
photosdb = osxphotos.PhotosDB()
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photo = photosdb.get_photo(UUID_LIVE_HEIC)
results = photo.export2(dest, convert_to_jpeg=True, live_photo=True)
for name in NAMES_LIVE_HEIC:
assert f"{tempdir.name}/{name}" in results.exported
for file_ in results.exported:
dest = pathlib.Path(file_)
assert dest.is_file()

View File

@@ -45,6 +45,18 @@ UUID_DICT = {
"xmp": "8SOE9s0XQVGsuq4ONohTng",
}
EXIF_JSON_EXPECTED = (
'[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", '
'"XMP:Title": "St. James\'s Park", "XMP:TagsList": ["UK", "England", '
'"London", "United Kingdom", "London 2018", "St. James\'s Park"], '
'"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", '
'"St. James\'s Park"], "XMP:Subject": ["UK", "England", "London", "United Kingdom", '
'"London 2018", "St. James\'s Park"], "EXIF:GPSLatitude": 51.50357167, '
'"EXIF:GPSLongitude": -0.1318055, "EXIF:GPSLatitudeRef": "N", '
'"EXIF:GPSLongitudeRef": "W", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", '
'"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:01 11:43:45"}]'
)
def test_export_1():
# test basic export
@@ -372,21 +384,7 @@ def test_exiftool_json_sidecar():
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
json_expected = json.loads(
"""
[{"XMP:Title": "St. James\'s Park",
"XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"EXIF:OffsetTimeOriginal": "-04:00",
"EXIF:ModifyDate": "2019:12:01 11:43:45",
"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos"
}] """
)[0]
json_expected = json.loads(EXIF_JSON_EXPECTED)[0]
json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got)[0]

117
tests/test_path_utils.py Normal file
View File

@@ -0,0 +1,117 @@
""" Test path_utils.py """
def test_sanitize_filename():
from osxphotos.path_utils import sanitize_filename
from osxphotos._constants import MAX_FILENAME_LEN
# basic sanitize
filenames = {
"Foobar.txt": "Foobar.txt",
"Foo:bar.txt": "Foo:bar.txt",
"Foo/bar.txt": "Foo:bar.txt",
"Foo//.txt": "Foo::.txt",
}
for filename, sanitized in filenames.items():
filename = sanitize_filename(filename)
assert filename == sanitized
# sanitize with replacement
filenames = {
"Foobar.txt": "Foobar.txt",
"Foo:bar.txt": "Foo:bar.txt",
"Foo/bar.txt": "Foo_bar.txt",
"Foo//.txt": "Foo__.txt",
}
for filename, sanitized in filenames.items():
filename = sanitize_filename(filename, replacement="_")
assert filename == sanitized
# filename too long
filename = "foo" + "x" * 512
new_filename = sanitize_filename(filename)
assert len(new_filename) == MAX_FILENAME_LEN
assert new_filename == "foo" + "x" * 252
# filename too long with extension
filename = "x" * 512 + ".jpeg"
new_filename = sanitize_filename(filename)
assert len(new_filename) == MAX_FILENAME_LEN
assert new_filename == "x" * 250 + ".jpeg"
# more than one extension
filename = "foo.bar" + "x" * 255 + ".foo.bar.jpeg"
new_filename = sanitize_filename(filename)
assert len(new_filename) == MAX_FILENAME_LEN
assert new_filename == "foo.bar" + "x" * 243 + ".jpeg"
# shorter than drop count
filename = "foo." + "x" * 256
new_filename = sanitize_filename(filename)
assert len(new_filename) == MAX_FILENAME_LEN
assert new_filename == "foo." + "x" * 251
def test_sanitize_dirname():
from osxphotos.path_utils import sanitize_dirname
from osxphotos._constants import MAX_DIRNAME_LEN
# basic sanitize
dirnames = {
"Foobar": "Foobar",
"Foo:bar": "Foo:bar",
"Foo/bar": "Foo:bar",
"Foo//": "Foo::",
}
for dirname, sanitized in dirnames.items():
dirname = sanitize_dirname(dirname)
assert dirname == sanitized
# sanitize with replacement
dirnames = {
"Foobar": "Foobar",
"Foo:bar": "Foo:bar",
"Foo/bar": "Foo_bar",
"Foo//": "Foo__",
}
for dirname, sanitized in dirnames.items():
dirname = sanitize_dirname(dirname, replacement="_")
assert dirname == sanitized
# dirname too long
dirname = "foo" + "x" * 512 + "bar"
new_dirname = sanitize_dirname(dirname)
assert len(new_dirname) == MAX_DIRNAME_LEN
assert new_dirname == "foo" + "x" * 252
def test_sanitize_pathpart():
from osxphotos.path_utils import sanitize_pathpart
from osxphotos._constants import MAX_DIRNAME_LEN
# basic sanitize
dirnames = {
"Foobar": "Foobar",
"Foo:bar": "Foo:bar",
"Foo/bar": "Foo:bar",
"Foo//": "Foo::",
}
for dirname, sanitized in dirnames.items():
dirname = sanitize_pathpart(dirname)
assert dirname == sanitized
# sanitize with replacement
dirnames = {
"Foobar": "Foobar",
"Foo:bar": "Foo:bar",
"Foo/bar": "Foo_bar",
"Foo//": "Foo__",
}
for dirname, sanitized in dirnames.items():
dirname = sanitize_pathpart(dirname, replacement="_")
assert dirname == sanitized
# dirname too long
dirname = "foo" + "x" * 512 + "bar"
new_dirname = sanitize_pathpart(dirname)
assert len(new_dirname) == MAX_DIRNAME_LEN
assert new_dirname == "foo" + "x" * 252

View File

@@ -130,15 +130,15 @@ def test_place_no_place_info():
assert photo.place is None
def test_place_place_info_as_dict():
# test PlaceInfo.as_dict()
def test_place_place_info_asdict():
# test PlaceInfo.asdict()
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photo = photosdb.photos(uuid=[UUID_DICT["place_maui"]])[0]
assert isinstance(photo.place, osxphotos.placeinfo.PlaceInfo)
assert photo.place.as_dict() == MAUI_DICT
assert photo.place.asdict() == MAUI_DICT
# def test_place_str():

View File

@@ -89,11 +89,11 @@ def test_place_str():
def test_place_as_dict():
# test PlaceInfo.as_dict()
# test PlaceInfo.asdict()
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photo = photosdb.photos(uuid=[UUID_DICT["place_uk"]])[0]
assert photo.place is not None
assert isinstance(photo.place, osxphotos.placeinfo.PlaceInfo)
assert photo.place.as_dict() == UK_DICT
assert photo.place.asdict() == UK_DICT

View File

@@ -8,6 +8,8 @@ PHOTOS_DB_15_1 = "./tests/Test-10.15.1.photoslibrary/database/photos.db"
PHOTOS_DB_15_4 = "./tests/Test-10.15.4.photoslibrary/database/photos.db"
PHOTOS_DB_14_6 = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_DB_COMMENTS = "tests/Test-Cloud-10.15.6.photoslibrary"
UUID_DICT = {
"place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
"1_1_2": "1EB2B765-0765-43BA-A90C-0D0580E6172C",
@@ -99,6 +101,15 @@ TEMPLATE_VALUES_DEU = {
"{place.address.country_code}": "US",
}
COMMENT_UUID_DICT = {
"4AD7C8EF-2991-4519-9D3A-7F44A6F031BE": [
"None: Nice photo!",
"None: Wish I was back here!",
],
"CCBE0EB9-AE9F-4479-BFFD-107042C75227": ["_"],
"4E4944A0-3E5C-4028-9600-A8709F2FA1DB": ["None: Nice trophy"],
}
def test_lookup():
""" Test that a lookup is returned for every possible value """
@@ -502,3 +513,13 @@ def test_subst_expand_inplace_3():
template, expand_inplace=True, inplace_sep="; "
)
assert sorted(rendered) == sorted(expected)
def test_comment():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_COMMENTS)
for uuid in COMMENT_UUID_DICT:
photo = photosdb.get_photo(uuid)
comments = photo.render_template("{comment}")
assert comments[0] == COMMENT_UUID_DICT[uuid]