@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,313 @@
1
+ local LrApplication = import 'LrApplication'
2
+
3
+ local PhotoLookup = require 'PhotoLookup'
4
+ local Log = require 'Log'
5
+
6
+ local SpotsHandler = {}
7
+
8
+ -- =====================================================================
9
+ -- Spot removal (the Develop module's spot heal/clone tool)
10
+ -- =====================================================================
11
+ --
12
+ -- Lightroom stores spot removal in the `RetouchInfo` develop setting. The
13
+ -- SDK returns it as a table that is a LIST of spot strings; each string is
14
+ -- the classic ACR flat format:
15
+ --
16
+ -- ( x, y ), ( type ), ( srcX, srcY ), ( radius ), ( f ), ( o ), ( flags )
17
+ --
18
+ -- where x/y/srcX/srcY are normalized coordinates (0..1), type is "heal" or
19
+ -- "clone", radius is normalized, and the trailing groups are preserved
20
+ -- verbatim when round-tripping. Because Adobe does not document this format,
21
+ -- this handler never rewrites existing entries (they are appended to
22
+ -- byte-identical) and verifies the write by reading the photo back.
23
+
24
+ local MAX_SPOTS_PER_REQUEST = 50
25
+
26
+ local function clamp01(v)
27
+ if v == nil then return 0 end
28
+ if v < 0 then return 0 end
29
+ if v > 1 then return 1 end
30
+ return v
31
+ end
32
+
33
+ SpotsHandler.clamp01 = clamp01
34
+
35
+ local function requirePhotoId(args)
36
+ if args.photo_id == nil or args.photo_id == "" then
37
+ error("photo_id is required")
38
+ end
39
+ end
40
+
41
+ -- Extract every "( ... )" group from a flat spot string.
42
+ local function extractGroups(s)
43
+ local groups = {}
44
+ for group in s:gmatch("%b()") do
45
+ table.insert(groups, group:sub(2, -2))
46
+ end
47
+ return groups
48
+ end
49
+
50
+ local function parseNumberList(group)
51
+ local numbers = {}
52
+ for token in group:gmatch("[-+]?%d+%.?%d*") do
53
+ table.insert(numbers, tonumber(token))
54
+ end
55
+ return numbers
56
+ end
57
+
58
+ -- Parse one flat spot string into a descriptive table. Unknown/extra groups
59
+ -- are preserved raw so callers always see the original data.
60
+ function SpotsHandler.parseSpotString(s)
61
+ if type(s) ~= "string" or s == "" then return nil end
62
+ local groups = extractGroups(s)
63
+ if #groups < 4 then return nil end
64
+
65
+ local center = parseNumberList(groups[1])
66
+ local kind = groups[2] and groups[2]:gsub("^%s+", ""):gsub("%s+$", "") or nil
67
+ local source = parseNumberList(groups[3])
68
+ local radius = tonumber(groups[4]:match("[-+]?%d+%.?%d*"))
69
+
70
+ local rawTail = {}
71
+ for i = 5, #groups do
72
+ -- Trim so round-trips keep the canonical "( 1 ), ( 1 ), ( 0 )" spacing
73
+ -- regardless of how much whitespace the source string carried.
74
+ table.insert(rawTail, (groups[i]:gsub("^%s+", ""):gsub("%s+$", "")))
75
+ end
76
+
77
+ return {
78
+ x = center[1],
79
+ y = center[2],
80
+ type = (kind == "clone") and "clone" or "heal",
81
+ source_x = source[1],
82
+ source_y = source[2],
83
+ radius = radius,
84
+ tail = rawTail,
85
+ raw = s,
86
+ }
87
+ end
88
+
89
+ -- Serialize one spot back into the flat format. The tail groups are kept
90
+ -- verbatim from the original entry when available.
91
+ local function buildSpotString(spot)
92
+ local fmt = string.format
93
+ local function num(v)
94
+ return fmt("%.6f", v)
95
+ end
96
+
97
+ local x = clamp01(spot.x)
98
+ local y = clamp01(spot.y)
99
+ local radius = tonumber(spot.radius) or 0.05
100
+ if radius < 0 then radius = 0.05 end
101
+ if radius > 1 then radius = 1 end
102
+
103
+ local kind = (spot.type == "clone") and "clone" or "heal"
104
+
105
+ -- Source defaults to an offset left of the spot; Lightroom will usually
106
+ -- recompute a better one automatically, but a deterministic fallback
107
+ -- guarantees the heal never samples the spot itself.
108
+ local srcX = tonumber(spot.source_x)
109
+ local srcY = tonumber(spot.source_y)
110
+ if srcX == nil or srcY == nil then
111
+ srcX = x - (radius * 3)
112
+ if srcX < 0 then srcX = x + (radius * 3) end
113
+ if srcX > 1 then srcX = clamp01(x) end
114
+ srcY = y
115
+ end
116
+
117
+ local tail
118
+ if type(spot.tail) == "table" and #spot.tail >= 3 then
119
+ tail = { spot.tail[1], spot.tail[2], spot.tail[3] }
120
+ else
121
+ tail = { "1", "1", "0" }
122
+ end
123
+
124
+ return "( " .. num(x) .. ", " .. num(y) .. " ), ( " .. kind .. " ), ( "
125
+ .. num(srcX) .. ", " .. num(srcY) .. " ), ( " .. num(radius)
126
+ .. " ), ( " .. tail[1] .. " ), ( " .. tail[2] .. " ), ( " .. tail[3] .. " )"
127
+ end
128
+
129
+ function SpotsHandler.clamp01(v)
130
+ return clamp01(v)
131
+ end
132
+
133
+ -- Normalize whatever getDevelopSettings() returned into an array of strings.
134
+ -- Observed shapes: nil, {}, { "spot1" }, { "spot1", "spot2" }, or a single
135
+ -- string (defensive). Tables of tables (newer formats) are passed through
136
+ -- untouched so data is never corrupted by a guess.
137
+ local function retouchEntriesToTable(retouchInfo)
138
+ if retouchInfo == nil then return {}, nil end
139
+ if type(retouchInfo) == "string" then
140
+ return { retouchInfo }, "string"
141
+ end
142
+ if type(retouchInfo) ~= "table" then
143
+ return {}, nil
144
+ end
145
+
146
+ local strings = {}
147
+ local others = {}
148
+ for _, entry in ipairs(retouchInfo) do
149
+ if type(entry) == "string" then
150
+ table.insert(strings, entry)
151
+ else
152
+ table.insert(others, entry)
153
+ end
154
+ end
155
+ return strings, (next(others) ~= nil) and others or nil
156
+ end
157
+
158
+ local function readRetouchInfo(photo)
159
+ local settings = photo:getDevelopSettings()
160
+ return retouchEntriesToTable(settings.RetouchInfo)
161
+ end
162
+
163
+ local function applyRetouchInfo(photo, entries, historyName)
164
+ photo:applyDevelopSettings({
165
+ EnableRetouch = (#entries > 0),
166
+ RetouchInfo = entries,
167
+ }, historyName)
168
+ end
169
+
170
+ local function resolveOnePhoto(catalog, photoId)
171
+ local photo = nil
172
+ catalog:withReadAccessDo(function()
173
+ photo = PhotoLookup.resolveOne(catalog, photoId)
174
+ end)
175
+ if not photo then
176
+ error("Photo not found: " .. tostring(photoId))
177
+ end
178
+ return photo
179
+ end
180
+
181
+ function SpotsHandler.getSpots(args)
182
+ args = args or {}
183
+ requirePhotoId(args)
184
+
185
+ local catalog = LrApplication.activeCatalog()
186
+ local photo = resolveOnePhoto(catalog, args.photo_id)
187
+
188
+ local entries, others = readRetouchInfo(photo)
189
+ local spots = {}
190
+ for _, entry in ipairs(entries) do
191
+ local parsed = SpotsHandler.parseSpotString(entry)
192
+ if parsed then
193
+ table.insert(spots, parsed)
194
+ else
195
+ -- Not the classic flat format; surface it raw instead of hiding it.
196
+ table.insert(spots, { raw = entry, unparsed = true })
197
+ end
198
+ end
199
+
200
+ Log.info(string.format("get_spots: %d spots on photo %s", #spots, tostring(args.photo_id)))
201
+
202
+ return {
203
+ success = true,
204
+ photo_id = photo.localIdentifier,
205
+ count = #spots,
206
+ spots = spots,
207
+ additional_raw_entries = others,
208
+ }
209
+ end
210
+
211
+ function SpotsHandler.addSpots(args)
212
+ args = args or {}
213
+ requirePhotoId(args)
214
+
215
+ local newSpots = args.spots
216
+ if type(newSpots) ~= "table" or #newSpots == 0 then
217
+ error("spots is required (array of {x, y, radius, type, source_x?, source_y?})")
218
+ end
219
+ if #newSpots > MAX_SPOTS_PER_REQUEST then
220
+ error("spots must contain at most " .. MAX_SPOTS_PER_REQUEST .. " entries")
221
+ end
222
+
223
+ for i, spot in ipairs(newSpots) do
224
+ if type(spot) ~= "table" or spot.x == nil or spot.y == nil then
225
+ error("spots[" .. i .. "] must include numeric x and y (normalized 0..1)")
226
+ end
227
+ if spot.type ~= nil and spot.type ~= "heal" and spot.type ~= "clone" then
228
+ error("spots[" .. i .. "].type must be 'heal' or 'clone'")
229
+ end
230
+ end
231
+
232
+ local catalog = LrApplication.activeCatalog()
233
+ local photo = resolveOnePhoto(catalog, args.photo_id)
234
+
235
+ -- Existing entries are preserved byte-identical.
236
+ local entries = readRetouchInfo(photo)
237
+ local beforeCount = #entries
238
+
239
+ for _, spot in ipairs(newSpots) do
240
+ table.insert(entries, buildSpotString(spot))
241
+ end
242
+
243
+ local appliedSettings
244
+ catalog:withWriteAccessDo("Add Spot Removal", function()
245
+ applyRetouchInfo(photo, entries, "MCP Add Spots")
246
+ appliedSettings = true
247
+ end)
248
+
249
+ -- Verify by reading back: if this Lightroom version refuses RetouchInfo
250
+ -- writes, say so instead of reporting success.
251
+ local afterEntries, _ = readRetouchInfo(photo)
252
+ local verified = #afterEntries
253
+ local took = verified > beforeCount
254
+
255
+ Log.info(string.format("add_spots: %d before, %d after on photo %s",
256
+ beforeCount, verified, tostring(args.photo_id)))
257
+
258
+ if not took then
259
+ return {
260
+ success = false,
261
+ photo_id = photo.localIdentifier,
262
+ before_count = beforeCount,
263
+ after_count = verified,
264
+ applied = false,
265
+ message = "Lightroom did not accept the RetouchInfo write on this version. "
266
+ .. "Draw one spot manually on the photo, then retry: existing entries are "
267
+ .. "used as a template and round-trip safely.",
268
+ }
269
+ end
270
+
271
+ return {
272
+ success = true,
273
+ applied = true,
274
+ photo_id = photo.localIdentifier,
275
+ before_count = beforeCount,
276
+ after_count = verified,
277
+ added = #newSpots,
278
+ message = string.format("Added %d spot(s); photo now has %d.", #newSpots, verified),
279
+ }
280
+ end
281
+
282
+ function SpotsHandler.clearSpots(args)
283
+ args = args or {}
284
+ requirePhotoId(args)
285
+
286
+ local catalog = LrApplication.activeCatalog()
287
+ local photo = resolveOnePhoto(catalog, args.photo_id)
288
+
289
+ local entries, _ = readRetouchInfo(photo)
290
+ local beforeCount = #entries
291
+
292
+ catalog:withWriteAccessDo("Clear Spot Removal", function()
293
+ applyRetouchInfo(photo, {}, "MCP Clear Spots")
294
+ end)
295
+
296
+ local afterEntries, _ = readRetouchInfo(photo)
297
+ local cleared = #afterEntries == 0
298
+
299
+ Log.info(string.format("clear_spots: %d before, %d after on photo %s",
300
+ beforeCount, #afterEntries, tostring(args.photo_id)))
301
+
302
+ return {
303
+ success = cleared,
304
+ photo_id = photo.localIdentifier,
305
+ before_count = beforeCount,
306
+ after_count = #afterEntries,
307
+ message = cleared
308
+ and string.format("Removed all %d spot(s).", beforeCount)
309
+ or string.format("Clear was not verified (%d spots still present); this Lightroom version may require removing them from the Develop panel.", #afterEntries),
310
+ }
311
+ end
312
+
313
+ return SpotsHandler
@@ -0,0 +1,59 @@
1
+ local LrPathUtils = import 'LrPathUtils'
2
+ local LrFileUtils = import 'LrFileUtils'
3
+
4
+ local Log = require 'Log'
5
+
6
+ local WatermarkHandler = {}
7
+
8
+ -- =====================================================================
9
+ -- Watermark presets
10
+ -- =====================================================================
11
+ --
12
+ -- Lightroom stores watermark presets as files under
13
+ -- <appPrefs>/Watermark Presets/<preset name>.watermark, where <appPrefs> is
14
+ -- the root of Lightroom's own preferences folder (getStandardFilePath
15
+ -- ("appPrefs"), i.e. %APPDATA%\Adobe\Lightroom on Windows). Enumerating that
16
+ -- directory gives the exact names the export watermarking option expects.
17
+
18
+ function WatermarkHandler.listWatermarks(_args)
19
+ local appPrefs = LrPathUtils.getStandardFilePath("appPrefs")
20
+ if not appPrefs then
21
+ error("Could not resolve Lightroom's preferences folder on this system")
22
+ end
23
+
24
+ local presetsDir = LrPathUtils.child(appPrefs, "Watermark Presets")
25
+
26
+ local names = {}
27
+ local ok, entries = pcall(function() return LrFileUtils.directoryContents(presetsDir) end)
28
+ if ok and type(entries) == "table" then
29
+ for _, entry in ipairs(entries) do
30
+ if type(entry) == "string" then
31
+ local name = entry:match("^(.*)%.watermark$")
32
+ if name and name ~= "" then
33
+ table.insert(names, name)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ table.sort(names, function(a, b) return a:lower() < b:lower() end)
40
+
41
+ Log.info(string.format("list_watermarks: %d presets found", #names))
42
+
43
+ local result = {
44
+ success = true,
45
+ watermarks = names,
46
+ count = #names,
47
+ presets_dir = presetsDir,
48
+ }
49
+
50
+ if #names == 0 then
51
+ result.message = "No watermark presets found. Create them in Lightroom via "
52
+ .. "Edit > Edit Watermarks..., then this tool (and export_photos' watermark option) "
53
+ .. "can reference them by name."
54
+ end
55
+
56
+ return result
57
+ end
58
+
59
+ return WatermarkHandler
@@ -0,0 +1,25 @@
1
+ return {
2
+ LrSdkVersion = 8.0,
3
+ LrSdkMinimumVersion = 8.0,
4
+
5
+ LrToolkitIdentifier = 'com.lightroom.mcp',
6
+ LrPluginName = "Lightroom MCP AI",
7
+
8
+ LrPluginInfoUrl = "https://github.com/pired/lightroom-mcp",
9
+
10
+ VERSION = { major=3, minor=1, revision=0, build=0 },
11
+
12
+ LrPluginInfoProvider = 'PluginInfoProvider.lua',
13
+ LrInitPlugin = 'PluginInit.lua',
14
+ -- LrForceInitPlugin forces eager load on Lr launch, but ONLY if the
15
+ -- plugin also exposes at least one menu item — see LrLibraryMenuItems
16
+ -- below. Adobe's own remote_control_socket sample uses this pattern.
17
+ LrForceInitPlugin = true,
18
+
19
+ LrLibraryMenuItems = {
20
+ {
21
+ title = "Lightroom MCP — Show Status",
22
+ file = "MenuShowStatus.lua",
23
+ },
24
+ },
25
+ }
@@ -0,0 +1,270 @@
1
+ -- Simple JSON encoder/decoder for Lightroom
2
+ local JSON = {}
3
+
4
+ local SHORT_ESCAPES = {
5
+ ['\n'] = '\\n',
6
+ ['\r'] = '\\r',
7
+ ['\t'] = '\\t',
8
+ ['\b'] = '\\b',
9
+ ['\f'] = '\\f',
10
+ }
11
+
12
+ -- Control characters are illegal raw inside a JSON string, so anything without
13
+ -- a short escape has to go out as \u00XX. Emitting them raw produced responses
14
+ -- the MCP server could not parse.
15
+ local function escapeString(s)
16
+ local escaped = s:gsub('[\\"]', '\\%0'):gsub('%c', function(c)
17
+ return SHORT_ESCAPES[c] or string.format('\\u%04x', c:byte())
18
+ end)
19
+ return escaped
20
+ end
21
+
22
+ -- Lightroom runs Lua 5.1: no utf8 library, so encode the code point by hand.
23
+ local function codepointToUtf8(cp)
24
+ if cp < 0x80 then
25
+ return string.char(cp)
26
+ elseif cp < 0x800 then
27
+ return string.char(0xC0 + math.floor(cp / 0x40), 0x80 + (cp % 0x40))
28
+ elseif cp < 0x10000 then
29
+ return string.char(
30
+ 0xE0 + math.floor(cp / 0x1000),
31
+ 0x80 + (math.floor(cp / 0x40) % 0x40),
32
+ 0x80 + (cp % 0x40))
33
+ end
34
+ return string.char(
35
+ 0xF0 + math.floor(cp / 0x40000),
36
+ 0x80 + (math.floor(cp / 0x1000) % 0x40),
37
+ 0x80 + (math.floor(cp / 0x40) % 0x40),
38
+ 0x80 + (cp % 0x40))
39
+ end
40
+
41
+ -- Reads one \uXXXX escape at `pos` (which points at the backslash), pairing a
42
+ -- high surrogate with the low surrogate that follows. A lone surrogate becomes
43
+ -- U+FFFD rather than an error: JSON.stringify emits those for unpaired code
44
+ -- units, and erroring would drop the whole request.
45
+ local function decodeUnicodeEscape(str, pos)
46
+ local hex = str:sub(pos + 2, pos + 5)
47
+ if not hex:match('^%x%x%x%x$') then
48
+ error('Invalid unicode escape: \\u' .. hex)
49
+ end
50
+ local cp = tonumber(hex, 16)
51
+ local nextPos = pos + 6
52
+
53
+ if cp >= 0xD800 and cp <= 0xDBFF then
54
+ if str:sub(nextPos, nextPos + 1) == '\\u' then
55
+ local lowHex = str:sub(nextPos + 2, nextPos + 5)
56
+ local low = lowHex:match('^%x%x%x%x$') and tonumber(lowHex, 16)
57
+ if low and low >= 0xDC00 and low <= 0xDFFF then
58
+ cp = 0x10000 + (cp - 0xD800) * 0x400 + (low - 0xDC00)
59
+ nextPos = nextPos + 6
60
+ else
61
+ cp = 0xFFFD
62
+ end
63
+ else
64
+ cp = 0xFFFD
65
+ end
66
+ elseif cp >= 0xDC00 and cp <= 0xDFFF then
67
+ cp = 0xFFFD
68
+ end
69
+
70
+ return codepointToUtf8(cp), nextPos
71
+ end
72
+
73
+ function JSON:encode(obj)
74
+ local function encode_value(v)
75
+ local t = type(v)
76
+ if t == "string" then
77
+ return '"' .. escapeString(v) .. '"'
78
+ elseif t == "number" or t == "boolean" then
79
+ return tostring(v)
80
+ elseif t == "table" then
81
+ -- An empty Lua table is ambiguous. Encoding it as {} broke every
82
+ -- client iterating an empty result list (changes.map is not a
83
+ -- function); an empty object rendered as [] is harmless by
84
+ -- comparison, so empty means array here.
85
+ local is_array = next(v) == nil or #v > 0
86
+ if is_array then
87
+ local values = {}
88
+ for i, item in ipairs(v) do
89
+ table.insert(values, encode_value(item))
90
+ end
91
+ return "[" .. table.concat(values, ",") .. "]"
92
+ else
93
+ local key_value_pairs = {}
94
+ for k, val in pairs(v) do
95
+ table.insert(key_value_pairs, encode_value(tostring(k)) .. ":" .. encode_value(val))
96
+ end
97
+ return "{" .. table.concat(key_value_pairs, ",") .. "}"
98
+ end
99
+ elseif t == "nil" then
100
+ return "null"
101
+ else
102
+ return '"' .. tostring(v) .. '"'
103
+ end
104
+ end
105
+ return encode_value(obj)
106
+ end
107
+
108
+ function JSON:decode(str)
109
+ local pos = 1
110
+
111
+ local function skip_whitespace()
112
+ while pos <= #str and str:sub(pos, pos):match("%s") do
113
+ pos = pos + 1
114
+ end
115
+ end
116
+
117
+ local function decode_value()
118
+ skip_whitespace()
119
+ local char = str:sub(pos, pos)
120
+
121
+ if char == '"' then
122
+ -- String
123
+ pos = pos + 1
124
+ local chars = {}
125
+ while pos <= #str do
126
+ local current = str:sub(pos, pos)
127
+ if current == '"' then
128
+ pos = pos + 1
129
+ return table.concat(chars)
130
+ elseif current == '\\' then
131
+ local escaped = str:sub(pos + 1, pos + 1)
132
+ if escaped == 'u' then
133
+ local text, nextPos = decodeUnicodeEscape(str, pos)
134
+ table.insert(chars, text)
135
+ pos = nextPos
136
+ else
137
+ if escaped == '"' or escaped == '\\' or escaped == '/' then
138
+ table.insert(chars, escaped)
139
+ elseif escaped == 'n' then
140
+ table.insert(chars, '\n')
141
+ elseif escaped == 'r' then
142
+ table.insert(chars, '\r')
143
+ elseif escaped == 't' then
144
+ table.insert(chars, '\t')
145
+ elseif escaped == 'b' then
146
+ table.insert(chars, string.char(8))
147
+ elseif escaped == 'f' then
148
+ table.insert(chars, string.char(12))
149
+ else
150
+ error("Invalid escape sequence: \\" .. tostring(escaped))
151
+ end
152
+ pos = pos + 2
153
+ end
154
+ else
155
+ table.insert(chars, current)
156
+ pos = pos + 1
157
+ end
158
+ end
159
+ error("Unterminated string")
160
+ elseif char == '{' then
161
+ -- Object
162
+ pos = pos + 1
163
+ local obj = {}
164
+ skip_whitespace()
165
+ if str:sub(pos, pos) == '}' then
166
+ pos = pos + 1
167
+ return obj
168
+ end
169
+ while true do
170
+ skip_whitespace()
171
+ local key = decode_value()
172
+ skip_whitespace()
173
+ if str:sub(pos, pos) ~= ':' then
174
+ error("Expected ':'")
175
+ end
176
+ pos = pos + 1
177
+ local value = decode_value()
178
+ obj[key] = value
179
+ skip_whitespace()
180
+ char = str:sub(pos, pos)
181
+ if char == '}' then
182
+ pos = pos + 1
183
+ break
184
+ elseif char == ',' then
185
+ pos = pos + 1
186
+ else
187
+ error("Expected ',' or '}'")
188
+ end
189
+ end
190
+ return obj
191
+ elseif char == '[' then
192
+ -- Array
193
+ pos = pos + 1
194
+ local arr = {}
195
+ skip_whitespace()
196
+ if str:sub(pos, pos) == ']' then
197
+ pos = pos + 1
198
+ return arr
199
+ end
200
+ while true do
201
+ local value = decode_value()
202
+ table.insert(arr, value)
203
+ skip_whitespace()
204
+ char = str:sub(pos, pos)
205
+ if char == ']' then
206
+ pos = pos + 1
207
+ break
208
+ elseif char == ',' then
209
+ pos = pos + 1
210
+ else
211
+ error("Expected ',' or ']'")
212
+ end
213
+ end
214
+ return arr
215
+ elseif char == 't' and str:sub(pos, pos + 3) == 'true' then
216
+ pos = pos + 4
217
+ return true
218
+ elseif char == 'f' and str:sub(pos, pos + 4) == 'false' then
219
+ pos = pos + 5
220
+ return false
221
+ elseif char == 'n' and str:sub(pos, pos + 3) == 'null' then
222
+ pos = pos + 4
223
+ return nil
224
+ elseif char:match("[%-0-9]") then
225
+ -- Number
226
+ local start = pos
227
+ if char == '-' then
228
+ pos = pos + 1
229
+ end
230
+ while pos <= #str and str:sub(pos, pos):match("[0-9]") do
231
+ pos = pos + 1
232
+ end
233
+ if pos <= #str and str:sub(pos, pos) == '.' then
234
+ pos = pos + 1
235
+ while pos <= #str and str:sub(pos, pos):match("[0-9]") do
236
+ pos = pos + 1
237
+ end
238
+ end
239
+ -- Exponent form is core JSON and JSON.stringify emits it for
240
+ -- magnitudes past 1e21. Without this the scanner stopped at the
241
+ -- 'e', and the whole request failed to decode.
242
+ local exponent = str:sub(pos, pos)
243
+ if exponent == 'e' or exponent == 'E' then
244
+ pos = pos + 1
245
+ local sign = str:sub(pos, pos)
246
+ if sign == '+' or sign == '-' then
247
+ pos = pos + 1
248
+ end
249
+ while str:sub(pos, pos):match("[0-9]") do
250
+ pos = pos + 1
251
+ end
252
+ end
253
+ -- tonumber returns nil for a malformed number such as "1e", "1e+"
254
+ -- or a lone "-". Returning that nil would drop the field silently
255
+ -- and read as null; a malformed number is a parse error.
256
+ local numberText = str:sub(start, pos - 1)
257
+ local value = tonumber(numberText)
258
+ if value == nil then
259
+ error("Invalid number: " .. numberText)
260
+ end
261
+ return value
262
+ else
263
+ error("Unexpected character: " .. char)
264
+ end
265
+ end
266
+
267
+ return decode_value()
268
+ end
269
+
270
+ return JSON