@pired/lightroom-mcp 3.1.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/LightroomMCP.lrplugin/HandlerAI.lua +466 -0
  4. package/dist/LightroomMCP.lrplugin/HandlerAIMasks.lua +549 -0
  5. package/dist/LightroomMCP.lrplugin/HandlerCatalog.lua +256 -0
  6. package/dist/LightroomMCP.lrplugin/HandlerCollections.lua +337 -0
  7. package/dist/LightroomMCP.lrplugin/HandlerDevelop.lua +1668 -0
  8. package/dist/LightroomMCP.lrplugin/HandlerExport.lua +149 -0
  9. package/dist/LightroomMCP.lrplugin/HandlerImport.lua +108 -0
  10. package/dist/LightroomMCP.lrplugin/HandlerLocalAdjustments.lua +330 -0
  11. package/dist/LightroomMCP.lrplugin/HandlerMetadata.lua +169 -0
  12. package/dist/LightroomMCP.lrplugin/HandlerOrganization.lua +783 -0
  13. package/dist/LightroomMCP.lrplugin/HandlerPreview.lua +189 -0
  14. package/dist/LightroomMCP.lrplugin/HandlerSearch.lua +185 -0
  15. package/dist/LightroomMCP.lrplugin/HandlerSelection.lua +302 -0
  16. package/dist/LightroomMCP.lrplugin/HandlerSpots.lua +313 -0
  17. package/dist/LightroomMCP.lrplugin/HandlerWatermark.lua +59 -0
  18. package/dist/LightroomMCP.lrplugin/Info.lua +25 -0
  19. package/dist/LightroomMCP.lrplugin/JSON.lua +270 -0
  20. package/dist/LightroomMCP.lrplugin/Log.lua +92 -0
  21. package/dist/LightroomMCP.lrplugin/MenuShowStatus.lua +22 -0
  22. package/dist/LightroomMCP.lrplugin/PhotoLookup.lua +39 -0
  23. package/dist/LightroomMCP.lrplugin/PluginInfoProvider.lua +865 -0
  24. package/dist/LightroomMCP.lrplugin/PluginInit.lua +48 -0
  25. package/dist/cli.d.ts +6 -0
  26. package/dist/cli.d.ts.map +1 -0
  27. package/dist/cli.js +31 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/configs/claude-code.mcp.json +9 -0
  30. package/dist/configs/claude-desktop.json +12 -0
  31. package/dist/configs/generic-stdio-client.json +10 -0
  32. package/dist/configs/mcp-proxy-http.json +9 -0
  33. package/dist/create-server.d.ts +12 -0
  34. package/dist/create-server.d.ts.map +1 -0
  35. package/dist/create-server.js +17 -0
  36. package/dist/create-server.js.map +1 -0
  37. package/dist/deepseek-harness/README.md +112 -0
  38. package/dist/deepseek-harness/deepseek-mcp-harness.mjs +292 -0
  39. package/dist/dispatcher.d.ts +34 -0
  40. package/dist/dispatcher.d.ts.map +1 -0
  41. package/dist/dispatcher.js +72 -0
  42. package/dist/dispatcher.js.map +1 -0
  43. package/dist/heartbeat.d.ts +17 -0
  44. package/dist/heartbeat.d.ts.map +1 -0
  45. package/dist/heartbeat.js +36 -0
  46. package/dist/heartbeat.js.map +1 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +255 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/install-plugin.d.ts +16 -0
  52. package/dist/install-plugin.d.ts.map +1 -0
  53. package/dist/install-plugin.js +125 -0
  54. package/dist/install-plugin.js.map +1 -0
  55. package/dist/instance-lock.d.ts +5 -0
  56. package/dist/instance-lock.d.ts.map +1 -0
  57. package/dist/instance-lock.js +77 -0
  58. package/dist/instance-lock.js.map +1 -0
  59. package/dist/list-tools-handler.d.ts +16 -0
  60. package/dist/list-tools-handler.d.ts.map +1 -0
  61. package/dist/list-tools-handler.js +50 -0
  62. package/dist/list-tools-handler.js.map +1 -0
  63. package/dist/plugin-liveness.d.ts +48 -0
  64. package/dist/plugin-liveness.d.ts.map +1 -0
  65. package/dist/plugin-liveness.js +75 -0
  66. package/dist/plugin-liveness.js.map +1 -0
  67. package/dist/plugin-socket.d.ts +32 -0
  68. package/dist/plugin-socket.d.ts.map +1 -0
  69. package/dist/plugin-socket.js +97 -0
  70. package/dist/plugin-socket.js.map +1 -0
  71. package/dist/ports.d.ts +3 -0
  72. package/dist/ports.d.ts.map +1 -0
  73. package/dist/ports.js +22 -0
  74. package/dist/ports.js.map +1 -0
  75. package/dist/token.d.ts +3 -0
  76. package/dist/token.d.ts.map +1 -0
  77. package/dist/token.js +23 -0
  78. package/dist/token.js.map +1 -0
  79. package/dist/tool-contracts.d.ts +13 -0
  80. package/dist/tool-contracts.d.ts.map +1 -0
  81. package/dist/tool-contracts.js +1422 -0
  82. package/dist/tool-contracts.js.map +1 -0
  83. package/dist/tool-handler.d.ts +23 -0
  84. package/dist/tool-handler.d.ts.map +1 -0
  85. package/dist/tool-handler.js +96 -0
  86. package/dist/tool-handler.js.map +1 -0
  87. package/dist/validate-args.d.ts +7 -0
  88. package/dist/validate-args.d.ts.map +1 -0
  89. package/dist/validate-args.js +37 -0
  90. package/dist/validate-args.js.map +1 -0
  91. package/dist/version.d.ts +2 -0
  92. package/dist/version.d.ts.map +1 -0
  93. package/dist/version.js +4 -0
  94. package/dist/version.js.map +1 -0
  95. package/dist/wait-until.d.ts +7 -0
  96. package/dist/wait-until.d.ts.map +1 -0
  97. package/dist/wait-until.js +15 -0
  98. package/dist/wait-until.js.map +1 -0
  99. package/package.json +73 -0
