@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,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
|