lightroom-cli 1.0.0__py3-none-any.whl

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 (54) hide show
  1. cli/__init__.py +0 -0
  2. cli/commands/__init__.py +0 -0
  3. cli/commands/ai_mask.py +228 -0
  4. cli/commands/catalog.py +378 -0
  5. cli/commands/develop.py +686 -0
  6. cli/commands/plugin.py +92 -0
  7. cli/commands/preview.py +48 -0
  8. cli/commands/selection.py +191 -0
  9. cli/commands/system.py +107 -0
  10. cli/completions.py +34 -0
  11. cli/decorators.py +92 -0
  12. cli/helpers.py +168 -0
  13. cli/main.py +60 -0
  14. cli/middleware.py +37 -0
  15. cli/output.py +135 -0
  16. cli/schema.py +92 -0
  17. cli/structured_group.py +83 -0
  18. cli/validation.py +204 -0
  19. lightroom_cli-1.0.0.dist-info/METADATA +333 -0
  20. lightroom_cli-1.0.0.dist-info/RECORD +54 -0
  21. lightroom_cli-1.0.0.dist-info/WHEEL +5 -0
  22. lightroom_cli-1.0.0.dist-info/entry_points.txt +2 -0
  23. lightroom_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  24. lightroom_cli-1.0.0.dist-info/top_level.txt +2 -0
  25. lightroom_sdk/__init__.py +24 -0
  26. lightroom_sdk/client.py +145 -0
  27. lightroom_sdk/exceptions.py +146 -0
  28. lightroom_sdk/paths.py +43 -0
  29. lightroom_sdk/plugin/AppShutdown.lua +9 -0
  30. lightroom_sdk/plugin/CatalogModule.lua +1534 -0
  31. lightroom_sdk/plugin/CommandRouter.lua +488 -0
  32. lightroom_sdk/plugin/Config.lua +60 -0
  33. lightroom_sdk/plugin/DevelopModule.lua +3879 -0
  34. lightroom_sdk/plugin/ErrorUtils.lua +225 -0
  35. lightroom_sdk/plugin/Info.lua +35 -0
  36. lightroom_sdk/plugin/Logger.lua +54 -0
  37. lightroom_sdk/plugin/MenuActions.lua +77 -0
  38. lightroom_sdk/plugin/MessageProtocol.lua +403 -0
  39. lightroom_sdk/plugin/PlatformPaths.lua +11 -0
  40. lightroom_sdk/plugin/PluginInit.lua +492 -0
  41. lightroom_sdk/plugin/PluginShutdown.lua +9 -0
  42. lightroom_sdk/plugin/PreviewModule.lua +521 -0
  43. lightroom_sdk/plugin/SelectionModule.lua +329 -0
  44. lightroom_sdk/plugin/SimpleSocketBridge.lua +403 -0
  45. lightroom_sdk/plugin/StopMenuAction.lua +53 -0
  46. lightroom_sdk/presets.py +24 -0
  47. lightroom_sdk/protocol.py +30 -0
  48. lightroom_sdk/resilient_bridge.py +157 -0
  49. lightroom_sdk/retry.py +39 -0
  50. lightroom_sdk/schema.py +1639 -0
  51. lightroom_sdk/socket_bridge.py +228 -0
  52. lightroom_sdk/types/__init__.py +22 -0
  53. lightroom_sdk/types/catalog.py +44 -0
  54. lightroom_sdk/types/develop.py +156 -0