@@ -0,0 +1,783 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrSelection = import 'LrSelection'
3
+
4
+ local PhotoLookup = require 'PhotoLookup'
5
+ local Log = require 'Log'
6
+
7
+ local OrganizationHandler = {}
8
+ local MAX_KEYWORDS_PER_REQUEST = 1000
9
+
10
+ local function validateKeywordLimit(keywords, fieldName)
11
+ if keywords and #keywords > MAX_KEYWORDS_PER_REQUEST then
12
+ error(fieldName .. " must contain at most " .. MAX_KEYWORDS_PER_REQUEST .. " keywords")
13
+ end
14
+ end
15
+
16
+ function OrganizationHandler.setKeywords(args)
17
+ if not args.photo_ids or #args.photo_ids == 0 then
18
+ error("photo_ids is required")
19
+ end
20
+ validateKeywordLimit(args.add_keywords, "add_keywords")
21
+ validateKeywordLimit(args.remove_keywords, "remove_keywords")
22
+
23
+ -- Neither list means there is nothing to do; reporting "Updated keywords
24
+ -- for 1 photos" for that claimed work that never happened.
25
+ local hasAdds = args.add_keywords ~= nil and args.add_keywords[1] ~= nil
26
+ local hasRemoves = args.remove_keywords ~= nil and args.remove_keywords[1] ~= nil
27
+ if not hasAdds and not hasRemoves then
28
+ error("add_keywords or remove_keywords is required")
29
+ end
30
+
31
+ local catalog = LrApplication.activeCatalog()
32
+ local updatedCount = 0
33
+
34
+ local addKeywordNames = {}
35
+ local addSet = {}
36
+ if args.add_keywords then
37
+ for _, kw in ipairs(args.add_keywords) do
38
+ if not addSet[kw] then
39
+ addSet[kw] = true
40
+ table.insert(addKeywordNames, kw)
41
+ end
42
+ end
43
+ end
44
+
45
+ local removeSet = {}
46
+ if args.remove_keywords then
47
+ for _, kw in ipairs(args.remove_keywords) do
48
+ removeSet[kw] = true
49
+ end
50
+ end
51
+
52
+ catalog:withWriteAccessDo("Set Keywords", function()
53
+ -- createKeyword is not idempotent within one write transaction.
54
+ local keywordObjs = {}
55
+ for _, kw in ipairs(addKeywordNames) do
56
+ table.insert(keywordObjs, catalog:createKeyword(kw, {}, true, nil, true))
57
+ end
58
+
59
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
60
+ for _, entry in ipairs(resolved) do
61
+ local photo = entry.photo
62
+ if photo then
63
+ for _, kwObj in ipairs(keywordObjs) do
64
+ photo:addKeyword(kwObj)
65
+ end
66
+
67
+ if next(removeSet) then
68
+ local existingKeywords = photo:getRawMetadata('keywords')
69
+ if existingKeywords then
70
+ for _, kw in ipairs(existingKeywords) do
71
+ if removeSet[kw:getName()] then
72
+ photo:removeKeyword(kw)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ updatedCount = updatedCount + 1
79
+ end
80
+ end
81
+ end)
82
+
83
+ Log.info(string.format("Updated keywords for %d photos", updatedCount))
84
+
85
+ return {
86
+ success = true,
87
+ updated = updatedCount,
88
+ message = string.format("Updated keywords for %d photos", updatedCount)
89
+ }
90
+ end
91
+
92
+ function OrganizationHandler.setRating(args)
93
+ if not args.photo_ids or #args.photo_ids == 0 then
94
+ error("photo_ids is required")
95
+ end
96
+
97
+ if not args.rating then
98
+ error("rating is required")
99
+ end
100
+
101
+ -- Comparing a string to a number raised a raw Lua type error that leaked
102
+ -- the handler's file and line to the client.
103
+ if type(args.rating) ~= "number" then
104
+ error("rating must be a number between 0 and 5")
105
+ end
106
+
107
+ if args.rating < 0 or args.rating > 5 then
108
+ error("rating must be between 0 and 5")
109
+ end
110
+
111
+ local catalog = LrApplication.activeCatalog()
112
+ local updatedCount = 0
113
+ local missingIds = {}
114
+ local missingCount = 0
115
+
116
+ -- LrSDK rejects literal 0 on the rating field; nil means "no rating".
117
+ local ratingValue = args.rating
118
+ if ratingValue == 0 then ratingValue = nil end
119
+
120
+ catalog:withWriteAccessDo("Set Rating", function()
121
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
122
+ for _, entry in ipairs(resolved) do
123
+ if entry.photo then
124
+ entry.photo:setRawMetadata('rating', ratingValue)
125
+ updatedCount = updatedCount + 1
126
+ else
127
+ missingCount = missingCount + 1
128
+ missingIds[missingCount] = tostring(entry.id)
129
+ end
130
+ end
131
+ end)
132
+
133
+ Log.info(string.format("Set rating to %d for %d photos", args.rating, updatedCount))
134
+
135
+ return {
136
+ success = true,
137
+ updated = updatedCount,
138
+ rating = args.rating,
139
+ missing = missingIds,
140
+ message = string.format("Set rating to %d for %d photos (%d ids not found)",
141
+ args.rating, updatedCount, missingCount)
142
+ }
143
+ end
144
+
145
+ function OrganizationHandler.setFlags(args)
146
+ if not args.photo_ids or #args.photo_ids == 0 then
147
+ error("photo_ids is required")
148
+ end
149
+
150
+ local flag = args.flag
151
+ if flag ~= "pick" and flag ~= "reject" and flag ~= "none" then
152
+ error("flag must be 'pick', 'reject' or 'none'")
153
+ end
154
+
155
+ local catalog = LrApplication.activeCatalog()
156
+
157
+ -- LrSelection commands behave like the main-menu commands: they act on
158
+ -- the UI selection (grid view: all selected; loupe: active photo only).
159
+ -- We therefore select the target photos first, run the command, then
160
+ -- verify per photo via pickStatus. Yields to the UI thread, so the
161
+ -- selection must stay OUTSIDE any catalog access gate (issues #134/#124).
162
+ local photos = {}
163
+ catalog:withReadAccessDo(function()
164
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
165
+ for _, entry in ipairs(resolved) do
166
+ if entry.photo then
167
+ table.insert(photos, entry.photo)
168
+ end
169
+ end
170
+ end)
171
+
172
+ if #photos == 0 then
173
+ error("No photos matched photo_ids")
174
+ end
175
+
176
+ local command = (flag == "pick" and LrSelection.flagAsPick)
177
+ or (flag == "reject" and LrSelection.flagAsReject)
178
+ or LrSelection.removeFlag
179
+
180
+ local function pickValueOf(photo)
181
+ return photo:getRawMetadata('pickStatus')
182
+ end
183
+
184
+ local function applyCommandToSelection(target)
185
+ catalog:setSelectedPhotos(target[1], target)
186
+ command()
187
+ end
188
+
189
+ local function flagMatches(photo)
190
+ local status = pickValueOf(photo)
191
+ if flag == "pick" then return status == 1 end
192
+ if flag == "reject" then return status == -1 end
193
+ return status == 0 or status == nil
194
+ end
195
+
196
+ -- First pass: whole batch as one selection (fast path; works when the
197
+ -- photos are visible in the current view source, e.g. All Photographs).
198
+ pcall(applyCommandToSelection, photos)
199
+
200
+ -- Verify and retry photo-by-photo where the batch command did not take
201
+ -- (hidden photos, view-source mismatch, loupe view applying to the
202
+ -- active photo only).
203
+ local updated = 0
204
+ local retried = 0
205
+ local failed = {}
206
+ for _, photo in ipairs(photos) do
207
+ if not flagMatches(photo) then
208
+ retried = retried + 1
209
+ pcall(applyCommandToSelection, { photo })
210
+ end
211
+ if flagMatches(photo) then
212
+ updated = updated + 1
213
+ else
214
+ table.insert(failed, tostring(photo.localIdentifier))
215
+ end
216
+ end
217
+
218
+ Log.info(string.format("Set flag '%s' on %d/%d photos (%d single retries)",
219
+ flag, updated, #photos, retried))
220
+
221
+ local result = {
222
+ success = #failed == 0,
223
+ updated = updated,
224
+ requested = #photos,
225
+ single_retries = retried,
226
+ missing = {},
227
+ message = string.format("Set flag '%s' on %d of %d photos", flag, updated, #photos),
228
+ }
229
+ if #failed > 0 then
230
+ result.missing = failed
231
+ result.message = result.message
232
+ .. ". Photos not updated: " .. table.concat(failed, ", ")
233
+ .. ". Switch Lightroom to a source containing them (e.g. All Photographs) and retry."
234
+ end
235
+ return result
236
+ end
237
+
238
+ -- =====================================================================
239
+ -- Color labels
240
+ -- =====================================================================
241
+ --
242
+ -- Labels are stored as plain text via setRawMetadata('label', ...). The
243
+ -- five standard UI colors map to their capitalized names; matching against
244
+ -- the catalog's label set is done by Lightroom itself on read-back (a
245
+ -- custom label set can rename them, hence the case-insensitive compare).
246
+
247
+ local COLOR_LABELS = {
248
+ red = "Red",
249
+ yellow = "Yellow",
250
+ green = "Green",
251
+ blue = "Blue",
252
+ purple = "Purple",
253
+ }
254
+
255
+ function OrganizationHandler.setColorLabel(args)
256
+ if not args.photo_ids or #args.photo_ids == 0 then
257
+ error("photo_ids is required")
258
+ end
259
+
260
+ local label = args.label
261
+ if label == nil then
262
+ error("label is required")
263
+ end
264
+
265
+ local labelValue = nil
266
+ if label ~= "none" then
267
+ labelValue = COLOR_LABELS[label]
268
+ if not labelValue then
269
+ error("label must be one of: red, yellow, green, blue, purple, none")
270
+ end
271
+ end
272
+
273
+ local catalog = LrApplication.activeCatalog()
274
+
275
+ catalog:withWriteAccessDo("Set Color Label", function()
276
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
277
+ for _, entry in ipairs(resolved) do
278
+ if entry.photo then
279
+ -- nil clears the label, exactly like the rating handler.
280
+ entry.photo:setRawMetadata('label', labelValue)
281
+ end
282
+ end
283
+ end)
284
+
285
+ -- Verify per photo by reading back: 'none' expects an empty label, the
286
+ -- colors compare case-insensitively to tolerate label-set variations.
287
+ local updatedCount = 0
288
+ local mismatchIds = {}
289
+ local mismatchCount = 0
290
+
291
+ catalog:withReadAccessDo(function()
292
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
293
+ for _, entry in ipairs(resolved) do
294
+ local photo = entry.photo
295
+ if photo then
296
+ local current = photo:getRawMetadata('label')
297
+ local matches
298
+ if label == "none" then
299
+ matches = current == nil or current == ""
300
+ else
301
+ matches = type(current) == "string"
302
+ and current:lower() == labelValue:lower()
303
+ end
304
+ if matches then
305
+ updatedCount = updatedCount + 1
306
+ else
307
+ mismatchCount = mismatchCount + 1
308
+ table.insert(mismatchIds, tostring(photo.localIdentifier))
309
+ end
310
+ end
311
+ end
312
+ end)
313
+
314
+ Log.info(string.format("Set color label '%s' on %d photos", label, updatedCount))
315
+
316
+ local result = {
317
+ success = mismatchCount == 0,
318
+ label = label,
319
+ updated = updatedCount,
320
+ mismatching = mismatchIds,
321
+ message = string.format("Set color label '%s' on %d photos (%d mismatched after write)",
322
+ label, updatedCount, mismatchCount),
323
+ }
324
+ if mismatchCount > 0 then
325
+ result.message = result.message
326
+ .. ". Photos not verified: " .. table.concat(mismatchIds, ", ")
327
+ end
328
+ return result
329
+ end
330
+
331
+ -- =====================================================================
332
+ -- Virtual copies
333
+ -- =====================================================================
334
+ --
335
+ -- photo:createVirtualCopy() inside a write gate returns the new LrPhoto.
336
+ -- The copies are stacked with their source and share its develop settings.
337
+
338
+ function OrganizationHandler.createVirtualCopies(args)
339
+ if not args.photo_ids or #args.photo_ids == 0 then
340
+ error("photo_ids is required")
341
+ end
342
+
343
+ local count = args.count or 1
344
+ if type(count) ~= "number" or count ~= math.floor(count) or count < 1 or count > 20 then
345
+ error("count must be an integer between 1 and 20")
346
+ end
347
+
348
+ local catalog = LrApplication.activeCatalog()
349
+ local created = {}
350
+
351
+ catalog:withWriteAccessDo("Create Virtual Copies", function()
352
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
353
+ for _, entry in ipairs(resolved) do
354
+ local photo = entry.photo
355
+ if photo then
356
+ for _ = 1, count do
357
+ local newPhoto = photo:createVirtualCopy()
358
+ if newPhoto then
359
+ table.insert(created, {
360
+ source_id = photo.localIdentifier,
361
+ id = newPhoto.localIdentifier,
362
+ path = newPhoto:getRawMetadata('path'),
363
+ filename = newPhoto:getFormattedMetadata('fileName'),
364
+ })
365
+ end
366
+ end
367
+ end
368
+ end
369
+ end)
370
+
371
+ if #created == 0 then
372
+ error("No virtual copies were created (check that photo_ids matched photos)")
373
+ end
374
+
375
+ Log.info(string.format("Created %d virtual copies", #created))
376
+
377
+ return {
378
+ success = true,
379
+ created = created,
380
+ count = #created,
381
+ message = string.format("Created %d virtual cop%s",
382
+ #created, #created == 1 and "y" or "ies"),
383
+ }
384
+ end
385
+
386
+ -- =====================================================================
387
+ -- Smart collections
388
+ -- =====================================================================
389
+ --
390
+ -- catalog:createSmartCollection(name, criteria, targetSet, position) uses
391
+ -- the same searchDesc shape as findPhotos: a combine field plus an array
392
+ -- of { criteria, operation, value, value2? } rules. Common criteria:
393
+ -- keywords, rating, filename, captureTime, copyName, cameraModel, lens,
394
+ -- isoSpeedRating, hasAdjustments, text; operations: all/any (containment),
395
+ -- ==/</>/>=/in (comparisons), startsWith/endsWith.
396
+
397
+ local MAX_SMART_COLLECTION_RULES = 20
398
+
399
+ function OrganizationHandler.createSmartCollection(args)
400
+ local name = args.name
401
+ if type(name) ~= "string" or name == "" then
402
+ error("name is required")
403
+ end
404
+ if #name > 255 then
405
+ error("name must be at most 255 characters")
406
+ end
407
+
408
+ local rules = args.rules
409
+ if type(rules) ~= "table" or #rules == 0 then
410
+ error("rules is required (at least one)")
411
+ end
412
+ if #rules > MAX_SMART_COLLECTION_RULES then
413
+ error("rules must contain at most " .. MAX_SMART_COLLECTION_RULES .. " entries")
414
+ end
415
+
416
+ local combine = args.combine or "intersect"
417
+ if combine ~= "intersect" and combine ~= "union" then
418
+ error("combine must be 'intersect' (AND, default) or 'union' (OR)")
419
+ end
420
+
421
+ local searchDesc = { combine = combine }
422
+ for i, rule in ipairs(rules) do
423
+ if type(rule) ~= "table" then
424
+ error("rules[" .. i .. "] must be an object")
425
+ end
426
+ if type(rule.criteria) ~= "string" or rule.criteria == "" then
427
+ error("rules[" .. i .. "].criteria is required (e.g. 'keywords', 'rating', 'filename', 'captureTime')")
428
+ end
429
+ if type(rule.operation) ~= "string" or rule.operation == "" then
430
+ error("rules[" .. i .. "].operation is required (e.g. 'all', 'any', '==', '>=', 'in', 'startsWith')")
431
+ end
432
+ if rule.value == nil then
433
+ error("rules[" .. i .. "].value is required")
434
+ end
435
+ local r = {
436
+ criteria = rule.criteria,
437
+ operation = rule.operation,
438
+ value = tostring(rule.value),
439
+ }
440
+ if rule.value2 ~= nil then r.value2 = tostring(rule.value2) end
441
+ table.insert(searchDesc, r)
442
+ end
443
+
444
+ local catalog = LrApplication.activeCatalog()
445
+ local createdCollection = nil
446
+ local createError = nil
447
+
448
+ catalog:withWriteAccessDo("Create Smart Collection", function()
449
+ local ok, result = pcall(function()
450
+ return catalog:createSmartCollection(name, searchDesc, nil, true)
451
+ end)
452
+ if ok then
453
+ createdCollection = result
454
+ else
455
+ createError = result
456
+ end
457
+ end)
458
+
459
+ if createError ~= nil then
460
+ error("createSmartCollection failed: " .. tostring(createError))
461
+ end
462
+ if not createdCollection then
463
+ error("createSmartCollection returned no collection (duplicate name?)")
464
+ end
465
+
466
+ -- Read back the name through the returned collection object.
467
+ local verifiedName = nil
468
+ pcall(function() verifiedName = createdCollection:getName() end)
469
+
470
+ Log.info(string.format("Created smart collection '%s' (%d rule(s))",
471
+ name, #rules))
472
+
473
+ local result = {
474
+ success = true,
475
+ name = name,
476
+ verified_name = verifiedName,
477
+ rule_count = #rules,
478
+ combine = combine,
479
+ message = string.format("Created smart collection '%s' with %d rule(s)",
480
+ name, #rules),
481
+ }
482
+ if verifiedName == nil then
483
+ result.warning = "Collection created but its name could not be read back."
484
+ end
485
+ return result
486
+ end
487
+
488
+ -- =====================================================================
489
+ -- get_photo_status — flag, rating and color label per photo
490
+ -- =====================================================================
491
+ --
492
+ -- Read-only counterpart of set_flags/set_rating/set_color_label: one
493
+ -- round-trip returns all three status fields for a batch. Ported from
494
+ -- lightroom-cli getFlag/getRating/getColorLabel (three commands there,
495
+ -- one here). pickStatus maps to flag names; label keeps the raw stored
496
+ -- text (custom label sets can rename colors, cf. setColorLabel).
497
+
498
+ local FLAG_NAMES = {
499
+ [1] = "pick",
500
+ [-1] = "reject",
501
+ [0] = "none",
502
+ }
503
+
504
+ function OrganizationHandler.getPhotoStatus(args)
505
+ if not args.photo_ids or #args.photo_ids == 0 then
506
+ error("photo_ids is required")
507
+ end
508
+
509
+ local catalog = LrApplication.activeCatalog()
510
+
511
+ local results = {}
512
+ local missing = {}
513
+
514
+ catalog:withReadAccessDo(function()
515
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
516
+ for _, entry in ipairs(resolved) do
517
+ local photo = entry.photo
518
+ if photo then
519
+ local pickStatus = photo:getRawMetadata('pickStatus')
520
+ table.insert(results, {
521
+ id = photo.localIdentifier,
522
+ path = photo:getRawMetadata('path'),
523
+ filename = photo:getFormattedMetadata('fileName'),
524
+ flag = FLAG_NAMES[pickStatus] or ("unknown (" .. tostring(pickStatus) .. ")"),
525
+ pick_status = pickStatus,
526
+ rating = photo:getRawMetadata('rating'),
527
+ color_label = photo:getRawMetadata('label'),
528
+ })
529
+ else
530
+ table.insert(missing, tostring(entry.id))
531
+ end
532
+ end
533
+ end)
534
+
535
+ if #results == 0 then
536
+ error("No photos matched photo_ids")
537
+ end
538
+
539
+ Log.info(string.format("getPhotoStatus: %d photo(s)", #results))
540
+
541
+ return {
542
+ success = true,
543
+ photos = results,
544
+ count = #results,
545
+ missing = missing,
546
+ message = string.format("Read status for %d photo(s) (%d ids not found)",
547
+ #results, #missing),
548
+ }
549
+ end
550
+
551
+ -- =====================================================================
552
+ -- batch_metadata — IPTC text fields for many photos at once
553
+ -- =====================================================================
554
+ --
555
+ -- setRawMetadata over a whitelisted set of writable IPTC string fields
556
+ -- (title, caption, headline, location, city, stateProvince, country,
557
+ -- creator, copyright), then a read-back verification pass — nil clears a
558
+ -- field exactly like the rating/label handlers. Ported from lightroom-cli
559
+ -- batch-metadata/set-metadata.
560
+
561
+ local BATCH_METADATA_FIELDS = {
562
+ title = true,
563
+ caption = true,
564
+ headline = true,
565
+ location = true,
566
+ city = true,
567
+ stateProvince = true,
568
+ country = true,
569
+ isoCountryCode = true,
570
+ creator = true,
571
+ copyright = true,
572
+ }
573
+
574
+ function OrganizationHandler.batchMetadata(args)
575
+ if not args.photo_ids or #args.photo_ids == 0 then
576
+ error("photo_ids is required")
577
+ end
578
+
579
+ local metadata = args.metadata
580
+ if type(metadata) ~= "table" then
581
+ error("metadata object is required")
582
+ end
583
+
584
+ local fields = {}
585
+ for key, _ in pairs(metadata) do
586
+ if not BATCH_METADATA_FIELDS[key] then
587
+ error("metadata field '" .. tostring(key)
588
+ .. "' is not supported (allowed: title, caption, headline, location, city, "
589
+ .. "stateProvince, country, isoCountryCode, creator, copyright)")
590
+ end
591
+ if metadata[key] ~= nil and type(metadata[key]) ~= "string" then
592
+ error("metadata field '" .. tostring(key) .. "' must be a string or null")
593
+ end
594
+ table.insert(fields, key)
595
+ end
596
+
597
+ if #fields == 0 then
598
+ error("metadata must contain at least one field")
599
+ end
600
+ table.sort(fields)
601
+
602
+ local catalog = LrApplication.activeCatalog()
603
+
604
+ catalog:withWriteAccessDo("Batch Metadata", function()
605
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
606
+ for _, entry in ipairs(resolved) do
607
+ if entry.photo then
608
+ for _, key in ipairs(fields) do
609
+ entry.photo:setRawMetadata(key, metadata[key])
610
+ end
611
+ end
612
+ end
613
+ end)
614
+
615
+ -- Verify by reading every field back; a mismatch is reported honestly
616
+ -- rather than failing the whole batch (per-photo, like setColorLabel).
617
+ local updatedCount = 0
618
+ local mismatches = {}
619
+
620
+ catalog:withReadAccessDo(function()
621
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
622
+ for _, entry in ipairs(resolved) do
623
+ local photo = entry.photo
624
+ if photo then
625
+ local badFields = {}
626
+ for _, key in ipairs(fields) do
627
+ local current = photo:getRawMetadata(key)
628
+ local expected = metadata[key]
629
+ local matches
630
+ if expected == nil then
631
+ matches = current == nil or current == ""
632
+ else
633
+ matches = current == expected
634
+ end
635
+ if not matches then
636
+ table.insert(badFields, key)
637
+ end
638
+ end
639
+ if #badFields == 0 then
640
+ updatedCount = updatedCount + 1
641
+ else
642
+ table.insert(mismatches, {
643
+ id = photo.localIdentifier,
644
+ fields = badFields,
645
+ })
646
+ end
647
+ end
648
+ end
649
+ end)
650
+
651
+ Log.info(string.format("batchMetadata: %d photo(s) verified", updatedCount))
652
+
653
+ local result = {
654
+ success = #mismatches == 0,
655
+ updated = updatedCount,
656
+ fields = fields,
657
+ mismatching = mismatches,
658
+ message = string.format("Set %d metadata field(s) on %d photo(s) (%d mismatched after write)",
659
+ #fields, updatedCount, #mismatches),
660
+ }
661
+ if #mismatches > 0 then
662
+ result.message = result.message
663
+ .. ". Check the 'mismatching' entries — the write may be pending or the field unsupported."
664
+ end
665
+ return result
666
+ end
667
+
668
+ -- =====================================================================
669
+ -- rotate_photo — 90-degree rotation, batch
670
+ -- =====================================================================
671
+ --
672
+ -- photo:rotateLeft()/rotateRight() inside one write gate; the rotation
673
+ -- is applied to the stored orientation, so previews/exports pick it up.
674
+ -- Ported from lightroom-cli rotateLeft/rotateRight (one tool here).
675
+
676
+ function OrganizationHandler.rotatePhoto(args)
677
+ if not args.photo_ids or #args.photo_ids == 0 then
678
+ error("photo_ids is required")
679
+ end
680
+
681
+ local direction = args.direction or "right"
682
+ if direction ~= "left" and direction ~= "right" then
683
+ error("direction must be 'left' or 'right'")
684
+ end
685
+
686
+ local catalog = LrApplication.activeCatalog()
687
+
688
+ local updatedCount = 0
689
+ local missing = {}
690
+
691
+ catalog:withWriteAccessDo("Rotate Photos", function()
692
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
693
+ for _, entry in ipairs(resolved) do
694
+ if entry.photo then
695
+ if direction == "left" then
696
+ entry.photo:rotateLeft()
697
+ else
698
+ entry.photo:rotateRight()
699
+ end
700
+ updatedCount = updatedCount + 1
701
+ else
702
+ table.insert(missing, tostring(entry.id))
703
+ end
704
+ end
705
+ end)
706
+
707
+ if updatedCount == 0 then
708
+ error("No photos matched photo_ids")
709
+ end
710
+
711
+ Log.info(string.format("Rotated %d photo(s) %s", updatedCount, direction))
712
+
713
+ return {
714
+ success = true,
715
+ updated = updatedCount,
716
+ direction = direction,
717
+ missing = missing,
718
+ message = string.format("Rotated %d photo(s) %s (%d ids not found)",
719
+ updatedCount, direction, #missing),
720
+ }
721
+ end
722
+
723
+ -- =====================================================================
724
+ -- remove_from_catalog — destructive, confirm-gated
725
+ -- =====================================================================
726
+ --
727
+ -- catalog:removePhoto(photo) removes the photo from the catalog (files on
728
+ -- disk are NOT deleted). Destructive and hard to undo, so the tool
729
+ -- contract demands confirm=true and the handler re-checks it: an
730
+ -- accidental call errors instead of removing anything. Verification: the
731
+ -- ids must no longer resolve afterwards.
732
+
733
+ function OrganizationHandler.removeFromCatalog(args)
734
+ if not args.photo_ids or #args.photo_ids == 0 then
735
+ error("photo_ids is required")
736
+ end
737
+
738
+ if args.confirm ~= true then
739
+ error("remove_from_catalog is destructive: pass confirm=true to proceed "
740
+ .. "(files on disk are kept; only the catalog entries are removed)")
741
+ end
742
+
743
+ local catalog = LrApplication.activeCatalog()
744
+
745
+ catalog:withWriteAccessDo("Remove From Catalog", function()
746
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
747
+ for _, entry in ipairs(resolved) do
748
+ if entry.photo then
749
+ catalog:removePhoto(entry.photo)
750
+ end
751
+ end
752
+ end)
753
+
754
+ -- Verify: every id that resolved before must now be gone.
755
+ local stillPresent = {}
756
+ catalog:withReadAccessDo(function()
757
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
758
+ for _, entry in ipairs(resolved) do
759
+ if entry.photo then
760
+ table.insert(stillPresent, tostring(entry.id))
761
+ end
762
+ end
763
+ end)
764
+
765
+ Log.info(string.format("removeFromCatalog: %d removed, %d still present",
766
+ #args.photo_ids - #stillPresent, #stillPresent))
767
+
768
+ local result = {
769
+ success = #stillPresent == 0,
770
+ requested = #args.photo_ids,
771
+ still_present = stillPresent,
772
+ message = string.format("Removed %d photo(s) from the catalog",
773
+ #args.photo_ids - #stillPresent),
774
+ }
775
+ if #stillPresent > 0 then
776
+ result.warning = "Some photos are still in the catalog: "
777
+ .. table.concat(stillPresent, ", ")
778
+ .. ". Re-run for those ids."
779
+ end
780
+ return result
781
+ end
782
+
783
+ return OrganizationHandler