238 lines
5.7 KiB
Python
238 lines
5.7 KiB
Python
import argparse
|
|
import datetime
|
|
import os
|
|
import sys
|
|
from importlib.metadata import PackageNotFoundError, version
|
|
from typing import Any
|
|
|
|
from emulsion.config import ConfigLoader
|
|
from emulsion.executor import Executor
|
|
from emulsion.resolver import ValueResolver
|
|
|
|
|
|
def get_version() -> str:
|
|
try:
|
|
return version('emulsion')
|
|
except PackageNotFoundError:
|
|
return 'unknown'
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description='A tool for updating exif tags'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'files', nargs='*', help='Image files to process (e.g. *.jpg *.tif).'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-v', '--version', action='version', version=f'%(prog)s {get_version()}'
|
|
)
|
|
|
|
# --- First-Class Fields ---
|
|
parser.add_argument('--author', help='Name of the photographer.')
|
|
parser.add_argument('--lab', help='Name of the lab who developed the film.')
|
|
parser.add_argument('--make', help='Camera make.')
|
|
parser.add_argument('--model', help='Camera model.')
|
|
parser.add_argument('--lens', help='Lens model.')
|
|
parser.add_argument('--film', help='Film stock.')
|
|
|
|
# --- Generic/Custom Fields ---
|
|
parser.add_argument(
|
|
'--field',
|
|
action='append',
|
|
dest='custom_fields',
|
|
metavar='KEY=VALUE',
|
|
help=(
|
|
'Set a custom field defined in config (e.g., '
|
|
'--field location="Paris").'
|
|
),
|
|
)
|
|
|
|
# --- Process Control ---
|
|
parser.add_argument(
|
|
'--base-date',
|
|
default=None,
|
|
help=(
|
|
'Base date or date/time (e.g. 2023-04-10 or 2023-04-10 12:00:00).'
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--time-increment',
|
|
type=int,
|
|
default=None,
|
|
help='Time increment in seconds between images.',
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--embed',
|
|
action='store_true',
|
|
help=(
|
|
'Embed EXIF data directly into the image file instead of a sidecar.'
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--dry-run',
|
|
action='store_true',
|
|
help='Show what would be changed without modifying files.',
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-j',
|
|
'--workers',
|
|
type=int,
|
|
nargs='?',
|
|
const=os.cpu_count() or 1,
|
|
default=os.cpu_count() or 1,
|
|
help=(
|
|
'Number of parallel workers to run exiftool; defaults to number '
|
|
'of CPUs.'
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--init-config',
|
|
action='store_true',
|
|
help='Create a default config file (if none exists) and exit.',
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--no-interaction',
|
|
action='store_true',
|
|
help='Do not prompt for missing fields (skip them if missing).',
|
|
)
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def prompt_for_defaults(config: dict[str, Any]) -> None:
|
|
"""
|
|
Prompts the user for default values to populate the initial config.
|
|
"""
|
|
print('Initializing configuration. Press Enter to skip any field.')
|
|
|
|
# We'll iterate over the 'mappings' to find what fields are available,
|
|
# but we'll prioritize the 'core' ones for a better UX order.
|
|
core_fields = ['author', 'lab', 'make', 'model', 'lens', 'film']
|
|
mappings = config.get('mappings', {})
|
|
defaults = config.setdefault('defaults', {})
|
|
|
|
# Prompt for core fields first
|
|
for field in core_fields:
|
|
if field in mappings:
|
|
schema = mappings[field]
|
|
help_text = (
|
|
schema.get('help', field) if isinstance(schema, dict) else field
|
|
)
|
|
val = input(f'Default {help_text} (optional): ').strip()
|
|
if val:
|
|
defaults[field] = val
|
|
|
|
# Time increment
|
|
dflt_inc = defaults.get('time_increment', 60)
|
|
val = input(f'Default Time Increment [seconds] ({dflt_inc}): ').strip()
|
|
if val:
|
|
try:
|
|
defaults['time_increment'] = int(val)
|
|
except ValueError:
|
|
print('Invalid number, keeping default.')
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
# 1. Load Config
|
|
loader = ConfigLoader()
|
|
config = loader.load()
|
|
|
|
# 2. Parse CLI
|
|
args = parse_args()
|
|
|
|
# Handle Initialization
|
|
if args.init_config:
|
|
if loader.path.exists():
|
|
print(
|
|
f'Config file already exists at {loader.path}. Not '
|
|
'overwriting.'
|
|
)
|
|
sys.exit(0)
|
|
|
|
# Prompt user for initial values
|
|
try:
|
|
prompt_for_defaults(config)
|
|
except KeyboardInterrupt:
|
|
print('\nAborted.')
|
|
sys.exit(1)
|
|
|
|
if loader.save_defaults(config):
|
|
print(f'Created config file at {loader.path}')
|
|
else:
|
|
# Should be caught by the check above, but for safety
|
|
print('Config file already exists. Not overwriting.')
|
|
sys.exit(0)
|
|
|
|
if not args.files:
|
|
print('No files provided.')
|
|
sys.exit(0)
|
|
|
|
# Handle Base Date Prompt logic
|
|
if not args.base_date and not args.no_interaction:
|
|
dflt = datetime.datetime.now().strftime('%Y-%m-%d')
|
|
resp = input(f'Base date/time for first image [{dflt}]: ').strip()
|
|
args.base_date = resp if resp else dflt
|
|
|
|
if not args.base_date:
|
|
print('Error: Base date is required.')
|
|
sys.exit(1)
|
|
|
|
# 3. Prepare Inputs for Resolver
|
|
# We need to mash --author and --field author=... into one dict
|
|
user_inputs: dict[str, Any] = {}
|
|
|
|
# First-Class args
|
|
for field in ['author', 'lab', 'make', 'model', 'lens', 'film']:
|
|
val = getattr(args, field, None)
|
|
if val:
|
|
user_inputs[field] = val
|
|
|
|
# Custom args
|
|
if args.custom_fields:
|
|
for item in args.custom_fields:
|
|
if '=' in item:
|
|
key, val = item.split('=', 1)
|
|
user_inputs[key.strip()] = val.strip()
|
|
else:
|
|
print(
|
|
f"Warning: Invalid format for --field '{item}'. "
|
|
'Expected KEY=VALUE.'
|
|
)
|
|
|
|
# 4. Resolve Metadata
|
|
resolver = ValueResolver(config)
|
|
resolved_values = resolver.resolve(
|
|
user_inputs, interactive=not args.no_interaction
|
|
)
|
|
|
|
# 5. Execute
|
|
executor = Executor(config)
|
|
|
|
# We pass 'args' as the options object (has dry_run, workers, etc)
|
|
# Just need to make sure time_increment is resolved from config defaults
|
|
# if missing
|
|
if args.time_increment is None:
|
|
args.time_increment = config.get('defaults', {}).get(
|
|
'time_increment', 60
|
|
)
|
|
|
|
executor.run_batch(args.files, resolved_values, args)
|
|
|
|
except KeyboardInterrupt:
|
|
print('\nInterrupted by user. Exiting.')
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|