Feature add sync command 887 (#921)
* Starting work on sync command, #887 * Added parsing for --set, --merge * Added query options * Added --incloud, --not-incloud, --not-missing, --cloudasset, --not-cloudasset to query options, #800 (#902) * Got basic import logic working * Got basic set/merge logic working * add to album now working * Resolve paths for --import * Refactored report writer to reuse code from export report * Removed report_writer_sync.py * add oPromessa as a contributor for code (#914) * update README.md [skip ci] * update .all-contributorsrc [skip ci] Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * Added --profile, --watch, --breakpoint, --debug as global options (#917) * Removed unnecessary import * Release 0.56.0 (#918) * Release 0 56 0 (#919) * Release 0.56.0 * Release 0.56.0 * Updated CHANGELOG.md [skip ci] * Got CSV reporting, summary results done * Added json report for sync results * Added sqlite report for sync * Basic set/merge working for sync * sync mvp working * Added help text for sync * Added test for sync * Updated tests for sync Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,7 @@ from .places import places
|
|||||||
from .query import query
|
from .query import query
|
||||||
from .repl import repl
|
from .repl import repl
|
||||||
from .snap_diff import diff, snap
|
from .snap_diff import diff, snap
|
||||||
|
from .sync import sync
|
||||||
from .theme import theme
|
from .theme import theme
|
||||||
from .timewarp import timewarp
|
from .timewarp import timewarp
|
||||||
from .tutorial import tutorial
|
from .tutorial import tutorial
|
||||||
@@ -128,6 +129,7 @@ for command in [
|
|||||||
repl,
|
repl,
|
||||||
run,
|
run,
|
||||||
snap,
|
snap,
|
||||||
|
sync,
|
||||||
theme,
|
theme,
|
||||||
timewarp,
|
timewarp,
|
||||||
tutorial,
|
tutorial,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Globals and constants use by the CLI commands"""
|
"""Globals and constants use by the CLI commands"""
|
||||||
|
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -10,6 +11,7 @@ from packaging import version
|
|||||||
from xdg import xdg_config_home, xdg_data_home
|
from xdg import xdg_config_home, xdg_data_home
|
||||||
|
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
from osxphotos import QueryOptions
|
||||||
from osxphotos._constants import APP_NAME
|
from osxphotos._constants import APP_NAME
|
||||||
from osxphotos._version import __version__
|
from osxphotos._version import __version__
|
||||||
from osxphotos.utils import get_latest_version
|
from osxphotos.utils import get_latest_version
|
||||||
@@ -42,10 +44,17 @@ __all__ = [
|
|||||||
"get_photos_db",
|
"get_photos_db",
|
||||||
"load_uuid_from_file",
|
"load_uuid_from_file",
|
||||||
"noop",
|
"noop",
|
||||||
|
"query_options_from_kwargs",
|
||||||
"time_stamp",
|
"time_stamp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IncompatibleQueryOptions(Exception):
|
||||||
|
"""Incompatible query options"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def noop(*args, **kwargs):
|
def noop(*args, **kwargs):
|
||||||
"""no-op function"""
|
"""no-op function"""
|
||||||
pass
|
pass
|
||||||
@@ -660,3 +669,103 @@ def check_version():
|
|||||||
"to suppress this message and prevent osxphotos from checking for latest version.",
|
"to suppress this message and prevent osxphotos from checking for latest version.",
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def query_options_from_kwargs(**kwargs) -> QueryOptions:
|
||||||
|
"""Validate query options and create a QueryOptions instance"""
|
||||||
|
# sanity check input args
|
||||||
|
nonexclusive = [
|
||||||
|
"added_after",
|
||||||
|
"added_before",
|
||||||
|
"added_in_last",
|
||||||
|
"album",
|
||||||
|
"duplicate",
|
||||||
|
"edited",
|
||||||
|
"exif",
|
||||||
|
"external_edit",
|
||||||
|
"folder",
|
||||||
|
"from_date",
|
||||||
|
"from_time",
|
||||||
|
"has_raw",
|
||||||
|
"keyword",
|
||||||
|
"label",
|
||||||
|
"max_size",
|
||||||
|
"min_size",
|
||||||
|
"name",
|
||||||
|
"person",
|
||||||
|
"query_eval",
|
||||||
|
"query_function",
|
||||||
|
"regex",
|
||||||
|
"selected",
|
||||||
|
"to_date",
|
||||||
|
"to_time",
|
||||||
|
"uti",
|
||||||
|
"uuid_from_file",
|
||||||
|
"uuid",
|
||||||
|
"year",
|
||||||
|
]
|
||||||
|
exclusive = [
|
||||||
|
("burst", "not_burst"),
|
||||||
|
("cloudasset", "not_cloudasset"),
|
||||||
|
("favorite", "not_favorite"),
|
||||||
|
("has_comment", "no_comment"),
|
||||||
|
("has_likes", "no_likes"),
|
||||||
|
("hdr", "not_hdr"),
|
||||||
|
("hidden", "not_hidden"),
|
||||||
|
("in_album", "not_in_album"),
|
||||||
|
("incloud", "not_incloud"),
|
||||||
|
("live", "not_live"),
|
||||||
|
("location", "no_location"),
|
||||||
|
("keyword", "no_keyword"),
|
||||||
|
("missing", "not_missing"),
|
||||||
|
("only_photos", "only_movies"),
|
||||||
|
("panorama", "not_panorama"),
|
||||||
|
("portrait", "not_portrait"),
|
||||||
|
("screenshot", "not_screenshot"),
|
||||||
|
("selfie", "not_selfie"),
|
||||||
|
("shared", "not_shared"),
|
||||||
|
("slow_mo", "not_slow_mo"),
|
||||||
|
("time_lapse", "not_time_lapse"),
|
||||||
|
("is_reference", "not_reference"),
|
||||||
|
]
|
||||||
|
# print help if no non-exclusive term or a double exclusive term is given
|
||||||
|
# TODO: add option to validate requiring at least one query arg
|
||||||
|
if any(all([kwargs[b], kwargs[n]]) for b, n in exclusive) or any(
|
||||||
|
[
|
||||||
|
all([any(kwargs["title"]), kwargs["no_title"]]),
|
||||||
|
all([any(kwargs["description"]), kwargs["no_description"]]),
|
||||||
|
all([any(kwargs["place"]), kwargs["no_place"]]),
|
||||||
|
all([any(kwargs["keyword"]), kwargs["no_keyword"]]),
|
||||||
|
]
|
||||||
|
):
|
||||||
|
raise IncompatibleQueryOptions
|
||||||
|
|
||||||
|
# can also be used with --deleted/--not-deleted which are not part of
|
||||||
|
# standard query options
|
||||||
|
try:
|
||||||
|
if kwargs["deleted"] and kwargs["not_deleted"]:
|
||||||
|
raise IncompatibleQueryOptions
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# actually have something to query
|
||||||
|
include_photos = True
|
||||||
|
include_movies = True # default searches for everything
|
||||||
|
if kwargs["only_movies"]:
|
||||||
|
include_photos = False
|
||||||
|
if kwargs["only_photos"]:
|
||||||
|
include_movies = False
|
||||||
|
|
||||||
|
# load UUIDs if necessary and append to any uuids passed with --uuid
|
||||||
|
uuid = None
|
||||||
|
if kwargs["uuid_from_file"]:
|
||||||
|
uuid_list = list(kwargs["uuid"]) # Click option is a tuple
|
||||||
|
uuid_list.extend(load_uuid_from_file(kwargs["uuid_from_file"]))
|
||||||
|
uuid = tuple(uuid_list)
|
||||||
|
|
||||||
|
query_fields = [field.name for field in dataclasses.fields(QueryOptions)]
|
||||||
|
query_dict = {field: kwargs.get(field) for field in query_fields}
|
||||||
|
query_dict["photos"] = include_photos
|
||||||
|
query_dict["movies"] = include_movies
|
||||||
|
query_dict["uuid"] = uuid
|
||||||
|
return QueryOptions(**query_dict)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from .color_themes import get_theme
|
|||||||
from .common import DB_OPTION, THEME_OPTION, get_photos_db
|
from .common import DB_OPTION, THEME_OPTION, get_photos_db
|
||||||
from .export import export, render_and_validate_report
|
from .export import export, render_and_validate_report
|
||||||
from .param_types import ExportDBType, TemplateString
|
from .param_types import ExportDBType, TemplateString
|
||||||
from .report_writer import ReportWriterNoOp, report_writer_factory
|
from .report_writer import ReportWriterNoOp, export_report_writer_factory
|
||||||
from .rich_progress import rich_progress
|
from .rich_progress import rich_progress
|
||||||
from .verbose import get_verbose_console, verbose_print
|
from .verbose import get_verbose_console, verbose_print
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ def process_files(
|
|||||||
report = render_and_validate_report(
|
report = render_and_validate_report(
|
||||||
options.report, options.exiftool_path, export_dir
|
options.report, options.exiftool_path, export_dir
|
||||||
)
|
)
|
||||||
report_writer = report_writer_factory(report, options.append)
|
report_writer = export_report_writer_factory(report, options.append)
|
||||||
else:
|
else:
|
||||||
report_writer = ReportWriterNoOp()
|
report_writer = ReportWriterNoOp()
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from osxphotos._constants import (
|
|||||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||||
OSXPHOTOS_EXPORT_DB,
|
OSXPHOTOS_EXPORT_DB,
|
||||||
POST_COMMAND_CATEGORIES,
|
POST_COMMAND_CATEGORIES,
|
||||||
PROFILE_SORT_KEYS,
|
|
||||||
SIDECAR_EXIFTOOL,
|
SIDECAR_EXIFTOOL,
|
||||||
SIDECAR_JSON,
|
SIDECAR_JSON,
|
||||||
SIDECAR_XMP,
|
SIDECAR_XMP,
|
||||||
@@ -86,7 +85,7 @@ from .common import (
|
|||||||
from .help import ExportCommand, get_help_msg
|
from .help import ExportCommand, get_help_msg
|
||||||
from .list import _list_libraries
|
from .list import _list_libraries
|
||||||
from .param_types import ExportDBType, FunctionCall, TemplateString
|
from .param_types import ExportDBType, FunctionCall, TemplateString
|
||||||
from .report_writer import ReportWriterNoOp, report_writer_factory
|
from .report_writer import ReportWriterNoOp, export_report_writer_factory
|
||||||
from .rich_progress import rich_progress
|
from .rich_progress import rich_progress
|
||||||
from .verbose import get_verbose_console, time_stamp, verbose_print
|
from .verbose import get_verbose_console, time_stamp, verbose_print
|
||||||
|
|
||||||
@@ -1218,7 +1217,7 @@ def export(
|
|||||||
|
|
||||||
if report:
|
if report:
|
||||||
report = render_and_validate_report(report, exiftool_path, dest)
|
report = render_and_validate_report(report, exiftool_path, dest)
|
||||||
report_writer = report_writer_factory(report, append)
|
report_writer = export_report_writer_factory(report, append)
|
||||||
else:
|
else:
|
||||||
report_writer = ReportWriterNoOp()
|
report_writer = ReportWriterNoOp()
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from .click_rich_echo import (
|
|||||||
from .color_themes import get_theme
|
from .color_themes import get_theme
|
||||||
from .export import render_and_validate_report
|
from .export import render_and_validate_report
|
||||||
from .param_types import TemplateString
|
from .param_types import TemplateString
|
||||||
from .report_writer import report_writer_factory
|
from .report_writer import export_report_writer_factory
|
||||||
from .verbose import get_verbose_console, verbose_print
|
from .verbose import get_verbose_console, verbose_print
|
||||||
|
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ def exportdb(
|
|||||||
rich_echo(f"[error]No report results found for run ID {run_id}[/error]")
|
rich_echo(f"[error]No report results found for run ID {run_id}[/error]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
try:
|
try:
|
||||||
report_writer = report_writer_factory(report_filename, append=append)
|
report_writer = export_report_writer_factory(report_filename, append=append)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
rich_echo(f"[error]Error: {e}[/error]")
|
rich_echo(f"[error]Error: {e}[/error]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""repl command for osxphotos CLI"""
|
"""repl command for osxphotos CLI"""
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -23,16 +22,13 @@ from .common import (
|
|||||||
DB_ARGUMENT,
|
DB_ARGUMENT,
|
||||||
DB_OPTION,
|
DB_OPTION,
|
||||||
DELETED_OPTIONS,
|
DELETED_OPTIONS,
|
||||||
|
IncompatibleQueryOptions,
|
||||||
QUERY_OPTIONS,
|
QUERY_OPTIONS,
|
||||||
get_photos_db,
|
get_photos_db,
|
||||||
load_uuid_from_file,
|
query_options_from_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class IncompatibleQueryOptions(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@click.command(name="repl")
|
@click.command(name="repl")
|
||||||
@DB_OPTION
|
@DB_OPTION
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
@@ -85,7 +81,7 @@ def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
|
|||||||
print("Getting photos")
|
print("Getting photos")
|
||||||
tic = time.perf_counter()
|
tic = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
query_options = _query_options_from_kwargs(**kwargs)
|
query_options = query_options_from_kwargs(**kwargs)
|
||||||
except IncompatibleQueryOptions:
|
except IncompatibleQueryOptions:
|
||||||
click.echo("Incompatible query options", err=True)
|
click.echo("Incompatible query options", err=True)
|
||||||
click.echo(ctx.obj.group.commands["repl"].get_help(ctx), err=True)
|
click.echo(ctx.obj.group.commands["repl"].get_help(ctx), err=True)
|
||||||
@@ -211,99 +207,6 @@ def _spotlight_photo(photo: PhotoInfo):
|
|||||||
photo_.spotlight()
|
photo_.spotlight()
|
||||||
|
|
||||||
|
|
||||||
def _query_options_from_kwargs(**kwargs) -> QueryOptions:
|
|
||||||
"""Validate query options and create a QueryOptions instance"""
|
|
||||||
# sanity check input args
|
|
||||||
nonexclusive = [
|
|
||||||
"added_after",
|
|
||||||
"added_before",
|
|
||||||
"added_in_last",
|
|
||||||
"album",
|
|
||||||
"duplicate",
|
|
||||||
"edited",
|
|
||||||
"exif",
|
|
||||||
"external_edit",
|
|
||||||
"folder",
|
|
||||||
"from_date",
|
|
||||||
"from_time",
|
|
||||||
"has_raw",
|
|
||||||
"keyword",
|
|
||||||
"label",
|
|
||||||
"max_size",
|
|
||||||
"min_size",
|
|
||||||
"name",
|
|
||||||
"person",
|
|
||||||
"query_eval",
|
|
||||||
"query_function",
|
|
||||||
"regex",
|
|
||||||
"selected",
|
|
||||||
"to_date",
|
|
||||||
"to_time",
|
|
||||||
"uti",
|
|
||||||
"uuid_from_file",
|
|
||||||
"uuid",
|
|
||||||
"year",
|
|
||||||
]
|
|
||||||
exclusive = [
|
|
||||||
("burst", "not_burst"),
|
|
||||||
("cloudasset", "not_cloudasset"),
|
|
||||||
("deleted", "deleted_only"),
|
|
||||||
("favorite", "not_favorite"),
|
|
||||||
("has_comment", "no_comment"),
|
|
||||||
("has_likes", "no_likes"),
|
|
||||||
("hdr", "not_hdr"),
|
|
||||||
("hidden", "not_hidden"),
|
|
||||||
("in_album", "not_in_album"),
|
|
||||||
("incloud", "not_incloud"),
|
|
||||||
("live", "not_live"),
|
|
||||||
("location", "no_location"),
|
|
||||||
("keyword", "no_keyword"),
|
|
||||||
("missing", "not_missing"),
|
|
||||||
("only_photos", "only_movies"),
|
|
||||||
("panorama", "not_panorama"),
|
|
||||||
("portrait", "not_portrait"),
|
|
||||||
("screenshot", "not_screenshot"),
|
|
||||||
("selfie", "not_selfie"),
|
|
||||||
("shared", "not_shared"),
|
|
||||||
("slow_mo", "not_slow_mo"),
|
|
||||||
("time_lapse", "not_time_lapse"),
|
|
||||||
("is_reference", "not_reference"),
|
|
||||||
]
|
|
||||||
# print help if no non-exclusive term or a double exclusive term is given
|
|
||||||
# TODO: add option to validate requiring at least one query arg
|
|
||||||
if any(all([kwargs[b], kwargs[n]]) for b, n in exclusive) or any(
|
|
||||||
[
|
|
||||||
all([any(kwargs["title"]), kwargs["no_title"]]),
|
|
||||||
all([any(kwargs["description"]), kwargs["no_description"]]),
|
|
||||||
all([any(kwargs["place"]), kwargs["no_place"]]),
|
|
||||||
all([any(kwargs["keyword"]), kwargs["no_keyword"]]),
|
|
||||||
]
|
|
||||||
):
|
|
||||||
raise IncompatibleQueryOptions
|
|
||||||
|
|
||||||
# actually have something to query
|
|
||||||
include_photos = True
|
|
||||||
include_movies = True # default searches for everything
|
|
||||||
if kwargs["only_movies"]:
|
|
||||||
include_photos = False
|
|
||||||
if kwargs["only_photos"]:
|
|
||||||
include_movies = False
|
|
||||||
|
|
||||||
# load UUIDs if necessary and append to any uuids passed with --uuid
|
|
||||||
uuid = None
|
|
||||||
if kwargs["uuid_from_file"]:
|
|
||||||
uuid_list = list(kwargs["uuid"]) # Click option is a tuple
|
|
||||||
uuid_list.extend(load_uuid_from_file(kwargs["uuid_from_file"]))
|
|
||||||
uuid = tuple(uuid_list)
|
|
||||||
|
|
||||||
query_fields = [field.name for field in dataclasses.fields(QueryOptions)]
|
|
||||||
query_dict = {field: kwargs.get(field) for field in query_fields}
|
|
||||||
query_dict["photos"] = include_photos
|
|
||||||
query_dict["movies"] = include_movies
|
|
||||||
query_dict["uuid"] = uuid
|
|
||||||
return QueryOptions(**query_dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List:
|
def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List:
|
||||||
"""Query photos given a QueryOptions instance"""
|
"""Query photos given a QueryOptions instance"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Report writer for the --report option of `osxphotos export`"""
|
"""Report writer for the --report option of `osxphotos export`"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import datetime
|
import datetime
|
||||||
@@ -15,20 +16,28 @@ from osxphotos.export_db import OSXPHOTOS_ABOUT_STRING
|
|||||||
from osxphotos.photoexporter import ExportResults
|
from osxphotos.photoexporter import ExportResults
|
||||||
from osxphotos.sqlite_utils import sqlite_columns
|
from osxphotos.sqlite_utils import sqlite_columns
|
||||||
|
|
||||||
|
from .sync_results import SyncResults
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"report_writer_factory",
|
"ExportReportWriterCSV",
|
||||||
|
"ExportReportWriterJSON",
|
||||||
|
"ExportReportWriterSqlite",
|
||||||
"ReportWriterABC",
|
"ReportWriterABC",
|
||||||
"ReportWriterCSV",
|
|
||||||
"ReportWriterSqlite",
|
|
||||||
"ReportWriterNoOp",
|
"ReportWriterNoOp",
|
||||||
|
"SyncReportWriterCSV",
|
||||||
|
"SyncReportWriterJSON",
|
||||||
|
"SyncReportWriterSqlite",
|
||||||
|
"export_report_writer_factory",
|
||||||
|
"sync_report_writer_factory",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Abstract base class for report writers
|
||||||
class ReportWriterABC(ABC):
|
class ReportWriterABC(ABC):
|
||||||
"""Abstract base class for report writers"""
|
"""Abstract base class for report writers"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def write(self, export_results: ExportResults):
|
def write(self, results: ExportResults | SyncResults):
|
||||||
"""Write results to the output file"""
|
"""Write results to the output file"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -38,13 +47,16 @@ class ReportWriterABC(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Report writer that does nothing, used for --dry-run or when --report not specified
|
||||||
|
|
||||||
|
|
||||||
class ReportWriterNoOp(ABC):
|
class ReportWriterNoOp(ABC):
|
||||||
"""Report writer that does nothing"""
|
"""Report writer that does nothing"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def write(self, export_results: ExportResults):
|
def write(self, results: ExportResults | SyncResults):
|
||||||
"""Write results to the output file"""
|
"""Write results to the output file"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -53,8 +65,9 @@ class ReportWriterNoOp(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ReportWriterCSV(ReportWriterABC):
|
# Classes for writing ExportResults to report file
|
||||||
"""Write CSV report file"""
|
class ExportReportWriterCSV(ReportWriterABC):
|
||||||
|
"""Write CSV report file for export results"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
@@ -95,7 +108,7 @@ class ReportWriterCSV(ReportWriterABC):
|
|||||||
|
|
||||||
def write(self, export_results: ExportResults):
|
def write(self, export_results: ExportResults):
|
||||||
"""Write results to the output file"""
|
"""Write results to the output file"""
|
||||||
all_results = prepare_results_for_writing(export_results)
|
all_results = prepare_export_results_for_writing(export_results)
|
||||||
for data in list(all_results.values()):
|
for data in list(all_results.values()):
|
||||||
self._csv_writer.writerow(data)
|
self._csv_writer.writerow(data)
|
||||||
self._output_fh.flush()
|
self._output_fh.flush()
|
||||||
@@ -109,8 +122,8 @@ class ReportWriterCSV(ReportWriterABC):
|
|||||||
self._output_fh.close()
|
self._output_fh.close()
|
||||||
|
|
||||||
|
|
||||||
class ReportWriterJSON(ReportWriterABC):
|
class ExportReportWriterJSON(ReportWriterABC):
|
||||||
"""Write JSON report file"""
|
"""Write JSON report file for export results"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
@@ -134,7 +147,9 @@ class ReportWriterJSON(ReportWriterABC):
|
|||||||
|
|
||||||
def write(self, export_results: ExportResults):
|
def write(self, export_results: ExportResults):
|
||||||
"""Write results to the output file"""
|
"""Write results to the output file"""
|
||||||
all_results = prepare_results_for_writing(export_results, bool_values=True)
|
all_results = prepare_export_results_for_writing(
|
||||||
|
export_results, bool_values=True
|
||||||
|
)
|
||||||
for data in list(all_results.values()):
|
for data in list(all_results.values()):
|
||||||
if self._first_record_written:
|
if self._first_record_written:
|
||||||
self._output_fh.write(",\n")
|
self._output_fh.write(",\n")
|
||||||
@@ -153,8 +168,8 @@ class ReportWriterJSON(ReportWriterABC):
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
class ReportWriterSQLite(ReportWriterABC):
|
class ExportReportWriterSQLite(ReportWriterABC):
|
||||||
"""Write sqlite report file"""
|
"""Write sqlite report file for export data"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
@@ -173,7 +188,7 @@ class ReportWriterSQLite(ReportWriterABC):
|
|||||||
def write(self, export_results: ExportResults):
|
def write(self, export_results: ExportResults):
|
||||||
"""Write results to the output file"""
|
"""Write results to the output file"""
|
||||||
|
|
||||||
all_results = prepare_results_for_writing(export_results)
|
all_results = prepare_export_results_for_writing(export_results)
|
||||||
for data in list(all_results.values()):
|
for data in list(all_results.values()):
|
||||||
data["report_id"] = self.report_id
|
data["report_id"] = self.report_id
|
||||||
cursor = self._conn.cursor()
|
cursor = self._conn.cursor()
|
||||||
@@ -284,7 +299,7 @@ class ReportWriterSQLite(ReportWriterABC):
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
def prepare_results_for_writing(
|
def prepare_export_results_for_writing(
|
||||||
export_results: ExportResults, bool_values: bool = False
|
export_results: ExportResults, bool_values: bool = False
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Return all results for writing to report
|
"""Return all results for writing to report
|
||||||
@@ -406,17 +421,250 @@ def prepare_results_for_writing(
|
|||||||
return all_results
|
return all_results
|
||||||
|
|
||||||
|
|
||||||
def report_writer_factory(
|
def export_report_writer_factory(
|
||||||
output_file: Union[str, bytes, os.PathLike], append: bool = False
|
output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
) -> ReportWriterABC:
|
) -> ReportWriterABC:
|
||||||
"""Return a ReportWriter instance appropriate for the output file type"""
|
"""Return a ReportWriter instance appropriate for the output file type"""
|
||||||
output_type = os.path.splitext(output_file)[1]
|
output_type = os.path.splitext(output_file)[1]
|
||||||
output_type = output_type.lower()[1:]
|
output_type = output_type.lower()[1:]
|
||||||
if output_type == "csv":
|
if output_type == "csv":
|
||||||
return ReportWriterCSV(output_file, append)
|
return ExportReportWriterCSV(output_file, append)
|
||||||
elif output_type == "json":
|
elif output_type == "json":
|
||||||
return ReportWriterJSON(output_file, append)
|
return ExportReportWriterJSON(output_file, append)
|
||||||
elif output_type in ["sqlite", "db"]:
|
elif output_type in ["sqlite", "db"]:
|
||||||
return ReportWriterSQLite(output_file, append)
|
return ExportReportWriterSQLite(output_file, append)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown report file type: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
# Classes for writing Sync results to a report file
|
||||||
|
|
||||||
|
|
||||||
|
class SyncReportWriterCSV(ReportWriterABC):
|
||||||
|
"""Write CSV report file"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
|
):
|
||||||
|
self.output_file = output_file
|
||||||
|
self.append = append
|
||||||
|
mode = "a" if append else "w"
|
||||||
|
self._output_fh = open(self.output_file, mode)
|
||||||
|
|
||||||
|
def write(self, sync_results: SyncResults):
|
||||||
|
"""Write results to the output file"""
|
||||||
|
report_columns = sync_results.results_header
|
||||||
|
self._csv_writer = csv.DictWriter(self._output_fh, fieldnames=report_columns)
|
||||||
|
if not self.append:
|
||||||
|
self._csv_writer.writeheader()
|
||||||
|
|
||||||
|
for data in sync_results.results_list:
|
||||||
|
self._csv_writer.writerow(dict(zip(report_columns, data)))
|
||||||
|
self._output_fh.flush()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the output file"""
|
||||||
|
self._output_fh.close()
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
with suppress(Exception):
|
||||||
|
self._output_fh.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SyncReportWriterJSON(ReportWriterABC):
|
||||||
|
"""Write JSON SyncResults report file"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
|
):
|
||||||
|
self.output_file = output_file
|
||||||
|
self.append = append
|
||||||
|
self.indent = 4
|
||||||
|
|
||||||
|
self._first_record_written = False
|
||||||
|
if append:
|
||||||
|
with open(self.output_file, "r") as fh:
|
||||||
|
existing_data = json.load(fh)
|
||||||
|
self._output_fh = open(self.output_file, "w")
|
||||||
|
self._output_fh.write("[")
|
||||||
|
for data in existing_data:
|
||||||
|
self._output_fh.write(json.dumps(data, indent=self.indent))
|
||||||
|
self._output_fh.write(",\n")
|
||||||
|
else:
|
||||||
|
self._output_fh = open(self.output_file, "w")
|
||||||
|
self._output_fh.write("[")
|
||||||
|
|
||||||
|
def write(self, results: SyncResults):
|
||||||
|
"""Write results to the output file"""
|
||||||
|
|
||||||
|
# convert datetimes to strings
|
||||||
|
def default(o):
|
||||||
|
if isinstance(o, (datetime.date, datetime.datetime)):
|
||||||
|
return o.isoformat()
|
||||||
|
|
||||||
|
for data in list(results.results_dict.values()):
|
||||||
|
if self._first_record_written:
|
||||||
|
self._output_fh.write(",\n")
|
||||||
|
else:
|
||||||
|
self._first_record_written = True
|
||||||
|
self._output_fh.write(json.dumps(data, indent=self.indent, default=default))
|
||||||
|
self._output_fh.flush()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the output file"""
|
||||||
|
self._output_fh.write("]")
|
||||||
|
self._output_fh.close()
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
with suppress(Exception):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SyncReportWriterSQLite(ReportWriterABC):
|
||||||
|
"""Write sqlite SyncResults report file"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
|
):
|
||||||
|
self.output_file = output_file
|
||||||
|
self.append = append
|
||||||
|
|
||||||
|
if not append:
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
os.unlink(self.output_file)
|
||||||
|
|
||||||
|
self._conn = sqlite3.connect(self.output_file)
|
||||||
|
self._create_tables()
|
||||||
|
self.report_id = self._generate_report_id()
|
||||||
|
|
||||||
|
def write(self, results: SyncResults):
|
||||||
|
"""Write results to the output file"""
|
||||||
|
|
||||||
|
# insert rows of values into sqlite report table
|
||||||
|
for row in list(results.results_list):
|
||||||
|
report_id = self.report_id
|
||||||
|
data = [str(v) if v else "" for v in row]
|
||||||
|
cursor = self._conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO report "
|
||||||
|
"(report_id, uuid, filename, fingerprint, updated, "
|
||||||
|
"albums_updated, albums_datetime, albums_before, albums_after, "
|
||||||
|
"description_updated, description_datetime, description_before, description_after, "
|
||||||
|
"favorite_updated, favorite_datetime, favorite_before, favorite_after, "
|
||||||
|
"keywords_updated, keywords_datetime, keywords_before, keywords_after, "
|
||||||
|
"title_updated, title_datetime, title_before, title_after)"
|
||||||
|
"VALUES "
|
||||||
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(report_id, *data),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the output file"""
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def _create_tables(self):
|
||||||
|
c = self._conn.cursor()
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS report (
|
||||||
|
report_id TEXT,
|
||||||
|
uuid TEXT,
|
||||||
|
filename TEXT,
|
||||||
|
fingerprint TEXT,
|
||||||
|
updated INT,
|
||||||
|
albums_updated INT,
|
||||||
|
albums_datetime TEXT,
|
||||||
|
albums_before TEXT,
|
||||||
|
albums_after TEXT,
|
||||||
|
description_updated INT,
|
||||||
|
description_datetime TEXT,
|
||||||
|
description_before TEXT,
|
||||||
|
description_after TEXT,
|
||||||
|
favorite_updated INT,
|
||||||
|
favorite_datetime TEXT,
|
||||||
|
favorite_before TEXT,
|
||||||
|
favorite_after TEXT,
|
||||||
|
keywords_updated INT,
|
||||||
|
keywords_datetime TEXT,
|
||||||
|
keywords_before TEXT,
|
||||||
|
keywords_after TEXT,
|
||||||
|
title_updated INT,
|
||||||
|
title_datetime TEXT,
|
||||||
|
title_before TEXT,
|
||||||
|
title_after TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS about (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
about TEXT
|
||||||
|
);"""
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO about(about) VALUES (?);",
|
||||||
|
(f"OSXPhotos Sync Report. {OSXPHOTOS_ABOUT_STRING}",),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS report_id (
|
||||||
|
report_id INTEGER PRIMARY KEY,
|
||||||
|
datetime TEXT
|
||||||
|
);"""
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
# create report_summary view
|
||||||
|
c.execute("DROP VIEW IF EXISTS report_summary;")
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE VIEW report_summary AS
|
||||||
|
SELECT
|
||||||
|
r.report_id,
|
||||||
|
i.datetime AS report_datetime,
|
||||||
|
COUNT(r.uuid) as processed,
|
||||||
|
COUNT(CASE r.updated WHEN 'True' THEN 1 ELSE NULL END) as updated,
|
||||||
|
COUNT(case r.albums_updated WHEN 'True' THEN 1 ELSE NULL END) as albums_updated,
|
||||||
|
COUNT(case r.description_updated WHEN 'True' THEN 1 ELSE NULL END) as description_updated,
|
||||||
|
COUNT(case r.favorite_updated WHEN 'True' THEN 1 ELSE NULL END) as favorite_updated,
|
||||||
|
COUNT(case r.keywords_updated WHEN 'True' THEN 1 ELSE NULL END) as keywords_updated,
|
||||||
|
COUNT(case r.title_updated WHEN 'True' THEN 1 ELSE NULL END) as title_updated
|
||||||
|
FROM report as r
|
||||||
|
INNER JOIN report_id as i ON r.report_id = i.report_id
|
||||||
|
GROUP BY r.report_id;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def _generate_report_id(self) -> int:
|
||||||
|
"""Get a new report ID for this report"""
|
||||||
|
c = self._conn.cursor()
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO report_id(datetime) VALUES (?);",
|
||||||
|
(datetime.datetime.now().isoformat(),),
|
||||||
|
)
|
||||||
|
report_id = c.lastrowid
|
||||||
|
self._conn.commit()
|
||||||
|
return report_id
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
with suppress(Exception):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_report_writer_factory(
|
||||||
|
output_file: Union[str, bytes, os.PathLike], append: bool = False
|
||||||
|
) -> ReportWriterABC:
|
||||||
|
"""Return a ReportWriter instance appropriate for the output file type"""
|
||||||
|
output_type = os.path.splitext(output_file)[1]
|
||||||
|
output_type = output_type.lower()[1:]
|
||||||
|
if output_type == "csv":
|
||||||
|
return SyncReportWriterCSV(output_file, append)
|
||||||
|
elif output_type == "json":
|
||||||
|
return SyncReportWriterJSON(output_file, append)
|
||||||
|
elif output_type in ["sqlite", "db"]:
|
||||||
|
return SyncReportWriterSQLite(output_file, append)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown report file type: {output_file}")
|
raise ValueError(f"Unknown report file type: {output_file}")
|
||||||
|
|||||||
749
osxphotos/cli/sync.py
Normal file
749
osxphotos/cli/sync.py
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
"""Sync metadata and albums between Photos libraries"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
from typing import Any, Callable, Literal
|
||||||
|
|
||||||
|
import click
|
||||||
|
import photoscript
|
||||||
|
|
||||||
|
from osxphotos import PhotoInfo, PhotosDB, __version__
|
||||||
|
from osxphotos.photoinfo import PhotoInfoNone
|
||||||
|
from osxphotos.photosalbum import PhotosAlbum
|
||||||
|
from osxphotos.photosdb.photosdb_utils import get_db_version
|
||||||
|
from osxphotos.phototemplate import PhotoTemplate, RenderOptions
|
||||||
|
from osxphotos.sqlitekvstore import SQLiteKVStore
|
||||||
|
from osxphotos.utils import pluralize
|
||||||
|
|
||||||
|
from .click_rich_echo import (
|
||||||
|
rich_click_echo,
|
||||||
|
rich_echo_error,
|
||||||
|
set_rich_console,
|
||||||
|
set_rich_theme,
|
||||||
|
set_rich_timestamp,
|
||||||
|
)
|
||||||
|
from .color_themes import get_theme
|
||||||
|
from .common import DB_OPTION, QUERY_OPTIONS, THEME_OPTION, query_options_from_kwargs
|
||||||
|
from .param_types import TemplateString
|
||||||
|
from .report_writer import sync_report_writer_factory
|
||||||
|
from .rich_progress import rich_progress
|
||||||
|
from .sync_results import SYNC_PROPERTIES, SyncResults
|
||||||
|
from .verbose import get_verbose_console, verbose_print
|
||||||
|
|
||||||
|
SYNC_ABOUT_STRING = (
|
||||||
|
f"Sync Metadata Database created by osxphotos version {__version__} "
|
||||||
|
+ f"(https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
SYNC_IMPORT_TYPES = [
|
||||||
|
"keywords",
|
||||||
|
"albums",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"favorite",
|
||||||
|
]
|
||||||
|
SYNC_IMPORT_TYPES_ALL = ["all"] + SYNC_IMPORT_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
class SyncImportPath(click.ParamType):
|
||||||
|
"""A path to a Photos library or a metadata export file created by --export"""
|
||||||
|
|
||||||
|
name = "SYNC_IMPORT_PATH"
|
||||||
|
|
||||||
|
def convert(self, value, param, ctx):
|
||||||
|
try:
|
||||||
|
if not pathlib.Path(value).exists():
|
||||||
|
self.fail(f"{value} is not a file or directory")
|
||||||
|
value = str(pathlib.Path(value).expanduser().resolve())
|
||||||
|
# call get_import_type to raise exception if not a valid import type
|
||||||
|
get_import_type(value)
|
||||||
|
return value
|
||||||
|
except Exception as e:
|
||||||
|
self.fail(f"Could not determine import type for {value}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class SyncImportType(click.ParamType):
|
||||||
|
"""A string indicating which metadata to set or merge from the import source"""
|
||||||
|
|
||||||
|
# valid values are specified in METADATA_IMPORT_TYPES_ALL
|
||||||
|
|
||||||
|
name = "SYNC_IMPORT_TYPE"
|
||||||
|
|
||||||
|
def convert(self, value, param, ctx):
|
||||||
|
try:
|
||||||
|
if value not in SYNC_IMPORT_TYPES_ALL:
|
||||||
|
values = [v.strip() for v in value.split(",")]
|
||||||
|
for v in values:
|
||||||
|
if v not in SYNC_IMPORT_TYPES_ALL:
|
||||||
|
self.fail(
|
||||||
|
f"{v} is not a valid import type, valid values are {', '.join(SYNC_IMPORT_TYPES_ALL)}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
except Exception as e:
|
||||||
|
self.fail(f"Could not determine import type for {value}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_and_validate_report(report: str) -> str:
|
||||||
|
"""Render a report file template and validate the filename
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report: the template string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
the rendered report filename
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Exits with error if the report filename is invalid
|
||||||
|
"""
|
||||||
|
# render report template and validate the filename
|
||||||
|
template = PhotoTemplate(PhotoInfoNone())
|
||||||
|
render_options = RenderOptions()
|
||||||
|
report_file, _ = template.render(report, options=render_options)
|
||||||
|
report = report_file[0]
|
||||||
|
|
||||||
|
if os.path.isdir(report):
|
||||||
|
rich_click_echo(
|
||||||
|
f"[error]Report '{report}' is a directory, must be file name",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
raise click.Abort()
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def parse_set_merge(values: tuple[str]) -> tuple[str]:
|
||||||
|
"""Parse --set and --merge options which may be passed individually or as a comma-separated list"""
|
||||||
|
new_values = []
|
||||||
|
for value in values:
|
||||||
|
new_values.extend([v.strip() for v in value.split(",")])
|
||||||
|
return tuple(new_values)
|
||||||
|
|
||||||
|
|
||||||
|
def open_metadata_db(db_path: str):
|
||||||
|
"""Open metadata database at db_path"""
|
||||||
|
metadata_db = SQLiteKVStore(
|
||||||
|
db_path,
|
||||||
|
wal=False, # don't use WAL to keep database a single file
|
||||||
|
)
|
||||||
|
if not metadata_db.about:
|
||||||
|
metadata_db.about = f"osxphotos metadata sync database\n{SYNC_ABOUT_STRING}"
|
||||||
|
return metadata_db
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_photo(photo: PhotoInfo) -> str:
|
||||||
|
"""Return key for photo used to correlate photos between libraries"""
|
||||||
|
return f"{photo.fingerprint}:{photo.original_filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_photo_metadata(photos: list[PhotoInfo]) -> str:
|
||||||
|
"""Return JSON string of metadata for photos; if more than one photo, merge metadata"""
|
||||||
|
if len(photos) == 1:
|
||||||
|
return photos[0].json()
|
||||||
|
|
||||||
|
# more than one photo with same fingerprint; merge metadata
|
||||||
|
merge_fields = ["keywords", "persons", "albums", "title", "description", "uuid"]
|
||||||
|
photos_dict = {}
|
||||||
|
for photo in photos:
|
||||||
|
data = photo.asdict()
|
||||||
|
for k, v in data.items():
|
||||||
|
if k not in photos_dict:
|
||||||
|
photos_dict[k] = v.copy() if isinstance(v, (list, dict)) else v
|
||||||
|
else:
|
||||||
|
# merge data if it's a merge field
|
||||||
|
if k in merge_fields and v:
|
||||||
|
if isinstance(v, (list, tuple)):
|
||||||
|
photos_dict[k] = sorted(list(set(photos_dict[k]) | set(v)))
|
||||||
|
else:
|
||||||
|
if v:
|
||||||
|
if not photos_dict[k]:
|
||||||
|
photos_dict[k] = v
|
||||||
|
elif photos_dict[k] and v != photos_dict[k]:
|
||||||
|
photos_dict[k] = f"{photos_dict[k]} {v}"
|
||||||
|
# convert photos_dict to JSON string
|
||||||
|
# wouldn't it be nice if json encoder handled datetimes...
|
||||||
|
def default(o):
|
||||||
|
if isinstance(o, (datetime.date, datetime.datetime)):
|
||||||
|
return o.isoformat()
|
||||||
|
|
||||||
|
return json.dumps(photos_dict, sort_keys=True, default=default)
|
||||||
|
|
||||||
|
|
||||||
|
def export_metadata(
|
||||||
|
photos: list[PhotoInfo], output_path: str, verbose: Callable[..., None]
|
||||||
|
):
|
||||||
|
"""Export metadata to metadata_db"""
|
||||||
|
metadata_db = open_metadata_db(output_path)
|
||||||
|
verbose(f"Exporting metadata to [filepath]{output_path}[/]")
|
||||||
|
num_photos = len(photos)
|
||||||
|
photo_word = pluralize(num_photos, "photo", "photos")
|
||||||
|
verbose(f"Analyzing [num]{num_photos}[/] {photo_word} to export")
|
||||||
|
verbose(f"Exporting [num]{len(photos)}[/] {photo_word} to {output_path}")
|
||||||
|
export_metadata_to_db(photos, metadata_db, progress=True)
|
||||||
|
rich_click_echo(
|
||||||
|
f"Done: exported metadata for [num]{len(photos)}[/] {photo_word} to [filepath]{output_path}[/]"
|
||||||
|
)
|
||||||
|
metadata_db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def export_metadata_to_db(
|
||||||
|
photos: list[PhotoInfo],
|
||||||
|
metadata_db: SQLiteKVStore,
|
||||||
|
progress: bool = True,
|
||||||
|
):
|
||||||
|
"""Export metadata for photos to metadata database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
photos: list of PhotoInfo objects
|
||||||
|
metadata_db: SQLiteKVStore object
|
||||||
|
progress: if True, show progress bar
|
||||||
|
"""
|
||||||
|
# it is possible to have multiple photos with the same fingerprint
|
||||||
|
# for example, the same photo was imported twice or the photo was duplicated in Photos
|
||||||
|
# in this case, we need to merge the metadata for the photos with the same fingerprint
|
||||||
|
# as there is no way to know which photo is the "correct" one
|
||||||
|
key_to_photos = {}
|
||||||
|
for photo in photos:
|
||||||
|
key = key_from_photo(photo)
|
||||||
|
if key in key_to_photos:
|
||||||
|
key_to_photos[key].append(photo)
|
||||||
|
else:
|
||||||
|
key_to_photos[key] = [photo]
|
||||||
|
|
||||||
|
with rich_progress(console=get_verbose_console(), mock=not progress) as progress:
|
||||||
|
task = progress.add_task("Exporting metadata", total=len(key_to_photos))
|
||||||
|
for key, key_photos in key_to_photos.items():
|
||||||
|
metadata_db[key] = get_photo_metadata(key_photos)
|
||||||
|
progress.advance(task)
|
||||||
|
|
||||||
|
|
||||||
|
def get_import_type(import_path: str) -> Literal["library", "export"]:
|
||||||
|
"""Determine if import_path is a Photos library, Photos database, or metadata export file"""
|
||||||
|
if pathlib.Path(import_path).is_dir():
|
||||||
|
if import_path.endswith(".photoslibrary"):
|
||||||
|
return "library"
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unable to determine type of import library: {import_path}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# import_path is a file, need to determine if it's a Photos database or metadata export file
|
||||||
|
try:
|
||||||
|
get_db_version(import_path)
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
db = SQLiteKVStore(import_path)
|
||||||
|
if db.about:
|
||||||
|
return "export"
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unable to determine type of import file: {import_path}"
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unable to determine type of import file: {import_path}"
|
||||||
|
) from e
|
||||||
|
else:
|
||||||
|
return "library"
|
||||||
|
|
||||||
|
|
||||||
|
def import_metadata(
|
||||||
|
photos: list[PhotoInfo],
|
||||||
|
import_path: str,
|
||||||
|
set_: tuple[str, ...],
|
||||||
|
merge: tuple[str, ...],
|
||||||
|
dry_run: bool,
|
||||||
|
verbose: Callable[..., None],
|
||||||
|
) -> SyncResults:
|
||||||
|
"""Import metadata from metadata_db"""
|
||||||
|
import_type = get_import_type(import_path)
|
||||||
|
photo_word = pluralize(len(photos), "photo", "photos")
|
||||||
|
verbose(
|
||||||
|
f"Importing metadata for [num]{len(photos)}[/] {photo_word} from [filepath]{import_path}[/]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# build mapping of key to photo
|
||||||
|
key_to_photo = {}
|
||||||
|
for photo in photos:
|
||||||
|
key = key_from_photo(photo)
|
||||||
|
if key in key_to_photo:
|
||||||
|
key_to_photo[key].append(photo)
|
||||||
|
else:
|
||||||
|
key_to_photo[key] = [photo]
|
||||||
|
|
||||||
|
# find keys in import_path that match keys in photos
|
||||||
|
if import_type == "library":
|
||||||
|
# create an in memory database of the import library
|
||||||
|
# so that the rest of the comparison code can be the same
|
||||||
|
photosdb = PhotosDB(import_path, verbose=verbose)
|
||||||
|
photos = photosdb.photos()
|
||||||
|
import_db = SQLiteKVStore(":memory:")
|
||||||
|
verbose(f"Loading metadata from import library: [filepath]{import_path}[/]")
|
||||||
|
export_metadata_to_db(photos, import_db, progress=False)
|
||||||
|
elif import_type == "export":
|
||||||
|
import_db = open_metadata_db(import_path)
|
||||||
|
else:
|
||||||
|
rich_echo_error(
|
||||||
|
f"Unable to determine type of import file: [filepath]{import_path}[/]"
|
||||||
|
)
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
results = SyncResults()
|
||||||
|
for key, key_photos in key_to_photo.items():
|
||||||
|
if key in import_db:
|
||||||
|
# import metadata from import_db
|
||||||
|
for photo in key_photos:
|
||||||
|
rich_click_echo(
|
||||||
|
f"Importing metadata for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
|
||||||
|
)
|
||||||
|
metadata = import_db[key]
|
||||||
|
results += import_metadata_for_photo(
|
||||||
|
photo, metadata, set_, merge, dry_run, verbose
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# unable to find metadata for photo in import_db
|
||||||
|
for photo in key_photos:
|
||||||
|
rich_click_echo(
|
||||||
|
f"Unable to find metadata for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/]) in [filepath]{import_path}[/]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# find any keys in import_db that don't match keys in photos
|
||||||
|
for key in import_db.keys():
|
||||||
|
if key not in key_to_photo:
|
||||||
|
rich_click_echo(f"Unable to find [uuid]{key}[/] in current library.")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def import_metadata_for_photo(
|
||||||
|
photo: PhotoInfo,
|
||||||
|
metadata: str,
|
||||||
|
set_: tuple[str, ...],
|
||||||
|
merge: tuple[str, ...],
|
||||||
|
dry_run: bool,
|
||||||
|
verbose: Callable[..., None],
|
||||||
|
) -> SyncResults:
|
||||||
|
"""Update metadata for photo from metadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
photo: PhotoInfo object
|
||||||
|
metadata: metadata to import (JSON string)
|
||||||
|
set_: tuple of metadata fields to set
|
||||||
|
merge: tuple of metadata fields to merge
|
||||||
|
dry_run: if True, don't actually update metadata
|
||||||
|
verbose: verbose function
|
||||||
|
"""
|
||||||
|
# convert metadata to dict
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
|
||||||
|
results = SyncResults()
|
||||||
|
if "albums" in set_ or "albums" in merge:
|
||||||
|
# behavior is the same for albums for set and merge:
|
||||||
|
# add photo to any new albums but do not remove from existing albums
|
||||||
|
results += _update_albums_for_photo(photo, metadata, dry_run, verbose)
|
||||||
|
|
||||||
|
results += _set_metadata_for_photo(photo, metadata, set_, dry_run, verbose)
|
||||||
|
results += _merge_metadata_for_photo(photo, metadata, merge, dry_run, verbose)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _update_albums_for_photo(
|
||||||
|
photo: PhotoInfo,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
dry_run: bool,
|
||||||
|
verbose: Callable[..., None],
|
||||||
|
) -> SyncResults:
|
||||||
|
"""Add photo to new albums if necessary"""
|
||||||
|
# add photo to any new albums but do not remove from existing albums
|
||||||
|
results = SyncResults()
|
||||||
|
value = sorted(metadata["albums"])
|
||||||
|
before = sorted(photo.albums)
|
||||||
|
albums_to_add = set(value) - set(before)
|
||||||
|
if not albums_to_add:
|
||||||
|
verbose(f"\tNothing to do for albums")
|
||||||
|
results.add_result(
|
||||||
|
photo.uuid,
|
||||||
|
photo.original_filename,
|
||||||
|
photo.fingerprint,
|
||||||
|
"albums",
|
||||||
|
False,
|
||||||
|
before,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
for album in albums_to_add:
|
||||||
|
verbose(f"\tAdding to album [filepath]{album}[/]")
|
||||||
|
if not dry_run:
|
||||||
|
PhotosAlbum(album, verbose=lambda x: verbose(f"\t{x}"), rich=True).add(
|
||||||
|
photo
|
||||||
|
)
|
||||||
|
results.add_result(
|
||||||
|
photo.uuid,
|
||||||
|
photo.original_filename,
|
||||||
|
photo.fingerprint,
|
||||||
|
"albums",
|
||||||
|
True,
|
||||||
|
before,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _set_metadata_for_photo(
|
||||||
|
photo: PhotoInfo,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
set_: tuple[str, ...],
|
||||||
|
dry_run: bool,
|
||||||
|
verbose: Callable[..., None],
|
||||||
|
) -> SyncResults:
|
||||||
|
"""Set metadata for photo"""
|
||||||
|
results = SyncResults()
|
||||||
|
photo_ = photoscript.Photo(photo.uuid)
|
||||||
|
|
||||||
|
for field in set_:
|
||||||
|
if field == "albums":
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = metadata[field]
|
||||||
|
before = getattr(photo, field)
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = sorted(value)
|
||||||
|
if isinstance(before, list):
|
||||||
|
before = sorted(before)
|
||||||
|
|
||||||
|
if value != before:
|
||||||
|
verbose(f"\tSetting {field} to {value} from {before}")
|
||||||
|
if not dry_run:
|
||||||
|
set_photo_property(photo_, field, value)
|
||||||
|
else:
|
||||||
|
verbose(f"\tNothing to do for {field}")
|
||||||
|
|
||||||
|
results.add_result(
|
||||||
|
photo.uuid,
|
||||||
|
photo.original_filename,
|
||||||
|
photo.fingerprint,
|
||||||
|
field,
|
||||||
|
value != before,
|
||||||
|
before,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_metadata_for_photo(
|
||||||
|
photo: PhotoInfo,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
merge: tuple[str, ...],
|
||||||
|
dry_run: bool,
|
||||||
|
verbose: Callable[..., None],
|
||||||
|
) -> SyncResults:
|
||||||
|
"""Merge metadata for photo"""
|
||||||
|
results = SyncResults()
|
||||||
|
photo_ = photoscript.Photo(photo.uuid)
|
||||||
|
|
||||||
|
for field in merge:
|
||||||
|
if field == "albums":
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = metadata[field]
|
||||||
|
before = getattr(photo, field)
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = sorted(value)
|
||||||
|
if isinstance(before, list):
|
||||||
|
before = sorted(before)
|
||||||
|
|
||||||
|
if value == before:
|
||||||
|
verbose(f"\tNothing to do for {field}")
|
||||||
|
results.add_result(
|
||||||
|
photo.uuid,
|
||||||
|
photo.original_filename,
|
||||||
|
photo.fingerprint,
|
||||||
|
field,
|
||||||
|
False,
|
||||||
|
before,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(value, list) and isinstance(before, list):
|
||||||
|
new_value = sorted(set(value + before))
|
||||||
|
elif isinstance(before, bool):
|
||||||
|
new_value = value or bool(before)
|
||||||
|
elif isinstance(before, str):
|
||||||
|
value = value or ""
|
||||||
|
new_value = f"{before} {value}" if value and value not in before else before
|
||||||
|
elif before is None:
|
||||||
|
new_value = value
|
||||||
|
else:
|
||||||
|
rich_echo_error(
|
||||||
|
f"Unable to merge {field} for [filename]{photo.original_filename}[filename]"
|
||||||
|
)
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
if new_value != before:
|
||||||
|
verbose(f"\tMerging {field} to {new_value} from {before}")
|
||||||
|
if not dry_run:
|
||||||
|
set_photo_property(photo_, field, new_value)
|
||||||
|
else:
|
||||||
|
# Merge'd value might still be the same as original value
|
||||||
|
# (e.g. if value is str and has previously been merged)
|
||||||
|
verbose(f"\tNothing to do for {field}")
|
||||||
|
|
||||||
|
results.add_result(
|
||||||
|
photo.uuid,
|
||||||
|
photo.original_filename,
|
||||||
|
photo.fingerprint,
|
||||||
|
field,
|
||||||
|
new_value != before,
|
||||||
|
before,
|
||||||
|
new_value,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def set_photo_property(photo: photoscript.Photo, property: str, value: Any):
|
||||||
|
"""Set property on photo"""
|
||||||
|
|
||||||
|
# do some basic validation
|
||||||
|
if property == "keywords" and not isinstance(value, list):
|
||||||
|
raise ValueError(f"keywords must be a list, not {type(value)}")
|
||||||
|
elif property in {"title", "description"} and not isinstance(value, str):
|
||||||
|
raise ValueError(f"{property} must be a str, not {type(value)}")
|
||||||
|
elif property == "favorite":
|
||||||
|
value = bool(value)
|
||||||
|
elif property not in {"title", "description", "favorite", "keywords"}:
|
||||||
|
raise ValueError(f"Unknown property: {property}")
|
||||||
|
setattr(photo, property, value)
|
||||||
|
|
||||||
|
|
||||||
|
def print_import_summary(results: SyncResults):
|
||||||
|
"""Print summary of import results"""
|
||||||
|
summary = results.results_summary()
|
||||||
|
property_summary = ", ".join(
|
||||||
|
f"updated {property}: [num]{summary.get(property,0)}[/]"
|
||||||
|
for property in SYNC_PROPERTIES
|
||||||
|
)
|
||||||
|
rich_click_echo(
|
||||||
|
f"Processed [num]{summary['total']}[/] photos, updated: [num]{summary['updated']}[/], {property_summary}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--export",
|
||||||
|
"-e",
|
||||||
|
"export_path",
|
||||||
|
metavar="EXPORT_FILE",
|
||||||
|
help="Export metadata to file EXPORT_FILE for later use with --import. "
|
||||||
|
"The export file will be a SQLite database; it is recommended to use the "
|
||||||
|
".db extension though this is not required.",
|
||||||
|
type=click.Path(dir_okay=False, writable=True),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--import",
|
||||||
|
"-i",
|
||||||
|
"import_path",
|
||||||
|
metavar="IMPORT_PATH",
|
||||||
|
help="Import metadata from file IMPORT_PATH. "
|
||||||
|
"IMPORT_PATH can a Photos library, a Photos database, or a metadata export file "
|
||||||
|
"created with --export.",
|
||||||
|
type=SyncImportPath(),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--set",
|
||||||
|
"-s",
|
||||||
|
"set_",
|
||||||
|
metavar="METADATA",
|
||||||
|
multiple=True,
|
||||||
|
help="When used with --import, set metadata in local Photos library to match import data. "
|
||||||
|
"Multiple metadata properties can be specified by repeating the --set option "
|
||||||
|
"or by using a comma-separated list. "
|
||||||
|
f"METADATA can be one of: {', '.join(SYNC_IMPORT_TYPES_ALL)}. "
|
||||||
|
"For example, to set keywords and favorite, use `--set keywords --set favorite` "
|
||||||
|
"or `--set keywords,favorite`. "
|
||||||
|
"If `--set all` is specified, all metadata will be set. "
|
||||||
|
"Note that using --set overwrites any existing metadata in the local Photos library. "
|
||||||
|
"For example, if a photo is marked as favorite in the local library but not in the import source, "
|
||||||
|
"--set favorite will clear the favorite status in the local library. "
|
||||||
|
"The exception to this is that `--set album` will not remove the photo "
|
||||||
|
"from any existing albums in the local library but will add the photo to any new albums specified "
|
||||||
|
"in the import source."
|
||||||
|
"See also --merge.",
|
||||||
|
type=SyncImportType(),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--merge",
|
||||||
|
"-m",
|
||||||
|
"merge",
|
||||||
|
metavar="METADATA",
|
||||||
|
multiple=True,
|
||||||
|
help="When used with --import, merge metadata in local Photos library with import data. "
|
||||||
|
"Multiple metadata properties can be specified by repeating the --merge option "
|
||||||
|
"or by using a comma-separated list. "
|
||||||
|
f"METADATA can be one of: {', '.join(SYNC_IMPORT_TYPES_ALL)}. "
|
||||||
|
"For example, to merge keywords and favorite, use `--merge keywords --merge favorite` "
|
||||||
|
"or `--merge keywords,favorite`. "
|
||||||
|
"If `--merge all` is specified, all metadata will be merged. "
|
||||||
|
"Note that using --merge does not overwrite any existing metadata in the local Photos library. "
|
||||||
|
"For example, if a photo is marked as favorite in the local library but not in the import source, "
|
||||||
|
"--merge favorite will not change the favorite status in the local library. "
|
||||||
|
"See also --set.",
|
||||||
|
type=SyncImportType(),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--report",
|
||||||
|
"-R",
|
||||||
|
metavar="REPORT_FILE",
|
||||||
|
help="Write a report of all photos that were processed with --import. "
|
||||||
|
"The extension of the report filename will be used to determine the format. "
|
||||||
|
"Valid extensions are: "
|
||||||
|
".csv (CSV file), .json (JSON), .db and .sqlite (SQLite database). "
|
||||||
|
"REPORT_FILE may be a an osxphotos template string, for example, "
|
||||||
|
"--report 'update_{today.date}.csv' will write a CSV report file named with today's date. "
|
||||||
|
"See also --append.",
|
||||||
|
type=TemplateString(),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--append",
|
||||||
|
"-A",
|
||||||
|
is_flag=True,
|
||||||
|
help="If used with --report, add data to existing report file instead of overwriting it. "
|
||||||
|
"See also --report.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--dry-run",
|
||||||
|
is_flag=True,
|
||||||
|
help="Dry run; " "when used with --import, don't actually update metadata.",
|
||||||
|
)
|
||||||
|
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
|
||||||
|
@click.option(
|
||||||
|
"--timestamp", "-T", is_flag=True, help="Add time stamp to verbose output."
|
||||||
|
)
|
||||||
|
@QUERY_OPTIONS
|
||||||
|
@DB_OPTION
|
||||||
|
@THEME_OPTION
|
||||||
|
@click.pass_obj
|
||||||
|
@click.pass_context
|
||||||
|
def sync(
|
||||||
|
ctx,
|
||||||
|
cli_obj,
|
||||||
|
db,
|
||||||
|
append,
|
||||||
|
dry_run,
|
||||||
|
export_path,
|
||||||
|
import_path,
|
||||||
|
merge,
|
||||||
|
report,
|
||||||
|
set_,
|
||||||
|
theme,
|
||||||
|
timestamp,
|
||||||
|
verbose_,
|
||||||
|
**kwargs, # query options
|
||||||
|
):
|
||||||
|
"""Sync metadata and albums between Photos libraries.
|
||||||
|
|
||||||
|
Use sync to update metadata in a local Photos library to match
|
||||||
|
metadata in another Photos library. The sync command works by
|
||||||
|
finding identical photos in the local library and the import source
|
||||||
|
and then updating the metadata in the local library to match the
|
||||||
|
metadata in the import source. Photos are considered identical if
|
||||||
|
their original filename and fingerprint match.
|
||||||
|
|
||||||
|
The import source can be a Photos library or a metadata export file
|
||||||
|
created with the --export option.
|
||||||
|
|
||||||
|
The sync command can be useful if you have imported the same photos to
|
||||||
|
multiple Photos libraries and want to keep the metadata in all libraries
|
||||||
|
in sync.
|
||||||
|
|
||||||
|
Metadata can be overwritten (--set) or merged (--merge) with the metadata
|
||||||
|
in the import source. You may specify specific metadata to sync or sync
|
||||||
|
all metadata. See --set and --merge for more details.
|
||||||
|
|
||||||
|
The sync command can be used to sync metadata between an iPhone or iPad
|
||||||
|
and a Mac, for example, in the case where you do not use iCloud but
|
||||||
|
manually import photos from your iPhone or iPad to your Mac. To do this,
|
||||||
|
you'll first need to copy the Photos database from the iPhone or iPad to
|
||||||
|
your Mac. This can be done by connecting your iPhone or iPad to your Mac
|
||||||
|
using a USB cable and then copying the Photos database from the iPhone
|
||||||
|
using a third-party tool such as iMazing (https://imazing.com/). You can
|
||||||
|
then use the sync command and set the import source to the Photos database
|
||||||
|
you copied from the iPhone or iPad.
|
||||||
|
|
||||||
|
The sync command can also be used to sync metadata between users using
|
||||||
|
iCloud Shared Photo Library. NOTE: This use case has not yet been
|
||||||
|
tested. If you use iCloud Shared Photo Library and would like to help
|
||||||
|
test this use case, please connect with me on GitHub:
|
||||||
|
https://github.com/RhetTbull/osxphotos/issues/887
|
||||||
|
|
||||||
|
You can run the --export and --import commands together. In this case,
|
||||||
|
the import will be run first and then the export will be run.
|
||||||
|
|
||||||
|
For example, if you want to sync two Photos libraries between users or
|
||||||
|
two different computers, you can export the metadata to a shared folder.
|
||||||
|
|
||||||
|
On the first computer, run:
|
||||||
|
|
||||||
|
osxphotos sync --export /path/to/export/folder/computer1.db --merge all --import /path/to/export/folder/computer2.db
|
||||||
|
|
||||||
|
On the second computer, run:
|
||||||
|
|
||||||
|
osxphotos sync --export /path/to/export/folder/computer2.db --merge all --import /path/to/export/folder/computer1.db
|
||||||
|
|
||||||
|
"""
|
||||||
|
color_theme = get_theme(theme)
|
||||||
|
verbose = verbose_print(
|
||||||
|
verbose_, timestamp, rich=True, theme=color_theme, highlight=False
|
||||||
|
)
|
||||||
|
# set console for rich_echo to be same as for verbose_
|
||||||
|
set_rich_console(get_verbose_console())
|
||||||
|
set_rich_theme(color_theme)
|
||||||
|
set_rich_timestamp(timestamp)
|
||||||
|
|
||||||
|
if (set_ or merge) and not import_path:
|
||||||
|
rich_echo_error("--set and --merge can only be used with --import")
|
||||||
|
ctx.exit(1)
|
||||||
|
|
||||||
|
set_ = parse_set_merge(set_)
|
||||||
|
merge = parse_set_merge(merge)
|
||||||
|
|
||||||
|
if "all" in set_:
|
||||||
|
set_ = tuple(SYNC_IMPORT_TYPES)
|
||||||
|
if "all" in merge:
|
||||||
|
merge = tuple(SYNC_IMPORT_TYPES)
|
||||||
|
|
||||||
|
if set_ and merge:
|
||||||
|
# fields in set cannot be in merge and vice versa
|
||||||
|
set_ = set(set_)
|
||||||
|
merge = set(merge)
|
||||||
|
if set_ & merge:
|
||||||
|
rich_echo_error(
|
||||||
|
"--set and --merge cannot be used with the same fields: "
|
||||||
|
f"set: {set_}, merge: {merge}"
|
||||||
|
)
|
||||||
|
ctx.exit(1)
|
||||||
|
|
||||||
|
if import_path:
|
||||||
|
query_options = query_options_from_kwargs(**kwargs)
|
||||||
|
photosdb = PhotosDB(dbfile=db, verbose=verbose)
|
||||||
|
photos = photosdb.query(query_options)
|
||||||
|
results = import_metadata(photos, import_path, set_, merge, dry_run, verbose)
|
||||||
|
if report:
|
||||||
|
report_path = render_and_validate_report(report)
|
||||||
|
verbose(f"Writing report to {report_path}")
|
||||||
|
report_writer = sync_report_writer_factory(report_path, append=append)
|
||||||
|
report_writer.write(results)
|
||||||
|
report_writer.close()
|
||||||
|
print_import_summary(results)
|
||||||
|
|
||||||
|
if export_path:
|
||||||
|
photosdb = PhotosDB(dbfile=db, verbose=verbose)
|
||||||
|
query_options = query_options_from_kwargs(**kwargs)
|
||||||
|
photos = photosdb.query(query_options)
|
||||||
|
export_metadata(photos, export_path, verbose)
|
||||||
175
osxphotos/cli/sync_results.py
Normal file
175
osxphotos/cli/sync_results.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""SyncResults class for osxphotos sync command"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from osxphotos.photoinfo import PhotoInfo
|
||||||
|
|
||||||
|
SYNC_PROPERTIES = [
|
||||||
|
"albums",
|
||||||
|
"description",
|
||||||
|
"favorite",
|
||||||
|
"keywords",
|
||||||
|
"title",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SyncResults:
|
||||||
|
"""Results of sync set/merge"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._results = {}
|
||||||
|
self._datetime = datetime.datetime.now()
|
||||||
|
|
||||||
|
def add_result(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
filename: str,
|
||||||
|
fingerprint: str,
|
||||||
|
property: str,
|
||||||
|
updated: bool,
|
||||||
|
before: str | list[str] | bool | None,
|
||||||
|
after: str | list[str] | bool | None,
|
||||||
|
):
|
||||||
|
"""Add result for a single photo"""
|
||||||
|
if uuid not in self._results:
|
||||||
|
self._results[uuid] = {
|
||||||
|
"filename": filename,
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"properties": {
|
||||||
|
property: {
|
||||||
|
"updated": updated,
|
||||||
|
"datetime": datetime.datetime.now().isoformat(),
|
||||||
|
"before": before,
|
||||||
|
"after": after,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
self._results[uuid]["properties"][property] = {
|
||||||
|
"updated": updated,
|
||||||
|
"datetime": datetime.datetime.now().isoformat(),
|
||||||
|
"before": before,
|
||||||
|
"after": after,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def results(self):
|
||||||
|
"""Return results"""
|
||||||
|
return self._results
|
||||||
|
|
||||||
|
@property
|
||||||
|
def results_list(self):
|
||||||
|
"""Return results as list lists where each sublist is values for a single photo"""
|
||||||
|
results = []
|
||||||
|
for uuid, record in self._results.items():
|
||||||
|
row = [
|
||||||
|
uuid,
|
||||||
|
record["filename"],
|
||||||
|
record["fingerprint"],
|
||||||
|
self._any_updated(uuid),
|
||||||
|
]
|
||||||
|
for property in SYNC_PROPERTIES:
|
||||||
|
if property in record["properties"]:
|
||||||
|
row.extend(
|
||||||
|
record["properties"][property][column]
|
||||||
|
for column in ["updated", "datetime", "before", "after"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.extend([False, "", "", ""])
|
||||||
|
results.append(row)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@property
|
||||||
|
def results_header(self):
|
||||||
|
"""Return headers for results_list"""
|
||||||
|
header = ["uuid", "filename", "fingerprint", "updated"]
|
||||||
|
for property in SYNC_PROPERTIES:
|
||||||
|
header.extend(
|
||||||
|
f"{property}_{column}"
|
||||||
|
for column in ["updated", "datetime", "before", "after"]
|
||||||
|
)
|
||||||
|
return header
|
||||||
|
|
||||||
|
@property
|
||||||
|
def results_dict(self):
|
||||||
|
"""Return dictionary of results"""
|
||||||
|
results = {}
|
||||||
|
for uuid, record in self._results.items():
|
||||||
|
results[uuid] = {
|
||||||
|
"uuid": uuid,
|
||||||
|
"filename": record["filename"],
|
||||||
|
"fingerprint": record["fingerprint"],
|
||||||
|
"updated": self._any_updated(uuid),
|
||||||
|
}
|
||||||
|
for property in SYNC_PROPERTIES:
|
||||||
|
if property in record["properties"]:
|
||||||
|
results[uuid][property] = record["properties"][property]
|
||||||
|
else:
|
||||||
|
results[uuid][property] = {
|
||||||
|
"updated": False,
|
||||||
|
"datetime": None,
|
||||||
|
"before": None,
|
||||||
|
"after": None,
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
|
||||||
|
def results_summary(self):
|
||||||
|
"""Get summary of results"""
|
||||||
|
updated = sum(bool(self._any_updated(uuid)) for uuid in self._results.keys())
|
||||||
|
property_updated = {}
|
||||||
|
for property in SYNC_PROPERTIES:
|
||||||
|
property_updated[property] = 0
|
||||||
|
for uuid in self._results.keys():
|
||||||
|
if self._results[uuid]["properties"].get(property, {"updated": False})[
|
||||||
|
"updated"
|
||||||
|
]:
|
||||||
|
property_updated[property] += 1
|
||||||
|
return {
|
||||||
|
"total": len(self._results),
|
||||||
|
"updated": updated,
|
||||||
|
} | property_updated
|
||||||
|
|
||||||
|
def _any_updated(self, uuid: str) -> bool:
|
||||||
|
"""Return True if any property was updated for this photo"""
|
||||||
|
return any(
|
||||||
|
self._results[uuid]["properties"].get(property, {"updated": False})[
|
||||||
|
"updated"
|
||||||
|
]
|
||||||
|
for property in SYNC_PROPERTIES
|
||||||
|
)
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
"""Add results from another SyncResults"""
|
||||||
|
for uuid in other._results.keys():
|
||||||
|
for property, values in other._results[uuid]["properties"].items():
|
||||||
|
self.add_result(
|
||||||
|
uuid,
|
||||||
|
other._results[uuid]["filename"],
|
||||||
|
other._results[uuid]["fingerprint"],
|
||||||
|
property,
|
||||||
|
values["updated"],
|
||||||
|
values["before"],
|
||||||
|
values["after"],
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __iadd__(self, other):
|
||||||
|
"""Add results from another SyncResults"""
|
||||||
|
for uuid in other._results.keys():
|
||||||
|
for property, values in other._results[uuid]["properties"].items():
|
||||||
|
self.add_result(
|
||||||
|
uuid,
|
||||||
|
other._results[uuid]["filename"],
|
||||||
|
other._results[uuid]["fingerprint"],
|
||||||
|
property,
|
||||||
|
values["updated"],
|
||||||
|
values["before"],
|
||||||
|
values["after"],
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return json.dumps(self._results, indent=2)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import plistlib
|
import plistlib
|
||||||
|
import sys
|
||||||
|
|
||||||
from .._constants import (
|
from .._constants import (
|
||||||
_PHOTOS_2_VERSION,
|
_PHOTOS_2_VERSION,
|
||||||
@@ -62,7 +63,7 @@ def get_db_version(db_file):
|
|||||||
if version not in _TESTED_DB_VERSIONS:
|
if version not in _TESTED_DB_VERSIONS:
|
||||||
print(
|
print(
|
||||||
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
|
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
|
||||||
+ f" You have database version={version} which has not been tested"
|
+ f" You have database version={version} which has not been tested", file=sys.stderr
|
||||||
)
|
)
|
||||||
|
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ from .test_catalina_10_15_7 import UUID_DICT_LOCAL
|
|||||||
# run timewarp tests (configured with --timewarp)
|
# run timewarp tests (configured with --timewarp)
|
||||||
TEST_TIMEWARP = False
|
TEST_TIMEWARP = False
|
||||||
|
|
||||||
# run import tests (configured with --import)
|
# run import tests (configured with --test-import)
|
||||||
TEST_IMPORT = False
|
TEST_IMPORT = False
|
||||||
|
|
||||||
|
# run sync tests (configured with --test-sync)
|
||||||
|
TEST_SYNC = False
|
||||||
|
|
||||||
# don't clean up crash logs (configured with --no-cleanup)
|
# don't clean up crash logs (configured with --no-cleanup)
|
||||||
NO_CLEANUP = False
|
NO_CLEANUP = False
|
||||||
|
|
||||||
@@ -46,10 +49,12 @@ OS_VER = get_os_version()[1]
|
|||||||
if OS_VER == "15":
|
if OS_VER == "15":
|
||||||
TEST_LIBRARY = "tests/Test-10.15.7.photoslibrary"
|
TEST_LIBRARY = "tests/Test-10.15.7.photoslibrary"
|
||||||
TEST_LIBRARY_IMPORT = TEST_LIBRARY
|
TEST_LIBRARY_IMPORT = TEST_LIBRARY
|
||||||
|
TEST_LIBRARY_SYNC = TEST_LIBRARY
|
||||||
from tests.config_timewarp_catalina import TEST_LIBRARY_TIMEWARP
|
from tests.config_timewarp_catalina import TEST_LIBRARY_TIMEWARP
|
||||||
else:
|
else:
|
||||||
TEST_LIBRARY = None
|
TEST_LIBRARY = None
|
||||||
TEST_LIBRARY_TIMEWARP = None
|
TEST_LIBRARY_TIMEWARP = None
|
||||||
|
TEST_LIBRARY_SYNC = None
|
||||||
# pytest.exit("This test suite currently only runs on MacOS Catalina ")
|
# pytest.exit("This test suite currently only runs on MacOS Catalina ")
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +72,13 @@ def setup_photos_import():
|
|||||||
copy_photos_library(TEST_LIBRARY_IMPORT, delay=10)
|
copy_photos_library(TEST_LIBRARY_IMPORT, delay=10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def setup_photos_sync():
|
||||||
|
if not TEST_SYNC:
|
||||||
|
return
|
||||||
|
copy_photos_library(TEST_LIBRARY_SYNC, delay=10)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_singletons():
|
def reset_singletons():
|
||||||
"""Need to clean up any ExifTool singletons between tests"""
|
"""Need to clean up any ExifTool singletons between tests"""
|
||||||
@@ -89,6 +101,12 @@ def pytest_addoption(parser):
|
|||||||
default=False,
|
default=False,
|
||||||
help="run `osxphotos import` tests",
|
help="run `osxphotos import` tests",
|
||||||
)
|
)
|
||||||
|
parser.addoption(
|
||||||
|
"--test-sync",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="run `osxphotos sync` tests",
|
||||||
|
)
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
"--no-cleanup",
|
"--no-cleanup",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -105,11 +123,14 @@ def pytest_configure(config):
|
|||||||
config.getoption("--addalbum"),
|
config.getoption("--addalbum"),
|
||||||
config.getoption("--timewarp"),
|
config.getoption("--timewarp"),
|
||||||
config.getoption("--test-import"),
|
config.getoption("--test-import"),
|
||||||
|
config.getoption("--test-sync"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
> 1
|
> 1
|
||||||
):
|
):
|
||||||
pytest.exit("--addalbum, --timewarp, --test-import are mutually exclusive")
|
pytest.exit(
|
||||||
|
"--addalbum, --timewarp, --test-import, --test-sync are mutually exclusive"
|
||||||
|
)
|
||||||
|
|
||||||
config.addinivalue_line(
|
config.addinivalue_line(
|
||||||
"markers", "addalbum: mark test as requiring --addalbum to run"
|
"markers", "addalbum: mark test as requiring --addalbum to run"
|
||||||
@@ -120,6 +141,9 @@ def pytest_configure(config):
|
|||||||
config.addinivalue_line(
|
config.addinivalue_line(
|
||||||
"markers", "test_import: mark test as requiring --test-import to run"
|
"markers", "test_import: mark test as requiring --test-import to run"
|
||||||
)
|
)
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "test_sync: mark test as requiring --test-sync to run"
|
||||||
|
)
|
||||||
|
|
||||||
# this is hacky but I can't figure out how to check config options in other fixtures
|
# this is hacky but I can't figure out how to check config options in other fixtures
|
||||||
if config.getoption("--timewarp"):
|
if config.getoption("--timewarp"):
|
||||||
@@ -130,6 +154,10 @@ def pytest_configure(config):
|
|||||||
global TEST_IMPORT
|
global TEST_IMPORT
|
||||||
TEST_IMPORT = True
|
TEST_IMPORT = True
|
||||||
|
|
||||||
|
if config.getoption("--test-sync"):
|
||||||
|
global TEST_SYNC
|
||||||
|
TEST_SYNC = True
|
||||||
|
|
||||||
if config.getoption("--no-cleanup"):
|
if config.getoption("--no-cleanup"):
|
||||||
global NO_CLEANUP
|
global NO_CLEANUP
|
||||||
NO_CLEANUP = True
|
NO_CLEANUP = True
|
||||||
@@ -160,6 +188,14 @@ def pytest_collection_modifyitems(config, items):
|
|||||||
if "test_import" in item.keywords:
|
if "test_import" in item.keywords:
|
||||||
item.add_marker(skip_test_import)
|
item.add_marker(skip_test_import)
|
||||||
|
|
||||||
|
if not (config.getoption("--test-sync") and TEST_LIBRARY_SYNC is not None):
|
||||||
|
skip_test_sync = pytest.mark.skip(
|
||||||
|
reason="need --test-sync option and MacOS Catalina to run"
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
if "test_sync" in item.keywords:
|
||||||
|
item.add_marker(skip_test_sync)
|
||||||
|
|
||||||
|
|
||||||
def copy_photos_library(photos_library, delay=0):
|
def copy_photos_library(photos_library, delay=0):
|
||||||
"""copy the test library and open Photos, returns path to copied library"""
|
"""copy the test library and open Photos, returns path to copied library"""
|
||||||
|
|||||||
108
tests/test_cli_sync.py
Normal file
108
tests/test_cli_sync.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Test osxphotos sync command"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import photoscript
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from osxphotos.cli.sync import sync
|
||||||
|
|
||||||
|
UUID_TEST_PHOTO_1 = "D79B8D77-BFFC-460B-9312-034F2877D35B" # Pumkins2.jpg
|
||||||
|
UUID_TEST_PHOTO_2 = "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" # wedding.jpg
|
||||||
|
|
||||||
|
TEST_ALBUM_NAME = "SyncTestAlbum"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.test_sync
|
||||||
|
def test_sync_export():
|
||||||
|
"""Test --export"""
|
||||||
|
with CliRunner().isolated_filesystem():
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
sync,
|
||||||
|
[
|
||||||
|
"--export",
|
||||||
|
"test.db",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert os.path.exists("test.db")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.test_sync
|
||||||
|
def test_sync_export_import():
|
||||||
|
"""Test --export and --import"""
|
||||||
|
|
||||||
|
photoslib = photoscript.PhotosLibrary()
|
||||||
|
|
||||||
|
# create a new album and initialize metadata
|
||||||
|
test_album = photoslib.create_album(TEST_ALBUM_NAME)
|
||||||
|
for uuid in [UUID_TEST_PHOTO_1, UUID_TEST_PHOTO_2]:
|
||||||
|
photo = photoscript.Photo(uuid)
|
||||||
|
photo.favorite = True
|
||||||
|
test_album.add([photo])
|
||||||
|
|
||||||
|
# export data
|
||||||
|
with CliRunner().isolated_filesystem():
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
sync,
|
||||||
|
[
|
||||||
|
"--export",
|
||||||
|
"test.db",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
# preserve metadata for comparison and clear metadata
|
||||||
|
metadata_before = {}
|
||||||
|
for uuid in [UUID_TEST_PHOTO_1, UUID_TEST_PHOTO_2]:
|
||||||
|
photo = photoscript.Photo(uuid)
|
||||||
|
metadata_before[uuid] = {
|
||||||
|
"title": photo.title,
|
||||||
|
"description": photo.description,
|
||||||
|
"keywords": photo.keywords,
|
||||||
|
"favorites": photo.favorite,
|
||||||
|
}
|
||||||
|
photo.title = ""
|
||||||
|
photo.description = ""
|
||||||
|
photo.keywords = ["NewKeyword"]
|
||||||
|
photo.favorite = False
|
||||||
|
|
||||||
|
# delete the test album
|
||||||
|
photoslib.delete_album(test_album)
|
||||||
|
|
||||||
|
# import metadata
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
sync,
|
||||||
|
[
|
||||||
|
"--import",
|
||||||
|
"test.db",
|
||||||
|
"--set",
|
||||||
|
"title,description,favorite,albums",
|
||||||
|
"--merge",
|
||||||
|
"keywords",
|
||||||
|
"--report",
|
||||||
|
"test_report.json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert os.path.exists("test_report.json")
|
||||||
|
|
||||||
|
# check metadata
|
||||||
|
for uuid in [UUID_TEST_PHOTO_1, UUID_TEST_PHOTO_2]:
|
||||||
|
photo = photoscript.Photo(uuid)
|
||||||
|
assert photo.title == metadata_before[uuid]["title"]
|
||||||
|
assert photo.description == metadata_before[uuid]["description"]
|
||||||
|
assert sorted(photo.keywords) == sorted(
|
||||||
|
["NewKeyword", *metadata_before[uuid]["keywords"]]
|
||||||
|
)
|
||||||
|
assert photo.favorite == metadata_before[uuid]["favorites"]
|
||||||
|
assert TEST_ALBUM_NAME in [album.title for album in photo.albums]
|
||||||
|
|
||||||
|
# check report
|
||||||
|
with open("test_report.json", "r") as f:
|
||||||
|
report = json.load(f)
|
||||||
|
report_data = {record["uuid"]: record for record in report}
|
||||||
|
for uuid in [UUID_TEST_PHOTO_1, UUID_TEST_PHOTO_2]:
|
||||||
|
assert report_data[uuid]["updated"]
|
||||||
|
assert report_data[uuid]["albums"]["updated"]
|
||||||
Reference in New Issue
Block a user