@thenavidm/apple-photos-mcp-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ """Changing the library.
2
+
3
+ Reading uses SQLite directly. Writing cannot: Photos owns the database while it
4
+ is running, and editing it underneath the app corrupts state. Every write here
5
+ therefore goes through Photos.app itself via ``photoscript`` (AppleScript), which
6
+ is the only supported way to change a library.
7
+
8
+ Three rules hold everywhere in this module:
9
+
10
+ * **Nothing is ever deleted.** Apple does not expose scripted permanent deletion
11
+ to any app, and this server does not try to work around that. "Delete" moves
12
+ items into an archive album for a human to empty.
13
+ * **Batches are bounded and explicit.** No wildcards, no "everything matching".
14
+ A caller names the items it means, up to a configured maximum.
15
+ * **Writes work by default.** Organizing a library is the point of the tool.
16
+ `APPLE_PHOTOS_READ_ONLY=1` removes the write tools from the list entirely.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ from .config import Config
26
+ from .library import Asset, PhotosLibrary
27
+ from .safety import AuditLog
28
+
29
+
30
+ class WritesDisabled(RuntimeError):
31
+ """Raised if a write is somehow reached while read-only mode is on.
32
+
33
+ In practice the tools are never registered in that mode, so this is a
34
+ backstop rather than the user-facing behavior.
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__(
39
+ "This server is running in read-only mode because APPLE_PHOTOS_READ_ONLY "
40
+ "is set. Remove it and restart the client to allow changes."
41
+ )
42
+
43
+
44
+ class TooManyItems(ValueError):
45
+ pass
46
+
47
+
48
+ @dataclass
49
+ class WriteResult:
50
+ changed: list[str]
51
+ skipped: list[dict[str, str]]
52
+
53
+ def as_dict(self, action: str, **extra: Any) -> dict[str, Any]:
54
+ out: dict[str, Any] = {
55
+ "action": action,
56
+ "changed": len(self.changed),
57
+ "uuids": self.changed,
58
+ }
59
+ if self.skipped:
60
+ out["skipped"] = self.skipped
61
+ out.update(extra)
62
+ return out
63
+
64
+
65
+ class Writer:
66
+ def __init__(self, config: Config, lib: PhotosLibrary):
67
+ self.config = config
68
+ self.lib = lib
69
+ self.audit = AuditLog(config)
70
+
71
+ # ------------------------------------------------------------- guardrails
72
+
73
+ def _guard(self, refs: list[str]) -> list[Asset]:
74
+ if self.config.read_only:
75
+ self.audit.record(
76
+ "blocked", allowed=False, summary=f"{len(refs)} item(s), read-only mode"
77
+ )
78
+ raise WritesDisabled()
79
+ if len(refs) > self.config.write_batch_max:
80
+ raise TooManyItems(
81
+ f"{len(refs)} items requested; this server allows at most "
82
+ f"{self.config.write_batch_max} per call. Split the work into batches."
83
+ )
84
+ return []
85
+
86
+ def _resolve(self, refs: list[str]) -> tuple[list[Asset], list[dict[str, str]]]:
87
+ found: list[Asset] = []
88
+ missing: list[dict[str, str]] = []
89
+ for ref in refs:
90
+ asset = self.lib.get(ref)
91
+ if asset:
92
+ found.append(asset)
93
+ else:
94
+ missing.append({"ref": ref, "reason": "not found in this library"})
95
+ return found, missing
96
+
97
+ def _photoslib(self):
98
+ import photoscript
99
+
100
+ return photoscript.PhotosLibrary()
101
+
102
+ def _apply(
103
+ self,
104
+ refs: list[str],
105
+ action: Callable[[Any, Asset], None],
106
+ ) -> WriteResult:
107
+ self._guard(refs)
108
+ assets, skipped = self._resolve(refs)
109
+ if not assets:
110
+ return WriteResult([], skipped)
111
+
112
+ import photoscript
113
+
114
+ # Instantiating the library launches Photos.app if it is not running,
115
+ # which every AppleScript write below depends on. The handle itself is
116
+ # not needed: writes address photos by uuid.
117
+ self._photoslib()
118
+ changed: list[str] = []
119
+ for asset in assets:
120
+ try:
121
+ photo = photoscript.Photo(asset.uuid)
122
+ action(photo, asset)
123
+ changed.append(asset.uuid)
124
+ except Exception as exc:
125
+ skipped.append({"ref": asset.uuid, "reason": str(exc)})
126
+ if changed:
127
+ # The cached index is now stale for these assets.
128
+ self.lib.load(force=True)
129
+ return WriteResult(changed, skipped)
130
+
131
+ # ------------------------------------------------------------------ tools
132
+
133
+ def set_favorite(self, refs: list[str], favorite: bool = True) -> dict[str, Any]:
134
+ res = self._apply(refs, lambda photo, _a: setattr(photo, "favorite", favorite))
135
+ action = "favorite" if favorite else "unfavorite"
136
+ self.audit.record(action, allowed=True, summary=f"{len(res.changed)} item(s)")
137
+ return res.as_dict(action, favorite=favorite)
138
+
139
+ def set_title(self, ref: str, title: str) -> dict[str, Any]:
140
+ res = self._apply([ref], lambda photo, _a: setattr(photo, "title", title))
141
+ return res.as_dict("set_title", title=title)
142
+
143
+ def set_description(self, ref: str, description: str) -> dict[str, Any]:
144
+ res = self._apply([ref], lambda photo, _a: setattr(photo, "description", description))
145
+ return res.as_dict("set_description", description=description)
146
+
147
+ def add_keywords(self, refs: list[str], keywords: list[str]) -> dict[str, Any]:
148
+ def action(photo: Any, _a: Asset) -> None:
149
+ existing = list(photo.keywords or [])
150
+ photo.keywords = existing + [k for k in keywords if k not in existing]
151
+
152
+ res = self._apply(refs, action)
153
+ return res.as_dict("add_keywords", keywords=keywords)
154
+
155
+ def add_to_album(self, album: str, refs: list[str]) -> dict[str, Any]:
156
+ self._guard(refs)
157
+
158
+ # Resolve before touching Photos.app. Launching Photos is a visible,
159
+ # slow side effect, and doing it for a call that turns out to have
160
+ # nothing to add is both surprising and a good way to make the test
161
+ # suite depend on a running Photos.
162
+ assets, skipped = self._resolve(refs)
163
+ if not assets and refs:
164
+ return WriteResult([], skipped).as_dict("add_to_album", album=album)
165
+
166
+ import photoscript
167
+
168
+ pl = self._photoslib()
169
+ target = None
170
+ for existing in pl.albums():
171
+ if existing.name == album:
172
+ target = existing
173
+ break
174
+ created = target is None
175
+ if target is None:
176
+ target = pl.create_album(album)
177
+
178
+ changed: list[str] = []
179
+ for asset in assets:
180
+ try:
181
+ target.add([photoscript.Photo(asset.uuid)])
182
+ changed.append(asset.uuid)
183
+ except Exception as exc:
184
+ skipped.append({"ref": asset.uuid, "reason": str(exc)})
185
+ if changed:
186
+ self.lib.load(force=True)
187
+ return WriteResult(changed, skipped).as_dict(
188
+ "add_to_album", album=album, album_created=created
189
+ )
190
+
191
+ def archive(self, refs: list[str], confirm: bool = False) -> dict[str, Any]:
192
+ """The closest thing to delete that macOS permits a script to do.
193
+
194
+ This is the only tool here that asks. Everything else it can do is one
195
+ click to undo in Photos, and confirming every write teaches a model to
196
+ pass `confirm` reflexively, which is exactly what must not happen on the
197
+ one action a user reads as deletion.
198
+ """
199
+ if not confirm:
200
+ self.audit.record(
201
+ "archive", allowed=False, summary=f"{len(refs)} item(s), unconfirmed"
202
+ )
203
+ return {
204
+ "action": "archive",
205
+ "confirmed": False,
206
+ "would_archive": len(refs),
207
+ "album": self.config.archive_album,
208
+ "message": (
209
+ f"Not done yet. This moves {len(refs)} item(s) out of the main view and "
210
+ f"into the album \"{self.config.archive_album}\". Call again with "
211
+ f"confirm=true to proceed."
212
+ ),
213
+ }
214
+ out = self.add_to_album(self.config.archive_album, refs)
215
+ out["action"] = "archive"
216
+ out["confirmed"] = True
217
+ self.audit.record(
218
+ "archive", allowed=True, summary=f"{out.get('changed', 0)} item(s) archived"
219
+ )
220
+ out["note"] = (
221
+ f"macOS does not allow any app to delete photos by script. These were moved "
222
+ f"into the album \"{self.config.archive_album}\" instead. To remove them for "
223
+ f"real, open that album in Photos and delete them there."
224
+ )
225
+ return out