parent
ce297ced0a
commit
b03670dc70
@ -459,3 +459,7 @@ PROFILE_SORT_KEYS = [
|
||||
"time",
|
||||
"tottime",
|
||||
]
|
||||
|
||||
UUID_PATTERN = (
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
||||
)
|
||||
|
||||
@ -79,6 +79,7 @@ from .photo_inspect import photo_inspect
|
||||
from .places import places
|
||||
from .query import query
|
||||
from .repl import repl
|
||||
from .show_command import show
|
||||
from .snap_diff import diff, snap
|
||||
from .sync import sync
|
||||
from .theme import theme
|
||||
@ -125,6 +126,7 @@ __all__ = [
|
||||
"run",
|
||||
"selection_command",
|
||||
"set_debug",
|
||||
"show",
|
||||
"snap",
|
||||
"tutorial",
|
||||
"uuid",
|
||||
|
||||
@ -35,6 +35,7 @@ from .photo_inspect import photo_inspect
|
||||
from .places import places
|
||||
from .query import query
|
||||
from .repl import repl
|
||||
from .show_command import show
|
||||
from .snap_diff import diff, snap
|
||||
from .sync import sync
|
||||
from .theme import theme
|
||||
@ -130,6 +131,7 @@ for command in [
|
||||
query,
|
||||
repl,
|
||||
run,
|
||||
show,
|
||||
snap,
|
||||
sync,
|
||||
theme,
|
||||
|
||||
@ -27,10 +27,15 @@ from .cli_params import (
|
||||
)
|
||||
from .click_rich_echo import rich_click_echo as echo
|
||||
from .click_rich_echo import rich_echo_error as echo_error
|
||||
from .click_rich_echo import set_rich_theme
|
||||
from .color_themes import get_theme
|
||||
from .verbose import verbose, verbose_print
|
||||
|
||||
logger = logging.getLogger("osxphotos")
|
||||
|
||||
# ensure echo, echo_error are configured with correct theme
|
||||
set_rich_theme(get_theme())
|
||||
|
||||
__all__ = [
|
||||
"abort",
|
||||
"echo",
|
||||
|
||||
@ -14,7 +14,7 @@ from typing import Dict
|
||||
import click
|
||||
|
||||
from osxphotos import PhotosDB
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION, UUID_PATTERN
|
||||
from osxphotos.fileutil import FileUtil
|
||||
from osxphotos.utils import increment_filename, pluralize
|
||||
|
||||
@ -131,8 +131,7 @@ def scan_for_files(directory: str, uuid_dict: Dict):
|
||||
|
||||
Note: modifies uuid_dict
|
||||
"""
|
||||
uuid_pattern = r"([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})"
|
||||
uuid_regex = re.compile(uuid_pattern)
|
||||
uuid_regex = re.compile(UUID_PATTERN)
|
||||
for dirpath, dirname, filenames in os.walk(directory):
|
||||
for filename in filenames:
|
||||
if match := uuid_regex.match(filename):
|
||||
|
||||
74
osxphotos/cli/show_command.py
Normal file
74
osxphotos/cli/show_command.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""osxphotos show command"""
|
||||
|
||||
import re
|
||||
|
||||
import click
|
||||
|
||||
from osxphotos._constants import UUID_PATTERN
|
||||
from osxphotos.photoscript_utils import (
|
||||
photoscript_object_from_name,
|
||||
photoscript_object_from_uuid,
|
||||
)
|
||||
from osxphotos.photosdb.photosdb_utils import get_photos_library_version
|
||||
from osxphotos.utils import get_last_library_path
|
||||
|
||||
from .cli_commands import echo, echo_error
|
||||
from .cli_params import DB_OPTION
|
||||
from .click_rich_echo import set_rich_theme
|
||||
|
||||
|
||||
@click.command(name="show")
|
||||
@DB_OPTION
|
||||
@click.argument("uuid_or_name", metavar="UUID_OR_NAME", nargs=1, required=True)
|
||||
@click.pass_context
|
||||
def show(ctx, db, uuid_or_name):
|
||||
"""Show photo, album, or folder in Photos from UUID_OR_NAME
|
||||
|
||||
Examples:
|
||||
|
||||
osxphotos show 12345678-1234-1234-1234-123456789012
|
||||
|
||||
osxphotos show "My Album"
|
||||
|
||||
osxphotos show "My Folder"
|
||||
|
||||
osxphotos show IMG_1234.JPG
|
||||
|
||||
Notes:
|
||||
|
||||
This command requires Photos library version 5 or higher.
|
||||
Currently this command cannot be used to show subfolders in Photos.
|
||||
"""
|
||||
db = db or get_last_library_path()
|
||||
if not db:
|
||||
echo(
|
||||
"Could not find Photos library. Use --library/--db to specify path to Photos library."
|
||||
)
|
||||
ctx.exit(1)
|
||||
|
||||
if get_photos_library_version(db) < 5:
|
||||
echo_error("[error]show command requires Photos library version 5 or higher")
|
||||
ctx.exit(1)
|
||||
|
||||
try:
|
||||
if re.match(UUID_PATTERN, uuid_or_name):
|
||||
if not (obj := photoscript_object_from_uuid(uuid_or_name, db)):
|
||||
raise ValueError(
|
||||
f"could not find asset with UUID [uuid]{uuid_or_name}[/]"
|
||||
)
|
||||
obj_type = obj.__class__.__name__
|
||||
echo(f"Found [filename]{obj_type}[/] with UUID: [uuid]{uuid_or_name}[/]")
|
||||
obj.spotlight()
|
||||
elif obj := photoscript_object_from_name(uuid_or_name, db):
|
||||
obj_type = obj.__class__.__name__
|
||||
echo(
|
||||
f"Found [filename]{obj_type}[/] with name: [filepath]{uuid_or_name}[/]"
|
||||
)
|
||||
obj.spotlight()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"could not find asset with name [filepath]{uuid_or_name}[/]"
|
||||
)
|
||||
except Exception as e:
|
||||
echo_error(f"[error]Error finding asset [uuid]{uuid_or_name}[/]: {e}")
|
||||
ctx.exit(1)
|
||||
172
osxphotos/photoscript_utils.py
Normal file
172
osxphotos/photoscript_utils.py
Normal file
@ -0,0 +1,172 @@
|
||||
"""Utilities for creating photoscript objects from a name or UUID"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import photoscript
|
||||
|
||||
from ._constants import _DB_TABLE_NAMES, _PHOTOS_5_ALBUM_KIND, _PHOTOS_5_FOLDER_KIND
|
||||
from .photosdb.photosdb_utils import get_db_path_for_library, get_photos_library_version
|
||||
from .sqlite_utils import sqlite_open_ro
|
||||
|
||||
|
||||
def casefold(s: str | None) -> str | None:
|
||||
return s.casefold() if s else None
|
||||
|
||||
|
||||
def photoscript_object_from_uuid(
|
||||
uuid: str, photos_database: str
|
||||
) -> photoscript.Photo | None:
|
||||
"""Return a photoscript object from a uuid"""
|
||||
photos_database = get_db_path_for_library(photos_database)
|
||||
photos_version = get_photos_library_version(photos_database)
|
||||
connection, cursor = sqlite_open_ro(photos_database)
|
||||
uuid = uuid.upper()
|
||||
if _uuid_is_asset(uuid, connection, photos_version):
|
||||
return photoscript.Photo(uuid)
|
||||
elif _uuid_is_album(uuid, connection, photos_version):
|
||||
return photoscript.Album(uuid)
|
||||
elif _uuid_is_folder(uuid, connection, photos_version):
|
||||
return photoscript.Folder(uuid)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def photoscript_object_from_name(
|
||||
name: str, photos_database: str
|
||||
) -> photoscript.Photo | None:
|
||||
"""Return a photoscript object from a name"""
|
||||
photos_database = get_db_path_for_library(photos_database)
|
||||
photos_version = get_photos_library_version(photos_database)
|
||||
connection, cursor = sqlite_open_ro(photos_database)
|
||||
connection.create_function("CASEFOLD", 1, casefold)
|
||||
if uuid := _asset_uuid_for_name(name, connection, photos_version):
|
||||
return photoscript.Photo(uuid)
|
||||
elif uuid := _album_uuid_for_name(name, connection, photos_version):
|
||||
return photoscript.Album(uuid)
|
||||
elif uuid := _folder_uuid_for_name(name, connection, photos_version):
|
||||
return photoscript.Folder(uuid)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _uuid_is_asset(
|
||||
uuid: str, connection: sqlite3.Connection, version: int
|
||||
) -> str | None:
|
||||
"""Return uuid if uuid is an asset uuid otherwise None"""
|
||||
asset_table = _DB_TABLE_NAMES[version]["ASSET"]
|
||||
cursor = connection.cursor()
|
||||
if results := cursor.execute(
|
||||
f"""
|
||||
SELECT ZUUID
|
||||
FROM {asset_table}
|
||||
WHERE ZUUID=?
|
||||
""",
|
||||
(uuid,),
|
||||
).fetchone():
|
||||
return results[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _uuid_is_album(
|
||||
uuid: str, connection: sqlite3.Connection, version: int
|
||||
) -> str | None:
|
||||
"""Return uuid if uuid is an album uuid otherwise None"""
|
||||
cursor = connection.cursor()
|
||||
if results := cursor.execute(
|
||||
"""
|
||||
SELECT ZUUID
|
||||
FROM ZGENERICALBUM
|
||||
WHERE ZUUID=?
|
||||
AND ZKIND=?
|
||||
""",
|
||||
(uuid, _PHOTOS_5_ALBUM_KIND),
|
||||
).fetchone():
|
||||
return results[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _uuid_is_folder(
|
||||
uuid: str, connection: sqlite3.Connection, version: int
|
||||
) -> str | None:
|
||||
"""Return uuid if uuid is an folder uuid otherwise None"""
|
||||
cursor = connection.cursor()
|
||||
if results := cursor.execute(
|
||||
"""
|
||||
SELECT ZUUID
|
||||
FROM ZGENERICALBUM
|
||||
WHERE ZUUID=?
|
||||
AND ZKIND=?
|
||||
""",
|
||||
(uuid, _PHOTOS_5_FOLDER_KIND),
|
||||
).fetchone():
|
||||
return results[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _asset_uuid_for_name(
|
||||
name: str, connection: sqlite3.Connection, version: int
|
||||
) -> str | None:
|
||||
"""Return uuid for asset with name or None if not found"""
|
||||
asset_table = _DB_TABLE_NAMES[version]["ASSET"]
|
||||
cursor = connection.cursor()
|
||||
if results := cursor.execute(
|
||||
f"""
|
||||
SELECT {asset_table}.ZUUID
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
WHERE CASEFOLD(ZADDITIONALASSETATTRIBUTES.ZORIGINALFILENAME)=?
|
||||
ORDER BY {asset_table}.ZDATECREATED DESC
|
||||
""",
|
||||
(casefold(name),),
|
||||
).fetchone():
|
||||
return results[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _album_uuid_for_name(
|
||||
name: str, connection: sqlite3.Connection, version: int
|
||||
) -> str | None:
|
||||
"""Return uuid for album with name or None if not found"""
|
||||
return _folder_album_uuid_for_name(name, connection, version, album=True)
|
||||
|
||||
|
||||
def _folder_uuid_for_name(
|
||||
name: str, connection: sqlite3.Connection, version: int
|
||||
) -> str | None:
|
||||
"""Return uuid for album with name or None if not found"""
|
||||
return _folder_album_uuid_for_name(name, connection, version, folder=True)
|
||||
|
||||
|
||||
def _folder_album_uuid_for_name(
|
||||
name: str,
|
||||
connection: sqlite3.Connection,
|
||||
version: int,
|
||||
album: bool = False,
|
||||
folder: bool = False,
|
||||
) -> str | None:
|
||||
"""Return uuid for album with name or None if not found"""
|
||||
if album and folder:
|
||||
raise ValueError("album and folder cannot both be True")
|
||||
if not album and not folder:
|
||||
raise ValueError("album and folder cannot both be False")
|
||||
kind = _PHOTOS_5_ALBUM_KIND if album else _PHOTOS_5_FOLDER_KIND
|
||||
cursor = connection.cursor()
|
||||
if results := cursor.execute(
|
||||
"""
|
||||
SELECT ZUUID
|
||||
FROM ZGENERICALBUM
|
||||
WHERE CASEFOLD(ZTITLE)=?
|
||||
AND ZKIND=?
|
||||
ORDER BY ZCREATIONDATE DESC
|
||||
""",
|
||||
(casefold(name), kind),
|
||||
).fetchone():
|
||||
return results[0]
|
||||
else:
|
||||
return None
|
||||
@ -1,5 +1,7 @@
|
||||
""" utility functions used by PhotosDB """
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import plistlib
|
||||
@ -63,7 +65,8 @@ def get_db_version(db_file):
|
||||
if version not in _TESTED_DB_VERSIONS:
|
||||
print(
|
||||
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
|
||||
+ f" You have database version={version} which has not been tested", file=sys.stderr
|
||||
+ f" You have database version={version} which has not been tested",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return version
|
||||
@ -115,11 +118,18 @@ def get_db_model_version(db_file: str) -> int:
|
||||
return 8
|
||||
|
||||
|
||||
def get_photos_library_version(library_path):
|
||||
"""Return int indicating which Photos version a library was created with"""
|
||||
def get_photos_library_version(library_path: str | pathlib.Path) -> int:
|
||||
"""Return int indicating which Photos version a library was created with
|
||||
|
||||
Args:
|
||||
library_path: path to Photos library; may be path to the root of the library or the photos.db file
|
||||
|
||||
Returns: int of major Photos version number (e.g. 5, 6, ...)
|
||||
"""
|
||||
library_path = pathlib.Path(library_path)
|
||||
db_ver = get_db_version(str(library_path / "database" / "photos.db"))
|
||||
db_ver = int(db_ver)
|
||||
if library_path.is_dir():
|
||||
library_path = library_path / "database" / "photos.db"
|
||||
db_ver = int(get_db_version(str(library_path)))
|
||||
if db_ver == int(_PHOTOS_2_VERSION):
|
||||
return 2
|
||||
if db_ver == int(_PHOTOS_3_VERSION):
|
||||
@ -128,7 +138,8 @@ def get_photos_library_version(library_path):
|
||||
return 4
|
||||
|
||||
# assume it's a Photos 5+ library, get the model version to determine which version
|
||||
model_ver = get_model_version(str(library_path / "database" / "Photos.sqlite"))
|
||||
library_path = library_path.parent / "Photos.sqlite"
|
||||
model_ver = get_model_version(str(library_path))
|
||||
model_ver = int(model_ver)
|
||||
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
|
||||
return 5
|
||||
@ -142,3 +153,23 @@ def get_photos_library_version(library_path):
|
||||
f"Unknown db / model version: db_ver={db_ver}, model_ver={model_ver}; assuming Photos 8"
|
||||
)
|
||||
return 8
|
||||
|
||||
|
||||
def get_db_path_for_library(photos_library: str | pathlib.Path) -> pathlib.Path:
|
||||
"""Returns path to Photos database file for Photos library
|
||||
|
||||
Args:
|
||||
photos_library: path to Photos library; may be path to the root of the library or the photos.db file
|
||||
|
||||
Returns: pathlib.Path to Photos database file
|
||||
"""
|
||||
photos_library = pathlib.Path(photos_library)
|
||||
if photos_library.is_file():
|
||||
return photos_library
|
||||
photos_version = get_photos_library_version(photos_library)
|
||||
if photos_version < 5:
|
||||
if photos_library.is_dir():
|
||||
photos_library = photos_library / "database" / "photos.db"
|
||||
elif photos_library.is_dir():
|
||||
photos_library = photos_library / "database" / "Photos.sqlite"
|
||||
return photos_library
|
||||
|
||||
@ -11,6 +11,8 @@ from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
import bitmath
|
||||
|
||||
from ._constants import UUID_PATTERN
|
||||
|
||||
__all__ = ["QueryOptions", "query_options_from_kwargs", "IncompatibleQueryOptions"]
|
||||
|
||||
|
||||
@ -196,12 +198,12 @@ class QueryOptions:
|
||||
|
||||
|
||||
def query_options_from_kwargs(**kwargs) -> QueryOptions:
|
||||
""" Validate query options and create a QueryOptions instance.
|
||||
Note: this will block on stdin if uuid_from_file is set to "-"
|
||||
so it is best to call function before creating the PhotosDB instance
|
||||
so that the validation of query options can happen before the database
|
||||
is loaded.
|
||||
"""
|
||||
"""Validate query options and create a QueryOptions instance.
|
||||
Note: this will block on stdin if uuid_from_file is set to "-"
|
||||
so it is best to call function before creating the PhotosDB instance
|
||||
so that the validation of query options can happen before the database
|
||||
is loaded.
|
||||
"""
|
||||
# sanity check input args
|
||||
nonexclusive = [
|
||||
"added_after",
|
||||
@ -301,9 +303,9 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions:
|
||||
return QueryOptions(**query_dict)
|
||||
|
||||
|
||||
def load_uuid_from_file(filename: str) ->list[str]:
|
||||
def load_uuid_from_file(filename: str) -> list[str]:
|
||||
"""
|
||||
Load UUIDs from file.
|
||||
Load UUIDs from file.
|
||||
Does not validate UUIDs but does validate that the UUIDs are in the correct format.
|
||||
Format is 1 UUID per line, any line beginning with # is ignored.
|
||||
Whitespace is stripped.
|
||||
@ -328,6 +330,7 @@ def load_uuid_from_file(filename: str) ->list[str]:
|
||||
with open(filename, "r") as f:
|
||||
return _load_uuid_from_stream(f)
|
||||
|
||||
|
||||
def _load_uuid_from_stream(stream: io.IOBase) -> list[str]:
|
||||
"""
|
||||
Load UUIDs from stream.
|
||||
@ -349,10 +352,7 @@ def _load_uuid_from_stream(stream: io.IOBase) -> list[str]:
|
||||
for line in stream:
|
||||
line = line.strip()
|
||||
if len(line) and line[0] != "#":
|
||||
if not re.match(
|
||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
|
||||
line,
|
||||
):
|
||||
if not re.match(f"^{UUID_PATTERN}$", line):
|
||||
raise ValueError(f"Invalid UUID: {line}")
|
||||
line = line.upper()
|
||||
uuid.append(line)
|
||||
|
||||
@ -19,6 +19,7 @@ from photoscript import Photo
|
||||
from pytest import MonkeyPatch, approx
|
||||
|
||||
from osxphotos import PhotosDB, QueryOptions
|
||||
from osxphotos._constants import UUID_PATTERN
|
||||
from osxphotos.cli.import_cli import import_cli
|
||||
from osxphotos.datetime_utils import datetime_remove_tz
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
@ -101,9 +102,7 @@ def parse_import_output(output: str) -> Dict[str, str]:
|
||||
|
||||
results = {}
|
||||
for line in output.split("\n"):
|
||||
pattern = re.compile(
|
||||
r"Imported ([\w\.]+)\s.*UUID\s([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})"
|
||||
)
|
||||
pattern = re.compile(r"Imported ([\w\.]+)\s.*UUID\s(" + UUID_PATTERN + r")")
|
||||
if match := re.match(pattern, line):
|
||||
file = match[1]
|
||||
uuid = match[2]
|
||||
|
||||
41
tests/test_photosdb_utils.py
Normal file
41
tests/test_photosdb_utils.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Test photosdb_utils """
|
||||
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from osxphotos.photosdb.photosdb_utils import (
|
||||
get_db_path_for_library,
|
||||
get_photos_library_version,
|
||||
)
|
||||
|
||||
LIBRARIES = {
|
||||
2: pathlib.Path("tests/Test-10.12.6.photoslibrary"),
|
||||
3: pathlib.Path("tests/Test-10.13.6.photoslibrary"),
|
||||
4: pathlib.Path("tests/Test-10.14.6.photoslibrary"),
|
||||
5: pathlib.Path("tests/Test-10.15.7.photoslibrary"),
|
||||
6: pathlib.Path("tests/Test-10.16.0.photoslibrary"),
|
||||
7: pathlib.Path("tests/Test-12.0.1.photoslibrary"),
|
||||
8: pathlib.Path("tests/Test-13.0.0.photoslibrary"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version,library_path", list(LIBRARIES.items()))
|
||||
def test_get_photos_library_version_library_path(version, library_path):
|
||||
"""Test get_photos_library_version with library path"""
|
||||
photos_version = get_photos_library_version(library_path)
|
||||
assert photos_version == version
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version,library_path", list(LIBRARIES.items()))
|
||||
def test_get_photos_library_version_db_path(version, library_path):
|
||||
"""Test get_photos_library_version with database path"""
|
||||
photos_version = get_photos_library_version(library_path / "database" / "photos.db")
|
||||
assert photos_version == version
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_path", list(LIBRARIES.values()))
|
||||
def test_get_db_path_for_library(library_path):
|
||||
"""Test get_db_path_for_library"""
|
||||
db_path = get_db_path_for_library(library_path)
|
||||
assert db_path.is_file()
|
||||
Loading…
x
Reference in New Issue
Block a user