cli/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,228 @@
1
+ """AI Mask CLI commands — lr develop ai <type>"""
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from cli.decorators import json_input_options
8
+ from cli.helpers import execute_command
9
+ from cli.output import OutputFormatter
10
+
11
+ AI_SELECTION_TYPES = ["subject", "sky", "background", "objects", "people", "landscape"]
12
+
13
+
14
+ @click.group("ai")
15
+ def ai():
16
+ """AI mask commands (subject, sky, background, people, landscape, objects)"""
17
+ pass
18
+
19
+
20
+ def _make_ai_type_command(selection_type: str, has_part: bool = False, part_choices: list[str] | None = None):
21
+ """各 AI マスクタイプコマンドのファクトリ関数"""
22
+
23
+ params = [
24
+ click.Option(["--adjust"], default=None, help="JSON adjustment settings"),
25
+ click.Option(
26
+ ["--adjust-preset"],
27
+ default=None,
28
+ help="Named preset (darken-sky, brighten-subject, etc)",
29
+ ),
30
+ click.Option(["--dry-run"], is_flag=True, default=False, help="Preview without executing"),
31
+ click.Option(["--json", "json_str"], default=None, help="JSON string with all parameters"),
32
+ click.Option(
33
+ ["--json-stdin", "json_stdin"],
34
+ is_flag=True,
35
+ default=False,
36
+ help="Read JSON parameters from stdin",
37
+ ),
38
+ ]
39
+ # --part is hidden until SDK support is verified
40
+ if has_part and part_choices:
41
+ params.insert(
42
+ 0,
43
+ click.Option(
44
+ ["--part"],
45
+ default=None,
46
+ type=click.Choice(part_choices),
47
+ help="Specific part to mask",
48
+ hidden=True,
49
+ ),
50
+ )
51
+
52
+ @click.pass_context
53
+ def command_func(ctx, **kwargs):
54
+ adjust = kwargs.get("adjust")
55
+ adjust_preset = kwargs.get("adjust_preset")
56
+ part = kwargs.get("part")
57
+
58
+ # Build params
59
+ cmd_params: dict = {"selectionType": selection_type}
60
+ if part:
61
+ cmd_params["part"] = part
62
+
63
+ # Resolve adjustments
64
+ adjustments = _resolve_adjustments(adjust, adjust_preset)
65
+ if isinstance(adjustments, str):
66
+ # Error message
67
+ click.echo(OutputFormatter.format_error(adjustments))
68
+ return
69
+ if adjustments:
70
+ cmd_params["adjustments"] = adjustments
71
+
72
+ execute_command(ctx, "develop.createAIMaskWithAdjustments", cmd_params, timeout=60.0)
73
+
74
+ cmd = click.Command(
75
+ name=selection_type,
76
+ callback=command_func,
77
+ params=params,
78
+ help=f"Create AI {selection_type} mask",
79
+ )
80
+ return cmd
81
+
82
+
83
+ def _resolve_adjustments(adjust_json: str | None, adjust_preset: str | None) -> dict | str | None:
84
+ """--adjust JSON と --adjust-preset を解決する。エラー時は文字列を返す。"""
85
+ if adjust_json and adjust_preset:
86
+ return "Cannot use both --adjust and --adjust-preset"
87
+
88
+ if adjust_preset:
89
+ from lightroom_sdk.presets import get_preset
90
+
91
+ preset = get_preset(adjust_preset)
92
+ if preset is None:
93
+ from lightroom_sdk.presets import list_presets
94
+
95
+ available = ", ".join(list_presets())
96
+ return f"Unknown preset '{adjust_preset}'. Available: {available}"
97
+ return preset
98
+
99
+ if adjust_json:
100
+ try:
101
+ parsed = json.loads(adjust_json)
102
+ if not isinstance(parsed, dict):
103
+ return "--adjust must be a JSON object"
104
+ return parsed
105
+ except json.JSONDecodeError as e:
106
+ return f"Invalid JSON in --adjust: {e}"
107
+
108
+ return None
109
+
110
+
111
+ # Register type commands
112
+ ai.add_command(_make_ai_type_command("subject"))
113
+ ai.add_command(_make_ai_type_command("sky"))
114
+ ai.add_command(_make_ai_type_command("background"))
115
+ ai.add_command(_make_ai_type_command("objects"))
116
+ ai.add_command(
117
+ _make_ai_type_command(
118
+ "people",
119
+ has_part=True,
120
+ part_choices=["eyes", "hair", "skin", "lips", "teeth", "clothes"],
121
+ )
122
+ )
123
+ ai.add_command(
124
+ _make_ai_type_command(
125
+ "landscape",
126
+ has_part=True,
127
+ part_choices=["mountain", "tree", "water", "building", "road"],
128
+ )
129
+ )
130
+
131
+
132
+ @ai.command("presets")
133
+ @click.pass_context
134
+ def ai_presets(ctx):
135
+ """List available adjustment presets"""
136
+ fmt = ctx.obj.get("output", "text") if ctx.obj else "text"
137
+ from lightroom_sdk.presets import AI_MASK_PRESETS
138
+
139
+ click.echo(OutputFormatter.format(AI_MASK_PRESETS, fmt))
140
+
141
+
142
+ @ai.command("reset")
143
+ @click.option(
144
+ "--confirm",
145
+ is_flag=True,
146
+ default=False,
147
+ help="Required confirmation flag (removes all masks)",
148
+ )
149
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
150
+ @json_input_options
151
+ @click.pass_context
152
+ def ai_reset(ctx, confirm, dry_run, **kwargs):
153
+ """Remove all masks from the current photo (requires --confirm)"""
154
+ if not confirm and not dry_run:
155
+ fmt = ctx.obj.get("output", "text") if ctx.obj else "text"
156
+ click.echo(
157
+ OutputFormatter.format_error(
158
+ "This will remove all masks. Pass --confirm to proceed.",
159
+ fmt,
160
+ code="CONFIRMATION_REQUIRED",
161
+ suggestions=[
162
+ "Add --confirm flag: lr develop ai reset --confirm",
163
+ "Use --dry-run first to preview: lr develop ai reset --dry-run",
164
+ ],
165
+ ),
166
+ err=True,
167
+ )
168
+ ctx.exit(2)
169
+ return
170
+ execute_command(ctx, "develop.resetMasking", {})
171
+
172
+
173
+ @ai.command("list")
174
+ @json_input_options
175
+ @click.pass_context
176
+ def ai_list(ctx, **kwargs):
177
+ """List all masks on the current photo"""
178
+ execute_command(ctx, "develop.getAllMasks", {})
179
+
180
+
181
+ @ai.command("batch")
182
+ @click.argument("type", type=click.Choice(AI_SELECTION_TYPES))
183
+ @click.option("--photos", default=None, help="Comma-separated photo IDs")
184
+ @click.option("--all-selected", is_flag=True, help="Apply to all selected photos")
185
+ @click.option("--adjust", default=None, help="JSON adjustment settings")
186
+ @click.option("--adjust-preset", default=None, help="Named preset")
187
+ @click.option("--dry-run", is_flag=True, help="Show targets without applying")
188
+ @click.option("--continue-on-error", is_flag=True, default=False, help="Continue on errors")
189
+ @json_input_options
190
+ @click.pass_context
191
+ def ai_batch(
192
+ ctx,
193
+ type,
194
+ photos,
195
+ all_selected,
196
+ adjust,
197
+ adjust_preset,
198
+ dry_run,
199
+ continue_on_error,
200
+ **kwargs,
201
+ ):
202
+ """Apply AI mask to multiple photos"""
203
+ if not photos and not all_selected:
204
+ click.echo(OutputFormatter.format_error("Specify --photos or --all-selected"))
205
+ return
206
+
207
+ if dry_run:
208
+ target = "all selected photos" if all_selected else f"photos: {photos}"
209
+ click.echo(f"Dry run: would apply AI {type} mask to {target}")
210
+ return
211
+
212
+ # Resolve adjustments
213
+ adjustments = _resolve_adjustments(adjust, adjust_preset)
214
+ if isinstance(adjustments, str):
215
+ click.echo(OutputFormatter.format_error(adjustments))
216
+ return
217
+
218
+ cmd_params: dict = {
219
+ "selectionType": type,
220
+ "allSelected": all_selected,
221
+ "continueOnError": continue_on_error,
222
+ }
223
+ if photos:
224
+ cmd_params["photoIds"] = [p.strip() for p in photos.split(",")]
225
+ if adjustments:
226
+ cmd_params["adjustments"] = adjustments
227
+
228
+ execute_command(ctx, "develop.batchAIMask", cmd_params, timeout=300.0)
@@ -0,0 +1,378 @@
1
+ import json
2
+
3
+ import click
4
+
5
+ from cli.decorators import json_input_options
6
+ from cli.helpers import execute_command
7
+ from cli.output import OutputFormatter
8
+
9
+
10
+ @click.group()
11
+ def catalog():
12
+ """Catalog commands (list, search, find, get-selected, get-info, set-rating, add-keywords, etc.)"""
13
+ pass
14
+
15
+
16
+ @catalog.command("get-selected")
17
+ @json_input_options
18
+ @click.pass_context
19
+ def get_selected(ctx, **kwargs):
20
+ """Get currently selected photos"""
21
+ execute_command(ctx, "catalog.getSelectedPhotos", {})
22
+
23
+
24
+ @catalog.command("list")
25
+ @click.option("--limit", default=50, type=int, help="Max photos to return")
26
+ @click.option("--offset", default=0, type=int, help="Offset for pagination")
27
+ @json_input_options
28
+ @click.pass_context
29
+ def list_photos(ctx, limit, offset, **kwargs):
30
+ """List photos in catalog"""
31
+ execute_command(ctx, "catalog.getAllPhotos", {"limit": limit, "offset": offset}, timeout=60.0)
32
+
33
+
34
+ @catalog.command("search")
35
+ @click.argument("query")
36
+ @click.option("--limit", default=50, type=int)
37
+ @json_input_options
38
+ @click.pass_context
39
+ def search(ctx, query, limit, **kwargs):
40
+ """Search photos by keyword"""
41
+ execute_command(ctx, "catalog.searchPhotos", {"query": query, "limit": limit}, timeout=60.0)
42
+
43
+
44
+ @catalog.command("get-info")
45
+ @click.argument("photo_id")
46
+ @json_input_options
47
+ @click.pass_context
48
+ def get_info(ctx, photo_id, **kwargs):
49
+ """Get detailed info for a photo"""
50
+ execute_command(ctx, "catalog.getPhotoMetadata", {"photoId": photo_id})
51
+
52
+
53
+ @catalog.command("set-rating")
54
+ @click.argument("photo_id")
55
+ @click.argument("rating", type=click.IntRange(0, 5))
56
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
57
+ @json_input_options
58
+ @click.pass_context
59
+ def set_rating(ctx, photo_id, rating, dry_run, **kwargs):
60
+ """Set photo rating (0-5)"""
61
+ execute_command(ctx, "catalog.setRating", {"photoId": photo_id, "rating": rating})
62
+
63
+
64
+ @catalog.command("add-keywords")
65
+ @click.argument("photo_id")
66
+ @click.argument("keywords", nargs=-1, required=True)
67
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
68
+ @json_input_options
69
+ @click.pass_context
70
+ def add_keywords(ctx, photo_id, keywords, dry_run, **kwargs):
71
+ """Add keywords to a photo"""
72
+ execute_command(ctx, "catalog.addKeywords", {"photoId": photo_id, "keywords": list(keywords)})
73
+
74
+
75
+ @catalog.command("set-flag")
76
+ @click.argument("photo_id")
77
+ @click.argument("flag", type=click.Choice(["pick", "reject", "none"]))
78
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
79
+ @json_input_options
80
+ @click.pass_context
81
+ def set_flag(ctx, photo_id, flag, dry_run, **kwargs):
82
+ """Set photo flag (pick/reject/none)"""
83
+ flag_map = {"pick": 1, "reject": -1, "none": 0}
84
+ execute_command(ctx, "catalog.setFlag", {"photoId": photo_id, "flag": flag_map[flag]})
85
+
86
+
87
+ @catalog.command("get-flag")
88
+ @click.argument("photo_id")
89
+ @json_input_options
90
+ @click.pass_context
91
+ def get_flag(ctx, photo_id, **kwargs):
92
+ """Get photo flag status"""
93
+ execute_command(ctx, "catalog.getFlag", {"photoId": photo_id})
94
+
95
+
96
+ @catalog.command("find")
97
+ @click.option("--flag", type=click.Choice(["pick", "reject", "none"]), help="Flag condition")
98
+ @click.option("--rating", type=int, help="Rating (0-5)")
99
+ @click.option(
100
+ "--rating-op",
101
+ default="==",
102
+ type=click.Choice(["==", ">=", "<=", ">", "<"]),
103
+ help="Rating comparison operator",
104
+ )
105
+ @click.option("--color-label", help="Color label (red/yellow/green/blue/purple/none)")
106
+ @click.option("--camera", help="Camera model name")
107
+ @click.option("--limit", default=50, type=int, help="Max results")
108
+ @click.option("--offset", default=0, type=int, help="Offset for pagination")
109
+ @json_input_options
110
+ @click.pass_context
111
+ def find_photos(ctx, flag, rating, rating_op, color_label, camera, limit, offset, **kwargs):
112
+ """Find photos by structured criteria"""
113
+ search_desc = {}
114
+ if flag:
115
+ search_desc["flag"] = flag
116
+ if rating is not None:
117
+ search_desc["rating"] = rating
118
+ search_desc["ratingOp"] = rating_op
119
+ if color_label:
120
+ search_desc["colorLabel"] = color_label
121
+ if camera:
122
+ search_desc["camera"] = camera
123
+
124
+ execute_command(
125
+ ctx,
126
+ "catalog.findPhotos",
127
+ {"searchDesc": search_desc, "limit": limit, "offset": offset},
128
+ )
129
+
130
+
131
+ @catalog.command("select")
132
+ @click.argument("photo_ids", nargs=-1, required=True)
133
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
134
+ @json_input_options
135
+ @click.pass_context
136
+ def select_photos(ctx, photo_ids, dry_run, **kwargs):
137
+ """Select photos by ID"""
138
+ execute_command(ctx, "catalog.setSelectedPhotos", {"photoIds": list(photo_ids)})
139
+
140
+
141
+ @catalog.command("find-by-path")
142
+ @click.argument("path")
143
+ @json_input_options
144
+ @click.pass_context
145
+ def find_by_path(ctx, path, **kwargs):
146
+ """Find photo by file path"""
147
+ execute_command(ctx, "catalog.findPhotoByPath", {"path": path})
148
+
149
+
150
+ @catalog.command("collections")
151
+ @json_input_options
152
+ @click.pass_context
153
+ def collections(ctx, **kwargs):
154
+ """List collections in catalog"""
155
+ execute_command(ctx, "catalog.getCollections", {})
156
+
157
+
158
+ @catalog.command("keywords")
159
+ @json_input_options
160
+ @click.pass_context
161
+ def keywords(ctx, **kwargs):
162
+ """List keywords in catalog"""
163
+ execute_command(ctx, "catalog.getKeywords", {})
164
+
165
+
166
+ @catalog.command("folders")
167
+ @click.option("--recursive", is_flag=True, help="Include subfolders")
168
+ @json_input_options
169
+ @click.pass_context
170
+ def folders(ctx, recursive, **kwargs):
171
+ """List folders in catalog"""
172
+ execute_command(ctx, "catalog.getFolders", {"includeSubfolders": recursive})
173
+
174
+
175
+ @catalog.command("set-title")
176
+ @click.argument("photo_id")
177
+ @click.argument("title")
178
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
179
+ @json_input_options
180
+ @click.pass_context
181
+ def set_title(ctx, photo_id, title, dry_run, **kwargs):
182
+ """Set photo title"""
183
+ execute_command(ctx, "catalog.setTitle", {"photoId": photo_id, "title": title})
184
+
185
+
186
+ @catalog.command("set-caption")
187
+ @click.argument("photo_id")
188
+ @click.argument("caption")
189
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
190
+ @json_input_options
191
+ @click.pass_context
192
+ def set_caption(ctx, photo_id, caption, dry_run, **kwargs):
193
+ """Set photo caption"""
194
+ execute_command(ctx, "catalog.setCaption", {"photoId": photo_id, "caption": caption})
195
+
196
+
197
+ @catalog.command("set-color-label")
198
+ @click.argument("photo_id")
199
+ @click.argument("label", type=click.Choice(["red", "yellow", "green", "blue", "purple", "none"]))
200
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
201
+ @json_input_options
202
+ @click.pass_context
203
+ def set_color_label(ctx, photo_id, label, dry_run, **kwargs):
204
+ """Set photo color label"""
205
+ execute_command(ctx, "catalog.setColorLabel", {"photoId": photo_id, "label": label})
206
+
207
+
208
+ @catalog.command("batch-metadata")
209
+ @click.argument("photo_ids", nargs=-1, required=True)
210
+ @click.option(
211
+ "--keys",
212
+ default="fileName,dateTimeOriginal,rating",
213
+ help="Comma-separated metadata keys",
214
+ )
215
+ @json_input_options
216
+ @click.pass_context
217
+ def batch_metadata(ctx, photo_ids, keys, **kwargs):
218
+ """Get formatted metadata for multiple photos"""
219
+ execute_command(
220
+ ctx,
221
+ "catalog.batchGetFormattedMetadata",
222
+ {"photoIds": list(photo_ids), "keys": keys.split(",")},
223
+ )
224
+
225
+
226
+ @catalog.command("rotate-left")
227
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
228
+ @json_input_options
229
+ @click.pass_context
230
+ def rotate_left(ctx, dry_run, **kwargs):
231
+ """Rotate selected photo left"""
232
+ execute_command(ctx, "catalog.rotateLeft", {})
233
+
234
+
235
+ @catalog.command("rotate-right")
236
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
237
+ @json_input_options
238
+ @click.pass_context
239
+ def rotate_right(ctx, dry_run, **kwargs):
240
+ """Rotate selected photo right"""
241
+ execute_command(ctx, "catalog.rotateRight", {})
242
+
243
+
244
+ @catalog.command("create-virtual-copy")
245
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
246
+ @json_input_options
247
+ @click.pass_context
248
+ def create_virtual_copy(ctx, dry_run, **kwargs):
249
+ """Create virtual copy of selected photo"""
250
+ execute_command(ctx, "catalog.createVirtualCopy", {})
251
+
252
+
253
+ @catalog.command("set-metadata")
254
+ @click.argument("photo_id")
255
+ @click.argument("key")
256
+ @click.argument("value")
257
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
258
+ @json_input_options
259
+ @click.pass_context
260
+ def set_metadata(ctx, photo_id, key, value, dry_run, **kwargs):
261
+ """Set arbitrary metadata key/value for a photo"""
262
+ execute_command(ctx, "catalog.setMetadata", {"photoId": photo_id, "key": key, "value": value})
263
+
264
+
265
+ @catalog.command("create-collection")
266
+ @click.argument("name")
267
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
268
+ @json_input_options
269
+ @click.pass_context
270
+ def create_collection(ctx, name, dry_run, **kwargs):
271
+ """Create a new collection"""
272
+ execute_command(ctx, "catalog.createCollection", {"name": name})
273
+
274
+
275
+ @catalog.command("create-smart-collection")
276
+ @click.argument("name")
277
+ @click.option("--search-desc", default=None, help="JSON search descriptor")
278
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
279
+ @json_input_options
280
+ @click.pass_context
281
+ def create_smart_collection(ctx, name, search_desc, dry_run, **kwargs):
282
+ """Create a smart collection"""
283
+ params = {"name": name}
284
+ if search_desc:
285
+ try:
286
+ params["searchDesc"] = json.loads(search_desc)
287
+ except json.JSONDecodeError as e:
288
+ click.echo(OutputFormatter.format_error(f"Invalid JSON for --search-desc: {e}"))
289
+ ctx.exit(1)
290
+ return
291
+ execute_command(ctx, "catalog.createSmartCollection", params)
292
+
293
+
294
+ @catalog.command("create-collection-set")
295
+ @click.argument("name")
296
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
297
+ @json_input_options
298
+ @click.pass_context
299
+ def create_collection_set(ctx, name, dry_run, **kwargs):
300
+ """Create a collection set"""
301
+ execute_command(ctx, "catalog.createCollectionSet", {"name": name})
302
+
303
+
304
+ @catalog.command("create-keyword")
305
+ @click.argument("keyword")
306
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
307
+ @json_input_options
308
+ @click.pass_context
309
+ def create_keyword(ctx, keyword, dry_run, **kwargs):
310
+ """Create a keyword in catalog"""
311
+ execute_command(ctx, "catalog.createKeyword", {"keyword": keyword})
312
+
313
+
314
+ @catalog.command("remove-keyword")
315
+ @click.argument("photo_id")
316
+ @click.argument("keyword")
317
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
318
+ @json_input_options
319
+ @click.pass_context
320
+ def remove_keyword(ctx, photo_id, keyword, dry_run, **kwargs):
321
+ """Remove keyword from a photo"""
322
+ execute_command(ctx, "catalog.removeKeyword", {"photoId": photo_id, "keyword": keyword})
323
+
324
+
325
+ @catalog.command("set-view-filter")
326
+ @click.option("--filter", "filter_json", required=True, help="JSON filter descriptor")
327
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
328
+ @json_input_options
329
+ @click.pass_context
330
+ def set_view_filter(ctx, filter_json, dry_run, **kwargs):
331
+ """Set view filter"""
332
+ try:
333
+ filter_data = json.loads(filter_json)
334
+ except json.JSONDecodeError as e:
335
+ click.echo(OutputFormatter.format_error(f"Invalid JSON for --filter: {e}"))
336
+ ctx.exit(1)
337
+ return
338
+ execute_command(ctx, "catalog.setViewFilter", {"filter": filter_data})
339
+
340
+
341
+ @catalog.command("get-view-filter")
342
+ @json_input_options
343
+ @click.pass_context
344
+ def get_view_filter(ctx, **kwargs):
345
+ """Get current view filter"""
346
+ execute_command(ctx, "catalog.getCurrentViewFilter", {})
347
+
348
+
349
+ @catalog.command("remove-from-catalog")
350
+ @click.argument("photo_id")
351
+ @click.option(
352
+ "--confirm",
353
+ is_flag=True,
354
+ default=False,
355
+ help="Required confirmation flag (this operation is irreversible)",
356
+ )
357
+ @click.option("--dry-run", is_flag=True, default=False, help="Preview without executing")
358
+ @json_input_options
359
+ @click.pass_context
360
+ def remove_from_catalog(ctx, photo_id, confirm, dry_run, **kwargs):
361
+ """Remove photo from catalog (irreversible, requires --confirm)"""
362
+ if not confirm and not dry_run:
363
+ fmt = ctx.obj.get("output", "text") if ctx.obj else "text"
364
+ click.echo(
365
+ OutputFormatter.format_error(
366
+ "This operation is irreversible. Pass --confirm to proceed.",
367
+ fmt,
368
+ code="CONFIRMATION_REQUIRED",
369
+ suggestions=[
370
+ "Add --confirm flag: lr catalog remove-from-catalog PHOTO_ID --confirm",
371
+ "Use --dry-run first to preview: lr catalog remove-from-catalog PHOTO_ID --dry-run",
372
+ ],
373
+ ),
374
+ err=True,
375
+ )
376
+ ctx.exit(2)
377
+ return
378
+ execute_command(ctx, "catalog.removeFromCatalog", {"photoId": photo_id})