@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,189 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrTasks = import 'LrTasks'
3
+ local LrPathUtils = import 'LrPathUtils'
4
+ local LrFileUtils = import 'LrFileUtils'
5
+
6
+ local PhotoLookup = require 'PhotoLookup'
7
+ local Log = require 'Log'
8
+
9
+ local PreviewHandler = {}
10
+
11
+ -- =====================================================================
12
+ -- get_photo_preview — let the AI actually SEE the photo
13
+ -- =====================================================================
14
+ --
15
+ -- The Lightroom SDK can render a JPEG of any catalog photo through
16
+ -- photo:requestJpegThumbnail(width, height, callback). The callback runs
17
+ -- asynchronously on a background render task, so we poll a completion
18
+ -- flag with LrTasks.sleep() instead of blocking.
19
+ --
20
+ -- The JPEG bytes are written to a file next to the MCP token
21
+ -- (<home>/.config/lightroom-mcp/previews/) so the MCP server can read
22
+ -- them back and attach the image inline to the tool response (Claude
23
+ -- Desktop renders image content blocks in the conversation). Writing to
24
+ -- disk keeps the large binary out of the line-delimited JSON socket
25
+ -- protocol entirely.
26
+ --
27
+ -- Old previews are pruned to bound disk usage: the folder keeps the
28
+ -- newest MAX_FILES_KEPT files (filenames embed a unix timestamp, so a
29
+ -- lexicographic sort on the timestamp field approximates mtime order
30
+ -- without a stat() API, which LrFileUtils does not expose).
31
+
32
+ local NAMED_SIZES = {
33
+ small = 240,
34
+ medium = 640,
35
+ large = 1024,
36
+ }
37
+
38
+ local MIN_PIXELS = 32
39
+ local MAX_PIXELS = 2048
40
+ local RENDER_TIMEOUT_S = 60
41
+ local POLL_INTERVAL_S = 0.1
42
+ local MAX_FILES_KEPT = 60
43
+ local FILE_PREFIX = "preview_"
44
+
45
+ local function previewsDir()
46
+ local config = LrPathUtils.child(
47
+ LrPathUtils.getStandardFilePath("home"), ".config")
48
+ local base = LrPathUtils.child(
49
+ LrPathUtils.child(config, "lightroom-mcp"), "previews")
50
+ LrFileUtils.createAllDirectories(base)
51
+ return base
52
+ end
53
+
54
+ -- "DSC_0123 (cópia).NEF" -> "dsc_0123_cpa" : Windows-safe, ascii-ish.
55
+ local function sanitizeBase(filename)
56
+ local base = filename or "photo"
57
+ base = base:gsub("%.[^%.]*$", "") -- drop extension
58
+ base = base:lower():gsub("[^%w%-_]", "")
59
+ if #base > 40 then base = base:sub(1, 40) end
60
+ if base == "" then base = "photo" end
61
+ return base
62
+ end
63
+
64
+ -- Delete oldest generated previews past MAX_FILES_KEPT. Filenames are
65
+ -- preview_<id>_<size>px_<timestamp>.jpg; extracting the timestamp keeps
66
+ -- the sort stable across ids/sizes. Best-effort, never fatal.
67
+ local function pruneOldPreviews(dir)
68
+ local entries = LrFileUtils.directoryContents(dir) or {}
69
+ local stamped = {}
70
+ for _, name in ipairs(entries) do
71
+ local ts = name:match("^" .. FILE_PREFIX .. ".-(%d+)%.jpg$")
72
+ if ts then table.insert(stamped, { name = name, ts = ts }) end
73
+ end
74
+ if #stamped <= MAX_FILES_KEPT then return end
75
+ table.sort(stamped, function(a, b) return a.ts < b.ts end)
76
+ for i = 1, #stamped - MAX_FILES_KEPT do
77
+ pcall(function() LrFileUtils.delete(LrPathUtils.child(dir, stamped[i].name)) end)
78
+ end
79
+ end
80
+
81
+ function PreviewHandler.getPhotoPreview(args)
82
+ args = args or {}
83
+ if not args.photo_id then
84
+ error("photo_id is required")
85
+ end
86
+
87
+ local requested = args.size or "medium"
88
+ local pixelSize
89
+ if type(requested) == "string" then
90
+ pixelSize = NAMED_SIZES[requested]
91
+ if not pixelSize then
92
+ error("size must be 'small', 'medium', 'large' or a number of pixels")
93
+ end
94
+ elseif type(requested) == "number" then
95
+ pixelSize = math.floor(requested)
96
+ if pixelSize < MIN_PIXELS or pixelSize > MAX_PIXELS then
97
+ error(string.format("size must be between %d and %d pixels", MIN_PIXELS, MAX_PIXELS))
98
+ end
99
+ else
100
+ error("size must be 'small', 'medium', 'large' or a number of pixels")
101
+ end
102
+
103
+ local catalog = LrApplication.activeCatalog()
104
+
105
+ local photo, filename, dimensions
106
+ catalog:withReadAccessDo(function()
107
+ photo = PhotoLookup.resolveOne(catalog, args.photo_id)
108
+ if photo then
109
+ filename = photo:getFormattedMetadata('fileName')
110
+ dimensions = photo:getRawMetadata('dimensions')
111
+ end
112
+ end)
113
+
114
+ if not photo then
115
+ error("No photo matched photo_id")
116
+ end
117
+
118
+ -- requestJpegThumbnail renders asynchronously and invokes its callback
119
+ -- from a background task; both the render and the poll sleep yield, so
120
+ -- they stay OUTSIDE any catalog access gate (issues #134/#124).
121
+ local jpegData, renderError, done = nil, nil, false
122
+ photo:requestJpegThumbnail(pixelSize, pixelSize, function(jpg, err)
123
+ if err then
124
+ renderError = tostring(err)
125
+ else
126
+ jpegData = jpg
127
+ end
128
+ done = true
129
+ end)
130
+
131
+ local waited = 0
132
+ while not done and waited < RENDER_TIMEOUT_S do
133
+ LrTasks.sleep(POLL_INTERVAL_S)
134
+ waited = waited + POLL_INTERVAL_S
135
+ end
136
+
137
+ if renderError then
138
+ error("Lightroom failed to render the preview: " .. renderError)
139
+ end
140
+ if not done then
141
+ error(string.format(
142
+ "Timed out after %ds waiting for Lightroom to render the preview "
143
+ .. "(a photo still pending standard previews can do this; retry "
144
+ .. "after Lightroom finishes building previews)", RENDER_TIMEOUT_S))
145
+ end
146
+ if not jpegData or #jpegData == 0 then
147
+ error("Lightroom returned no image data for this photo")
148
+ end
149
+
150
+ local dir = previewsDir()
151
+ local outName = string.format("%sp%d_%dpx_%d.jpg",
152
+ FILE_PREFIX, photo.localIdentifier or 0, pixelSize, os.time())
153
+ local outPath = LrPathUtils.child(dir, outName)
154
+
155
+ local fh, openErr = io.open(outPath, "wb")
156
+ if not fh then
157
+ error("failed to write preview file: " .. tostring(openErr))
158
+ end
159
+ fh:write(jpegData)
160
+ fh:close()
161
+
162
+ pruneOldPreviews(dir)
163
+
164
+ Log.info(string.format("getPhotoPreview: wrote %s (%d bytes, %dpx)",
165
+ outPath, #jpegData, pixelSize))
166
+
167
+ return {
168
+ success = true,
169
+ file_path = outPath,
170
+ size_bytes = #jpegData,
171
+ mime_type = "image/jpeg",
172
+ size_px = pixelSize,
173
+ photo = {
174
+ id = photo.localIdentifier,
175
+ path = photo:getRawMetadata('path'),
176
+ filename = filename,
177
+ width = dimensions and dimensions.width or nil,
178
+ height = dimensions and dimensions.height or nil,
179
+ },
180
+ -- Marker consumed by the MCP server (tool-handler.ts): the JPEG is
181
+ -- on disk and small enough to inline as an MCP image content block.
182
+ image_attached_by_server = true,
183
+ message = string.format(
184
+ "Preview written to %s and attached as an image (%d bytes, %dpx)",
185
+ outPath, #jpegData, pixelSize),
186
+ }
187
+ end
188
+
189
+ return PreviewHandler
@@ -0,0 +1,185 @@
1
+ local LrApplication = import 'LrApplication'
2
+
3
+ local Log = require 'Log'
4
+
5
+ local SearchHandler = {}
6
+
7
+ local function parseDate(value, name)
8
+ if type(value) ~= "string" then
9
+ error(name .. " must be a string in YYYY-MM-DD format", 0)
10
+ end
11
+
12
+ local year, month, day = value:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
13
+ if not year then
14
+ error(name .. " must use YYYY-MM-DD format", 0)
15
+ end
16
+
17
+ year = tonumber(year)
18
+ month = tonumber(month)
19
+ day = tonumber(day)
20
+
21
+ if month < 1 or month > 12 then
22
+ error(name .. " must use a valid calendar date", 0)
23
+ end
24
+
25
+ local timestamp = os.time({ year = year, month = month, day = day, hour = 12 })
26
+ local normalized = os.date("*t", timestamp)
27
+ if normalized.year ~= year or normalized.month ~= month or normalized.day ~= day then
28
+ error(name .. " must use a valid calendar date", 0)
29
+ end
30
+
31
+ return value, timestamp
32
+ end
33
+
34
+ local function addDays(timestamp, days)
35
+ return os.date("%Y-%m-%d", timestamp + (days * 86400))
36
+ end
37
+
38
+ local MAX_SEARCH_RULES = 20
39
+
40
+ -- Advanced criteria rules, the same { criteria, operation, value, value2? }
41
+ -- shape create_smart_collection feeds into its searchDesc. Lets a caller
42
+ -- use any findPhotos criterion (cameraModel, lens, isoSpeedRating,
43
+ -- copyName, hasAdjustments, ...) beyond the simplified filename/keywords/
44
+ -- rating/date shortcuts above. Rules intersect with those shortcuts.
45
+ local function appendRules(desc, rules)
46
+ if rules == nil then return end
47
+ if type(rules) ~= "table" then
48
+ error("rules must be an array", 0)
49
+ end
50
+ if #rules == 0 then
51
+ error("rules must contain at least one entry when provided", 0)
52
+ end
53
+ if #rules > MAX_SEARCH_RULES then
54
+ error("rules must contain at most " .. MAX_SEARCH_RULES .. " entries", 0)
55
+ end
56
+ for i, rule in ipairs(rules) do
57
+ if type(rule) ~= "table" then
58
+ error("rules[" .. i .. "] must be an object", 0)
59
+ end
60
+ if type(rule.criteria) ~= "string" or rule.criteria == "" then
61
+ error("rules[" .. i .. "].criteria is required (e.g. 'cameraModel', 'lens', 'isoSpeedRating', 'copyName', 'hasAdjustments')", 0)
62
+ end
63
+ if type(rule.operation) ~= "string" or rule.operation == "" then
64
+ error("rules[" .. i .. "].operation is required (e.g. 'all', 'any', '==', '>=', 'in', 'startsWith')", 0)
65
+ end
66
+ if rule.value == nil then
67
+ error("rules[" .. i .. "].value is required", 0)
68
+ end
69
+ local r = {
70
+ criteria = rule.criteria,
71
+ operation = rule.operation,
72
+ value = tostring(rule.value),
73
+ }
74
+ if rule.value2 ~= nil then r.value2 = tostring(rule.value2) end
75
+ table.insert(desc, r)
76
+ end
77
+ end
78
+
79
+ local function buildSearchDesc(args)
80
+ local desc = { combine = args.combine == "union" and "union" or "intersect" }
81
+
82
+ if args.filename then
83
+ table.insert(desc, { criteria = "filename", operation = "any", value = args.filename })
84
+ end
85
+
86
+ if args.rating then
87
+ table.insert(desc, { criteria = "rating", operation = "==", value = args.rating })
88
+ end
89
+
90
+ if args.keywords and #args.keywords > 0 then
91
+ for _, kw in ipairs(args.keywords) do
92
+ table.insert(desc, { criteria = "keywords", operation = "all", value = kw })
93
+ end
94
+ end
95
+
96
+ if args.start_date and args.end_date then
97
+ local startDate, startTimestamp = parseDate(args.start_date, "start_date")
98
+ local endDate, endTimestamp = parseDate(args.end_date, "end_date")
99
+ if startTimestamp > endTimestamp then
100
+ error("start_date must be on or before end_date", 0)
101
+ end
102
+ table.insert(desc, {
103
+ criteria = "captureTime",
104
+ operation = "in",
105
+ value = startDate,
106
+ value2 = endDate,
107
+ })
108
+ elseif args.start_date then
109
+ local _, startTimestamp = parseDate(args.start_date, "start_date")
110
+ table.insert(desc, { criteria = "captureTime", operation = ">", value = addDays(startTimestamp, -1) })
111
+ elseif args.end_date then
112
+ local _, endTimestamp = parseDate(args.end_date, "end_date")
113
+ table.insert(desc, { criteria = "captureTime", operation = "<", value = addDays(endTimestamp, 1) })
114
+ end
115
+
116
+ appendRules(desc, args.rules)
117
+
118
+ return desc
119
+ end
120
+
121
+ local function buildResult(photo)
122
+ return {
123
+ id = photo.localIdentifier,
124
+ path = photo:getRawMetadata('path'),
125
+ filename = photo:getFormattedMetadata('fileName'),
126
+ rating = photo:getRawMetadata('rating'),
127
+ dateTimeOriginal = photo:getFormattedMetadata('dateTimeOriginal'),
128
+ }
129
+ end
130
+
131
+ function SearchHandler.searchPhotos(args)
132
+ local catalog = LrApplication.activeCatalog()
133
+ local results = {}
134
+ local searchDesc = buildSearchDesc(args)
135
+ local hasFilters = #searchDesc > 0
136
+
137
+ -- floor: the tool schema permits any number, and a fractional offset would
138
+ -- make the page loop index matches[i] with a non-integer key (always nil)
139
+ -- and crash buildResult(nil).
140
+ local limit = math.floor(tonumber(args.limit) or 100)
141
+ if limit < 0 then limit = 0 end
142
+ local offset = math.floor(tonumber(args.offset) or 0)
143
+ if offset < 0 then offset = 0 end
144
+
145
+ -- Run the catalog query OUTSIDE withReadAccessDo. findPhotos() runs an
146
+ -- async catalog search that yields; called from inside the read gate on
147
+ -- Windows it deadlocks -- the task never returns and never releases the
148
+ -- gate, wedging the whole bridge until the 30s server timeout (issue #124,
149
+ -- same root cause as #134's getTargetPhotos). Only the per-photo metadata
150
+ -- reads need the gate. getAllPhotos() (no-filter path) is a non-yielding
151
+ -- enumeration, but is hoisted out too for a single, consistent structure.
152
+ Log.info(string.format("searchPhotos: querying (hasFilters=%s)", tostring(hasFilters)))
153
+ -- Unrated photos have nil rating; rating>=0 excludes them, so getAllPhotos()
154
+ -- must be used when no filters are specified.
155
+ local matches = (hasFilters
156
+ and catalog:findPhotos{ searchDesc = searchDesc }
157
+ or catalog:getAllPhotos()) or {}
158
+
159
+ local total = #matches
160
+ Log.info(string.format("searchPhotos: query returned %d", total))
161
+
162
+ local last = math.min(offset + limit, total)
163
+ catalog:withReadAccessDo(function()
164
+ for i = offset + 1, last do
165
+ table.insert(results, buildResult(matches[i]))
166
+ end
167
+ end)
168
+
169
+ Log.info(string.format("Search matched %d photos, returning %d (offset=%d, limit=%d)",
170
+ total, #results, offset, limit))
171
+
172
+ local response = {
173
+ count = total,
174
+ photos = results,
175
+ has_more = (offset + #results) < total,
176
+ }
177
+
178
+ if not hasFilters then
179
+ response.warning = "No filters applied — scanned full catalog. Provide filename, keywords, rating, or date filters to narrow results and improve performance."
180
+ end
181
+
182
+ return response
183
+ end
184
+
185
+ return SearchHandler
@@ -0,0 +1,302 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrSelection = import 'LrSelection'
3
+
4
+ local PhotoLookup = require 'PhotoLookup'
5
+ local Log = require 'Log'
6
+
7
+ local SelectionHandler = {}
8
+
9
+ local function buildResult(photo)
10
+ return {
11
+ id = photo.localIdentifier,
12
+ path = photo:getRawMetadata('path'),
13
+ filename = photo:getFormattedMetadata('fileName'),
14
+ rating = photo:getRawMetadata('rating'),
15
+ dateTimeOriginal = photo:getFormattedMetadata('dateTimeOriginal'),
16
+ }
17
+ end
18
+
19
+ function SelectionHandler.getSelectedPhotos(args)
20
+ args = args or {}
21
+ local catalog = LrApplication.activeCatalog()
22
+
23
+ -- floor: the tool schema permits any number, and a fractional offset would
24
+ -- make the page loop index matches[i] with a non-integer key (always nil)
25
+ -- and crash buildResult(nil).
26
+ local limit = math.floor(tonumber(args.limit) or 100)
27
+ if limit < 0 then limit = 0 end
28
+ local offset = math.floor(tonumber(args.offset) or 0)
29
+ if offset < 0 then offset = 0 end
30
+
31
+ -- Acquire the target set OUTSIDE withReadAccessDo. getTargetPhotos() reads
32
+ -- the live view selection, which yields to the UI thread; called from
33
+ -- inside the read gate on Windows it deadlocks -- the task never returns
34
+ -- and never releases the gate, so the whole bridge wedges until the 30s
35
+ -- server timeout (issues #134, #124). Only the per-photo metadata reads
36
+ -- need the gate; getTargetPhotos() does not. This mirrors get_photo_metadata,
37
+ -- which works because it only does a non-yielding getAllPhotos() pass.
38
+ Log.info("getSelectedPhotos: requesting target photos")
39
+ local matches = catalog:getTargetPhotos() or {}
40
+ local total = #matches
41
+ Log.info(string.format("getSelectedPhotos: getTargetPhotos returned %d", total))
42
+
43
+ local last = math.min(offset + limit, total)
44
+ local results = {}
45
+ catalog:withReadAccessDo(function()
46
+ for i = offset + 1, last do
47
+ table.insert(results, buildResult(matches[i]))
48
+ end
49
+ end)
50
+
51
+ Log.info(string.format("getSelectedPhotos: returning %d (offset=%d, limit=%d)",
52
+ #results, offset, limit))
53
+
54
+ return {
55
+ count = total,
56
+ photos = results,
57
+ has_more = (offset + #results) < total,
58
+ }
59
+ end
60
+
61
+ -- Test-only: kept out of server/src/tool-contracts.ts so it is reachable from
62
+ -- the raw TCP probe but never from an MCP client, which must not be able to
63
+ -- reorder the user's filmstrip. Lets the e2e playbook stage the selection that
64
+ -- get_selected_photos reads back, instead of a human clicking in Lightroom.
65
+ function SelectionHandler.setSelection(args)
66
+ args = args or {}
67
+ local ids = args.photo_ids
68
+ if type(ids) ~= "table" or ids[1] == nil then
69
+ error("photo_ids is required")
70
+ end
71
+
72
+ local catalog = LrApplication.activeCatalog()
73
+ local resolved = PhotoLookup.resolveMany(catalog, ids)
74
+
75
+ local photos = {}
76
+ local missing = {}
77
+ local seen = {}
78
+ local selectedCount = 0
79
+ local missingCount = 0
80
+ for _, entry in ipairs(resolved) do
81
+ if entry.photo then
82
+ if not seen[entry.photo] then
83
+ seen[entry.photo] = true
84
+ selectedCount = selectedCount + 1
85
+ photos[selectedCount] = entry.photo
86
+ end
87
+ else
88
+ missingCount = missingCount + 1
89
+ missing[missingCount] = tostring(entry.id)
90
+ end
91
+ end
92
+
93
+ if selectedCount == 0 then
94
+ error("No photos matched photo_ids")
95
+ end
96
+
97
+ -- Yields to the UI thread, so it must stay OUTSIDE withReadAccessDo for the
98
+ -- same reason getTargetPhotos does (#134/#124). First arg becomes active.
99
+ Log.info(string.format("setSelection: selecting %d (missing %d)", selectedCount, missingCount))
100
+ catalog:setSelectedPhotos(photos[1], photos)
101
+
102
+ -- Lightroom ignores photos outside the current view source and leaves the
103
+ -- old selection in place, reporting nothing. Read the selection back so a
104
+ -- caller is never told a selection happened that did not.
105
+ local requested = {}
106
+ for _, photo in ipairs(photos) do
107
+ requested[photo] = true
108
+ end
109
+ local actualCount = 0
110
+ for _, photo in ipairs(catalog:getTargetPhotos() or {}) do
111
+ if requested[photo] then
112
+ actualCount = actualCount + 1
113
+ end
114
+ end
115
+
116
+ local result = {
117
+ selected = actualCount,
118
+ requested = selectedCount,
119
+ active = photos[1].localIdentifier,
120
+ missing = missing,
121
+ }
122
+ if actualCount < selectedCount then
123
+ result.warning = string.format(
124
+ "Lightroom selected %d of %d photos: the rest are not in the current view source. "
125
+ .. "Switch Lightroom to a source that contains them, such as All Photographs.",
126
+ actualCount, selectedCount)
127
+ end
128
+ return result
129
+ end
130
+
131
+ -- =====================================================================
132
+ -- select_photos — set the Lightroom UI selection by photo ids or mode
133
+ -- =====================================================================
134
+ --
135
+ -- Ported from znznzna/lightroom-cli (MIT) setSelectedPhotos/SelectionModule
136
+ -- and exposed as a first-class tool: selecting photos drives every
137
+ -- LrSelection-based command (flags, rating, labels) and stages the photo
138
+ -- the Develop module loads. catalog:setSelectedPhotos yields to the UI
139
+ -- thread, so it runs OUTSIDE any catalog gate (#134/#124), like the
140
+ -- test-only setSelection above; the two share the read-back verification.
141
+
142
+ function SelectionHandler.selectPhotos(args)
143
+ args = args or {}
144
+ local ids = args.photo_ids
145
+ local mode = args.mode
146
+
147
+ if ids == nil and mode == nil then
148
+ error("photo_ids or mode is required")
149
+ end
150
+ if ids ~= nil and mode ~= nil then
151
+ error("pass either photo_ids or mode, not both")
152
+ end
153
+
154
+ local catalog = LrApplication.activeCatalog()
155
+
156
+ if mode ~= nil then
157
+ local MODE_COMMANDS = {
158
+ all = LrSelection.selectAll,
159
+ none = LrSelection.selectNone,
160
+ inverse = LrSelection.selectInverse,
161
+ deselect_others = LrSelection.deselectOthers,
162
+ }
163
+ local command = MODE_COMMANDS[mode]
164
+ if not command then
165
+ error("mode must be one of: all, none, inverse, deselect_others")
166
+ end
167
+
168
+ -- LrSelection commands act on the UI selection directly (no catalog
169
+ -- gate involved — same family as the flag commands in set_flags).
170
+ local ok, err = pcall(command)
171
+ if not ok then
172
+ error("LrSelection command for mode '" .. mode .. "' failed: " .. tostring(err))
173
+ end
174
+
175
+ Log.info("selectPhotos: mode=" .. mode)
176
+
177
+ return {
178
+ success = true,
179
+ mode = mode,
180
+ message = "Selection mode applied: " .. mode,
181
+ }
182
+ end
183
+
184
+ if type(ids) ~= "table" or ids[1] == nil then
185
+ error("photo_ids must be a non-empty array")
186
+ end
187
+
188
+ local resolved = PhotoLookup.resolveMany(catalog, ids)
189
+
190
+ local photos = {}
191
+ local missing = {}
192
+ local seen = {}
193
+ local selectedCount = 0
194
+ local missingCount = 0
195
+ for _, entry in ipairs(resolved) do
196
+ if entry.photo then
197
+ if not seen[entry.photo] then
198
+ seen[entry.photo] = true
199
+ selectedCount = selectedCount + 1
200
+ photos[selectedCount] = entry.photo
201
+ end
202
+ else
203
+ missingCount = missingCount + 1
204
+ missing[missingCount] = tostring(entry.id)
205
+ end
206
+ end
207
+
208
+ if selectedCount == 0 then
209
+ error("No photos matched photo_ids")
210
+ end
211
+
212
+ -- Yields to the UI thread, so it must stay OUTSIDE withReadAccessDo for
213
+ -- the same reason getTargetPhotos does (#134/#124). First arg becomes active.
214
+ Log.info(string.format("selectPhotos: selecting %d (missing %d)", selectedCount, missingCount))
215
+ catalog:setSelectedPhotos(photos[1], photos)
216
+
217
+ -- Lightroom ignores photos outside the current view source and leaves the
218
+ -- old selection in place, reporting nothing. Read the selection back so a
219
+ -- caller is never told a selection happened that did not.
220
+ local requested = {}
221
+ for _, photo in ipairs(photos) do
222
+ requested[photo] = true
223
+ end
224
+ local actualCount = 0
225
+ for _, photo in ipairs(catalog:getTargetPhotos() or {}) do
226
+ if requested[photo] then
227
+ actualCount = actualCount + 1
228
+ end
229
+ end
230
+
231
+ Log.info(string.format("selectPhotos: Lightroom reports %d of %d", actualCount, selectedCount))
232
+
233
+ local result = {
234
+ success = actualCount > 0,
235
+ selected = actualCount,
236
+ requested = selectedCount,
237
+ active = photos[1].localIdentifier,
238
+ missing = missing,
239
+ message = string.format("Selected %d of %d photo(s)", actualCount, selectedCount),
240
+ }
241
+ if actualCount < selectedCount then
242
+ result.warning = string.format(
243
+ "Lightroom selected %d of %d photos: the rest are not in the current view source. "
244
+ .. "Switch Lightroom to a source that contains them, such as All Photographs.",
245
+ actualCount, selectedCount)
246
+ end
247
+ return result
248
+ end
249
+
250
+ -- =====================================================================
251
+ -- navigate_photo — filmstrip navigation (next / previous)
252
+ -- =====================================================================
253
+ --
254
+ -- LrSelection.nextPhoto()/previousPhoto() move the filmstrip the way the
255
+ -- arrow keys do; the loupe view follows. Outside any gate (LrSelection
256
+ -- family, cf. set_flags). Ported from lightroom-cli SelectionModule.
257
+
258
+ function SelectionHandler.navigatePhoto(args)
259
+ args = args or {}
260
+ local direction = args.direction or "next"
261
+ if direction ~= "next" and direction ~= "previous" then
262
+ error("direction must be 'next' or 'previous'")
263
+ end
264
+
265
+ local command = (direction == "next") and LrSelection.nextPhoto
266
+ or LrSelection.previousPhoto
267
+ if type(command) ~= "function" then
268
+ error("LrSelection navigation is not available")
269
+ end
270
+
271
+ local ok, err = pcall(command)
272
+ if not ok then
273
+ error("could not navigate to " .. direction .. " photo: " .. tostring(err))
274
+ end
275
+
276
+ -- Report which photo is active after the move, so the caller can chain
277
+ -- get_photo_preview / develop work without a second round-trip.
278
+ local catalog = LrApplication.activeCatalog()
279
+ local activeId, activePath = nil, nil
280
+ pcall(function()
281
+ local targets = catalog:getTargetPhotos() or {}
282
+ local active = targets[1]
283
+ if active then
284
+ activeId = active.localIdentifier
285
+ activePath = active:getRawMetadata('path')
286
+ end
287
+ end)
288
+
289
+ Log.info("navigatePhoto: " .. direction)
290
+
291
+ local result = {
292
+ success = true,
293
+ direction = direction,
294
+ message = "Moved to " .. direction .. " photo",
295
+ }
296
+ if activeId ~= nil then
297
+ result.active = { id = activeId, path = activePath }
298
+ end
299
+ return result
300
+ end
301
+
302
+ return SelectionHandler