@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/LightroomMCP.lrplugin/HandlerAI.lua +466 -0
- package/dist/LightroomMCP.lrplugin/HandlerAIMasks.lua +549 -0
- package/dist/LightroomMCP.lrplugin/HandlerCatalog.lua +256 -0
- package/dist/LightroomMCP.lrplugin/HandlerCollections.lua +337 -0
- package/dist/LightroomMCP.lrplugin/HandlerDevelop.lua +1668 -0
- package/dist/LightroomMCP.lrplugin/HandlerExport.lua +149 -0
- package/dist/LightroomMCP.lrplugin/HandlerImport.lua +108 -0
- package/dist/LightroomMCP.lrplugin/HandlerLocalAdjustments.lua +330 -0
- package/dist/LightroomMCP.lrplugin/HandlerMetadata.lua +169 -0
- package/dist/LightroomMCP.lrplugin/HandlerOrganization.lua +783 -0
- package/dist/LightroomMCP.lrplugin/HandlerPreview.lua +189 -0
- package/dist/LightroomMCP.lrplugin/HandlerSearch.lua +185 -0
- package/dist/LightroomMCP.lrplugin/HandlerSelection.lua +302 -0
- package/dist/LightroomMCP.lrplugin/HandlerSpots.lua +313 -0
- package/dist/LightroomMCP.lrplugin/HandlerWatermark.lua +59 -0
- package/dist/LightroomMCP.lrplugin/Info.lua +25 -0
- package/dist/LightroomMCP.lrplugin/JSON.lua +270 -0
- package/dist/LightroomMCP.lrplugin/Log.lua +92 -0
- package/dist/LightroomMCP.lrplugin/MenuShowStatus.lua +22 -0
- package/dist/LightroomMCP.lrplugin/PhotoLookup.lua +39 -0
- package/dist/LightroomMCP.lrplugin/PluginInfoProvider.lua +865 -0
- package/dist/LightroomMCP.lrplugin/PluginInit.lua +48 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +31 -0
- package/dist/cli.js.map +1 -0
- package/dist/configs/claude-code.mcp.json +9 -0
- package/dist/configs/claude-desktop.json +12 -0
- package/dist/configs/generic-stdio-client.json +10 -0
- package/dist/configs/mcp-proxy-http.json +9 -0
- package/dist/create-server.d.ts +12 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +17 -0
- package/dist/create-server.js.map +1 -0
- package/dist/deepseek-harness/README.md +112 -0
- package/dist/deepseek-harness/deepseek-mcp-harness.mjs +292 -0
- package/dist/dispatcher.d.ts +34 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +72 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/heartbeat.d.ts +17 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +36 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +255 -0
- package/dist/index.js.map +1 -0
- package/dist/install-plugin.d.ts +16 -0
- package/dist/install-plugin.d.ts.map +1 -0
- package/dist/install-plugin.js +125 -0
- package/dist/install-plugin.js.map +1 -0
- package/dist/instance-lock.d.ts +5 -0
- package/dist/instance-lock.d.ts.map +1 -0
- package/dist/instance-lock.js +77 -0
- package/dist/instance-lock.js.map +1 -0
- package/dist/list-tools-handler.d.ts +16 -0
- package/dist/list-tools-handler.d.ts.map +1 -0
- package/dist/list-tools-handler.js +50 -0
- package/dist/list-tools-handler.js.map +1 -0
- package/dist/plugin-liveness.d.ts +48 -0
- package/dist/plugin-liveness.d.ts.map +1 -0
- package/dist/plugin-liveness.js +75 -0
- package/dist/plugin-liveness.js.map +1 -0
- package/dist/plugin-socket.d.ts +32 -0
- package/dist/plugin-socket.d.ts.map +1 -0
- package/dist/plugin-socket.js +97 -0
- package/dist/plugin-socket.js.map +1 -0
- package/dist/ports.d.ts +3 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +22 -0
- package/dist/ports.js.map +1 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +23 -0
- package/dist/token.js.map +1 -0
- package/dist/tool-contracts.d.ts +13 -0
- package/dist/tool-contracts.d.ts.map +1 -0
- package/dist/tool-contracts.js +1422 -0
- package/dist/tool-contracts.js.map +1 -0
- package/dist/tool-handler.d.ts +23 -0
- package/dist/tool-handler.d.ts.map +1 -0
- package/dist/tool-handler.js +96 -0
- package/dist/tool-handler.js.map +1 -0
- package/dist/validate-args.d.ts +7 -0
- package/dist/validate-args.d.ts.map +1 -0
- package/dist/validate-args.js +37 -0
- package/dist/validate-args.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/wait-until.d.ts +7 -0
- package/dist/wait-until.d.ts.map +1 -0
- package/dist/wait-until.js +15 -0
- package/dist/wait-until.js.map +1 -0
- 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
|