"""exiftool adapter: read and apply EXIF keywords. Only the ``Keywords`` and ``Subject`` fields are read or written, so every other tag (caption, dates, GPS, camera, ratings) is preserved. Reading is batched over stdin; writing uses the idempotent ``-=``/``+=`` pattern so re-running never duplicates a keyword. Extracted from nsfwtag.exif (_read_keywords / write_keyword / remove_keyword) and photo_analyzer's batched keyword read (donor_ledger.yaml: nt-exif-marks, nt-apply-list, pa-nsfw-filter). No dependency on the archived entry points. """ from __future__ import annotations import json import os import subprocess from collections.abc import Iterable def read_keyword_sets(paths: Iterable[str]) -> dict[str, set[str]]: """Map each path to its lowercased set of ``Keywords`` + ``Subject`` values. One batched exiftool call (paths fed via stdin). Returns ``{}`` if exiftool is unavailable — callers decide how to fail (analysis fails open, safety fails safe). """ paths = list(paths) if not paths: return {} want = {os.path.normpath(p): p for p in paths} try: result = subprocess.run( ["exiftool", "-m", "-j", "-Keywords", "-Subject", "-@", "-"], input="\n".join(paths), capture_output=True, text=True, ) except FileNotFoundError: return {} out: dict[str, set[str]] = {} try: records = json.loads(result.stdout or "[]") except ValueError: return {} for record in records: values: list = [] for field in ("Keywords", "Subject"): value = record.get(field) if isinstance(value, list): values += value elif value is not None: values.append(value) key = want.get(os.path.normpath(record.get("SourceFile", ""))) if key is not None: out[key] = {str(v).strip().lower() for v in values} return out def apply_keywords(path: str, *, add: Iterable[str] = (), remove: Iterable[str] = ()) -> bool: """Idempotently add/remove keywords in Keywords + Subject; preserve all else.""" args = ["exiftool", "-m", "-overwrite_original"] for kw in remove: args += [f"-Keywords-={kw}", f"-Subject-={kw}"] for kw in add: # remove-then-add makes the add idempotent (no duplicate on re-run). args += [f"-Keywords-={kw}", f"-Keywords+={kw}", f"-Subject-={kw}", f"-Subject+={kw}"] if len(args) == 3: return True args.append(path) return subprocess.run(args, capture_output=True, text=True).returncode == 0