stashapp-cli 0.1.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 (41) hide show
  1. stashapp_cli/__init__.py +2 -0
  2. stashapp_cli/cli.py +149 -0
  3. stashapp_cli/commands/__init__.py +39 -0
  4. stashapp_cli/commands/_common.py +440 -0
  5. stashapp_cli/commands/backup.py +70 -0
  6. stashapp_cli/commands/generate.py +88 -0
  7. stashapp_cli/commands/graphql.py +212 -0
  8. stashapp_cli/commands/group.py +137 -0
  9. stashapp_cli/commands/introspect.py +35 -0
  10. stashapp_cli/commands/job.py +122 -0
  11. stashapp_cli/commands/performer.py +368 -0
  12. stashapp_cli/commands/scan.py +92 -0
  13. stashapp_cli/commands/scene.py +405 -0
  14. stashapp_cli/commands/scrape.py +133 -0
  15. stashapp_cli/commands/stashbox.py +173 -0
  16. stashapp_cli/commands/studio.py +227 -0
  17. stashapp_cli/commands/tag.py +694 -0
  18. stashapp_cli/core.py +165 -0
  19. stashapp_cli/images.py +72 -0
  20. stashapp_cli/introspect.py +343 -0
  21. stashapp_cli/py.typed +0 -0
  22. stashapp_cli/queries/__init__.py +40 -0
  23. stashapp_cli/queries/_get.py +27 -0
  24. stashapp_cli/queries/_image_flags.py +30 -0
  25. stashapp_cli/queries/backup.py +20 -0
  26. stashapp_cli/queries/group.py +117 -0
  27. stashapp_cli/queries/job.py +29 -0
  28. stashapp_cli/queries/metadata.py +42 -0
  29. stashapp_cli/queries/performer.py +179 -0
  30. stashapp_cli/queries/scene.py +139 -0
  31. stashapp_cli/queries/scrape.py +183 -0
  32. stashapp_cli/queries/sources.py +76 -0
  33. stashapp_cli/queries/stashbox.py +67 -0
  34. stashapp_cli/queries/studio.py +145 -0
  35. stashapp_cli/queries/tag.py +244 -0
  36. stashapp_cli/sources.py +116 -0
  37. stashapp_cli-0.1.0.dist-info/METADATA +208 -0
  38. stashapp_cli-0.1.0.dist-info/RECORD +41 -0
  39. stashapp_cli-0.1.0.dist-info/WHEEL +4 -0
  40. stashapp_cli-0.1.0.dist-info/entry_points.txt +3 -0
  41. stashapp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,694 @@
1
+ """tag commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from .. import core, queries
8
+ from ..queries._image_flags import add_image_flags
9
+ from ._common import (
10
+ CHILD_IDS,
11
+ PARENT_IDS,
12
+ TAG_ALIASES,
13
+ CommandKind,
14
+ FindSpec,
15
+ add_find_options,
16
+ add_get_command,
17
+ add_leaf,
18
+ add_list_options,
19
+ add_update_target,
20
+ bulk_update_input,
21
+ find_filter,
22
+ mutation_result,
23
+ read_json_arg,
24
+ require_force,
25
+ require_list_target,
26
+ resolve_update_input,
27
+ )
28
+
29
+ FIND = FindSpec("tags", "name", match_aliases=True, sort="name")
30
+
31
+
32
+ def cmd_tag_find(args: argparse.Namespace) -> object:
33
+ return queries.tag.find_tags(find_filter(args, FIND), args.page, args.per_page)
34
+
35
+
36
+ def cmd_tag_find_or_create(args: argparse.Namespace) -> object:
37
+ if tag_id := queries.tag.find_tag(args.name):
38
+ return {"applied": False, "operation": "tagFind", "id": tag_id}
39
+ return mutation_result(
40
+ "tagCreate",
41
+ args.apply,
42
+ {"name": args.name},
43
+ lambda: queries.tag.find_or_create_tag(args.name),
44
+ )
45
+
46
+
47
+ LIST_FIELDS = (TAG_ALIASES, PARENT_IDS, CHILD_IDS)
48
+ _HIERARCHY_DESTS = tuple(
49
+ dest for field in (PARENT_IDS, CHILD_IDS) for dest in field.dests
50
+ )
51
+
52
+
53
+ def _normalise_hierarchy_args(args: argparse.Namespace) -> None:
54
+ """Rewrite the tag ID and the parent and child IDs in Stash's decimal form."""
55
+ if args.id is not None:
56
+ args.id = _tag_id(args.id)
57
+ for dest in _HIERARCHY_DESTS:
58
+ if values := getattr(args, dest):
59
+ setattr(args, dest, [_tag_id(value) for value in values])
60
+
61
+
62
+ def _tag_label(tag_id: str, tags: list[dict]) -> str:
63
+ for tag in tags:
64
+ for ref in [tag, *_refs(tag, "parents"), *_refs(tag, "children")]:
65
+ if ref["id"] == tag_id and ref.get("name"):
66
+ return f"{ref['name']} ({tag_id})"
67
+ return tag_id
68
+
69
+
70
+ def _loop_error(label: str, target: str, advice: str = "") -> core.CliError:
71
+ """The `usage_error` for a tag that would be both a parent and a child.
72
+
73
+ `label` names the tag and `target` the tag to update. Stash accepts the two-tag
74
+ loop in one update, then refuses every later update of `target`.
75
+ """
76
+ return core.CliError(
77
+ f"Tag {label} and {target} would form a loop: {label} would be both a "
78
+ f"parent and a child of {target}. Stash accepts this and then refuses to "
79
+ f"change {target}.{advice}",
80
+ 2,
81
+ "usage_error",
82
+ )
83
+
84
+
85
+ def _check_hierarchy_clash(args: argparse.Namespace, tags: list[dict]) -> None:
86
+ """Fail when an added parent or child would be on both sides of the tag.
87
+
88
+ Stash accepts a tag that is both a parent and a child, then refuses every
89
+ later update of the tag, so the clash must be stopped before it is sent.
90
+ """
91
+ tag = next(tag for tag in tags if tag["id"] == args.id)
92
+ parents = {ref["id"] for ref in _refs(tag, "parents")}
93
+ children = {ref["id"] for ref in _refs(tag, "children")}
94
+ add_parent = args.add_parent_id or []
95
+ add_child = args.add_child_id or []
96
+ for tag_id in add_parent:
97
+ if tag_id in add_child:
98
+ raise _loop_error(
99
+ _tag_label(tag_id, tags),
100
+ f"tag {args.id}",
101
+ " It is in both --add-parent-id and --add-child-id.",
102
+ )
103
+ if tag_id in children:
104
+ raise _clash_move(tag_id, tags, args.id, "child", "parent")
105
+ for tag_id in add_child:
106
+ if tag_id in parents:
107
+ raise _clash_move(tag_id, tags, args.id, "parent", "child")
108
+
109
+
110
+ def _clash_move(
111
+ tag_id: str, tags: list[dict], own_id: str, is_now: str, wanted: str
112
+ ) -> core.CliError:
113
+ return _loop_error(
114
+ _tag_label(tag_id, tags),
115
+ f"tag {own_id}",
116
+ f" It is already a {is_now} of tag {own_id}, so it cannot also be added "
117
+ f"as a {wanted}. To move it, remove it first with --remove-{is_now}-id "
118
+ "in a separate command.",
119
+ )
120
+
121
+
122
+ def _input_ids(value: list) -> set[str]:
123
+ """The tag IDs of an `--input` list, written as `_tag_id` writes them."""
124
+ ids = set()
125
+ for entry in value:
126
+ text = str(entry).strip()
127
+ ids.add(str(int(text)) if text.isascii() and text.isdecimal() else text)
128
+ return ids
129
+
130
+
131
+ def _check_input_loop(input_data: object) -> None:
132
+ """Fail when `--input` would put a tag in both `parent_ids` and `child_ids`.
133
+
134
+ A list in the input replaces the tag's list; the other list is the tag's
135
+ current one, read only when the input sets exactly one of the two. A value
136
+ that is not a list is left to Stash.
137
+ """
138
+ if not isinstance(input_data, dict):
139
+ return
140
+ lists: dict[str, list] = {
141
+ field: value
142
+ for field in ("parent_ids", "child_ids")
143
+ if isinstance(value := input_data.get(field), list)
144
+ }
145
+ if not lists:
146
+ return
147
+ tags: list[dict] = []
148
+ own = str(input_data.get("id")).strip()
149
+ final = {field: _input_ids(value) for field, value in lists.items()}
150
+ if len(lists) == 1:
151
+ if not (own.isascii() and own.isdecimal()):
152
+ return
153
+ (field,) = lists
154
+ other = "child_ids" if field == "parent_ids" else "parent_ids"
155
+ tags = queries.tag.find_tags_by_ids([_tag_id(own)])
156
+ ref_field = "children" if other == "child_ids" else "parents"
157
+ final[other] = {ref["id"] for ref in _refs(tags[0], ref_field)}
158
+ both = sorted(final["parent_ids"] & final["child_ids"])
159
+ if both:
160
+ raise _loop_error(_tag_label(both[0], tags), f"tag {own}")
161
+
162
+
163
+ def cmd_tag_update(args: argparse.Namespace) -> object:
164
+ fields = {
165
+ field: value
166
+ for field, value in (
167
+ ("name", args.name),
168
+ ("sort_name", args.sort_name),
169
+ ("description", args.description),
170
+ ("favorite", args.favorite),
171
+ ("ignore_auto_tag", args.ignore_auto_tag),
172
+ )
173
+ if value is not None
174
+ }
175
+ if any(getattr(args, dest) for dest in _HIERARCHY_DESTS):
176
+ _normalise_hierarchy_args(args)
177
+ bulk_input = bulk_update_input(args, LIST_FIELDS)
178
+ if bulk_input is not None:
179
+ require_list_target(args)
180
+ if fields:
181
+ raise core.CliError(
182
+ "The alias, parent, and child options cannot be combined with "
183
+ "--name, --sort-name, --description, or the flag options; run "
184
+ "two commands.",
185
+ 2,
186
+ "usage_error",
187
+ )
188
+ if args.add_parent_id or args.add_child_id:
189
+ wanted = list(
190
+ dict.fromkeys(
191
+ [args.id, *(args.add_parent_id or []), *(args.add_child_id or [])]
192
+ )
193
+ )
194
+ _check_hierarchy_clash(args, queries.tag.find_tags_by_ids(wanted))
195
+ return mutation_result(
196
+ "bulkTagUpdate",
197
+ args.apply,
198
+ bulk_input,
199
+ lambda: queries.tag.bulk_update_tag(bulk_input),
200
+ )
201
+ input_data = resolve_update_input(args, fields)
202
+ _check_input_loop(input_data)
203
+ return mutation_result(
204
+ "tagUpdate",
205
+ args.apply,
206
+ input_data,
207
+ lambda: queries.tag.update_tag(input_data),
208
+ )
209
+
210
+
211
+ def _tag_id(value: str) -> str:
212
+ """`value` as Stash writes a tag ID, so `03` and `3` compare equal."""
213
+ text = value.strip()
214
+ if not (text.isascii() and text.isdecimal()):
215
+ raise core.CliError(
216
+ f"Tag ID must be a non-negative integer, got {value!r}.",
217
+ 2,
218
+ "usage_error",
219
+ )
220
+ return str(int(text))
221
+
222
+
223
+ _USED_BY = (
224
+ ("scenes", "scene_count"),
225
+ ("scene_markers", "scene_marker_count"),
226
+ ("images", "image_count"),
227
+ ("galleries", "gallery_count"),
228
+ ("performers", "performer_count"),
229
+ ("studios", "studio_count"),
230
+ ("groups", "group_count"),
231
+ )
232
+
233
+
234
+ def _delete_report(tag: dict) -> dict:
235
+ return {
236
+ "id": tag["id"],
237
+ "name": tag["name"],
238
+ "aliases": tag.get("aliases") or [],
239
+ "description": tag.get("description"),
240
+ "used_by": {key: tag.get(field) or 0 for key, field in _USED_BY},
241
+ "parents": tag.get("parents") or [],
242
+ "children": tag.get("children") or [],
243
+ }
244
+
245
+
246
+ def cmd_tag_delete(args: argparse.Namespace) -> object:
247
+ ids = list(dict.fromkeys(_tag_id(text) for text in args.ids))
248
+ found = {tag["id"]: tag for tag in queries.tag.find_tags_by_ids(ids)}
249
+ report = [_delete_report(found[tag_id]) for tag_id in ids]
250
+ if not args.apply:
251
+ return {
252
+ "applied": False,
253
+ "operation": "tagsDestroy",
254
+ "input": {"ids": ids},
255
+ "tags": report,
256
+ }
257
+ require_force(args, ["tagsDestroy"])
258
+ queries.tag.destroy_tags(ids)
259
+ return {
260
+ "applied": True,
261
+ "operation": "tagsDestroy",
262
+ "ids": ids,
263
+ "tags": report,
264
+ }
265
+
266
+
267
+ def _alias_key(value: str) -> str:
268
+ return value.strip().casefold()
269
+
270
+
271
+ def _is_str_list(value: object) -> bool:
272
+ return isinstance(value, list) and all(isinstance(item, str) for item in value)
273
+
274
+
275
+ # The `--values` fields the merge report reads, with the shape each must have.
276
+ _VALUES_FIELD_CHECKS = (
277
+ (("name", "sort_name", "description", "image"), str, "a string"),
278
+ (("favorite", "ignore_auto_tag"), bool, "a boolean"),
279
+ (("custom_fields",), dict, "an object"),
280
+ )
281
+ _VALUES_LIST_FIELDS = ("aliases", "parent_ids", "child_ids")
282
+
283
+
284
+ def _check_values_fields(values: dict) -> None:
285
+ for fields, kind, expected in _VALUES_FIELD_CHECKS:
286
+ for field in fields:
287
+ if values.get(field) is not None and not isinstance(values[field], kind):
288
+ raise core.CliError(
289
+ f"--values field {field} must be {expected}.", 2, "usage_error"
290
+ )
291
+ for field in _VALUES_LIST_FIELDS:
292
+ if values.get(field) is not None and not _is_str_list(values[field]):
293
+ raise core.CliError(
294
+ f"--values field {field} must be a list of strings.", 2, "usage_error"
295
+ )
296
+
297
+
298
+ def _merge_values(raw: str | None, destination_id: str) -> dict | None:
299
+ """Parse and check `--values`, filling its required id with the destination."""
300
+ if raw is None:
301
+ return None
302
+ values = read_json_arg(raw)
303
+ if not isinstance(values, dict):
304
+ raise core.CliError(
305
+ "--values must be a TagUpdateInput JSON object.", 2, "usage_error"
306
+ )
307
+ _check_values_fields(values)
308
+ if "id" in values and _tag_id(str(values["id"])) != destination_id:
309
+ raise core.CliError(
310
+ f"Destination {destination_id} does not match id {values['id']} "
311
+ "in --values.",
312
+ 2,
313
+ "usage_error",
314
+ )
315
+ values["id"] = destination_id
316
+ return values
317
+
318
+
319
+ def _refs(tag: dict, field: str) -> list[dict]:
320
+ return tag.get(field) or []
321
+
322
+
323
+ def _hierarchy_ids(
324
+ destination: dict,
325
+ sources: list[dict],
326
+ values: dict | None,
327
+ keep: bool,
328
+ ) -> dict[str, list[str]]:
329
+ """The destination's final `parent_ids` and `child_ids` after the merge.
330
+
331
+ A list in `values` wins. Otherwise the default is the union of the
332
+ destination's and the sources' tags, and `keep=False` keeps only the
333
+ destination's own. The destination and the sources are always left out,
334
+ because the sources are deleted.
335
+ """
336
+ excluded = {destination["id"], *(source["id"] for source in sources)}
337
+ final = {}
338
+ for field, ref_field in (("parent_ids", "parents"), ("child_ids", "children")):
339
+ if values and values.get(field) is not None:
340
+ final[field] = list(values[field])
341
+ continue
342
+ tags = [destination, *sources] if keep else [destination]
343
+ final[field] = list(
344
+ dict.fromkeys(
345
+ ref["id"]
346
+ for tag in tags
347
+ for ref in _refs(tag, ref_field)
348
+ if ref["id"] not in excluded
349
+ )
350
+ )
351
+ return final
352
+
353
+
354
+ def _check_no_loop(final: dict[str, list[str]], names: dict[str, str]) -> None:
355
+ both = [tag_id for tag_id in final["parent_ids"] if tag_id in final["child_ids"]]
356
+ if both:
357
+ label = f"{names[both[0]]} ({both[0]})" if both[0] in names else both[0]
358
+ raise _loop_error(label, "the destination")
359
+
360
+
361
+ def _kept_custom_fields(destination: dict, values: dict) -> dict:
362
+ kept = dict(destination.get("custom_fields") or {})
363
+ changes = values.get("custom_fields") or {}
364
+ if changes.get("full") is not None:
365
+ kept = dict(changes["full"])
366
+ kept.update(changes.get("partial") or {})
367
+ for key in changes.get("remove") or []:
368
+ kept.pop(key, None)
369
+ return kept
370
+
371
+
372
+ def _lost_aliases(destination: dict, source: dict, values: dict) -> list[str]:
373
+ if values.get("aliases") is None:
374
+ return []
375
+ name = values["name"] if values.get("name") is not None else destination["name"]
376
+ kept = {_alias_key(alias) for alias in [name, *values["aliases"]]}
377
+ lost: list[str] = []
378
+ for alias in [source["name"], *(source.get("aliases") or [])]:
379
+ key = _alias_key(alias)
380
+ if alias and key not in kept:
381
+ kept.add(key)
382
+ lost.append(alias)
383
+ return lost
384
+
385
+
386
+ def _kept(destination: dict, values: dict, field: str) -> object:
387
+ return values[field] if field in values else destination.get(field)
388
+
389
+
390
+ def _lost_report(
391
+ destination: dict,
392
+ source: dict,
393
+ values: dict,
394
+ final: dict[str, list[str]],
395
+ ) -> dict:
396
+ excluded = {destination["id"]}
397
+ lost: dict = {"aliases": _lost_aliases(destination, source, values)}
398
+ for field in ("description", "sort_name"):
399
+ value = source.get(field)
400
+ lost[field] = (
401
+ value if value and value != _kept(destination, values, field) else None
402
+ )
403
+ for field in ("favorite", "ignore_auto_tag"):
404
+ kept = values.get(field)
405
+ if kept is None:
406
+ kept = destination.get(field)
407
+ lost[field] = bool(source.get(field)) and not kept
408
+ has_image = add_image_flags(dict(source), {"image_path": "has_image"})["has_image"]
409
+ lost["image"] = has_image and values.get("image") is None
410
+ kept_fields = _kept_custom_fields(destination, values)
411
+ lost["custom_fields"] = {
412
+ key: value
413
+ for key, value in (source.get("custom_fields") or {}).items()
414
+ if key not in kept_fields or kept_fields[key] != value
415
+ }
416
+ for field, ref_field in (("parents", "parent_ids"), ("children", "child_ids")):
417
+ lost[field] = [
418
+ ref
419
+ for ref in _refs(source, field)
420
+ if ref["id"] not in excluded and ref["id"] not in final[ref_field]
421
+ ]
422
+ return lost
423
+
424
+
425
+ def _gains(destination: dict, final: dict[str, list[str]], tags: list[dict]) -> dict:
426
+ names = {
427
+ ref["id"]: ref["name"]
428
+ for tag in tags
429
+ for field in ("parents", "children")
430
+ for ref in _refs(tag, field)
431
+ }
432
+ gains = {}
433
+ for field, ref_field in (("parents", "parent_ids"), ("children", "child_ids")):
434
+ current = {ref["id"] for ref in _refs(destination, field)}
435
+ gains[field] = [
436
+ {"id": tag_id, "name": names.get(tag_id)}
437
+ for tag_id in final[ref_field]
438
+ if tag_id not in current
439
+ ]
440
+ return gains
441
+
442
+
443
+ def _merge_report(destination: dict, sources: list[dict], values, final) -> dict:
444
+ return {
445
+ "destination": {
446
+ "id": destination["id"],
447
+ "name": destination["name"],
448
+ "gains": _gains(destination, final, [destination, *sources]),
449
+ },
450
+ "sources": [
451
+ {
452
+ "id": source["id"],
453
+ "name": source["name"],
454
+ "used_by": {key: source.get(field) or 0 for key, field in _USED_BY},
455
+ "lost": _lost_report(destination, source, values or {}, final),
456
+ }
457
+ for source in sources
458
+ ],
459
+ }
460
+
461
+
462
+ def cmd_tag_merge(args: argparse.Namespace) -> object:
463
+ destination_id = _tag_id(args.destination)
464
+ source_ids = list(dict.fromkeys(_tag_id(text) for text in args.source))
465
+ if destination_id in source_ids:
466
+ raise core.CliError(
467
+ f"Destination {destination_id} is also a --source; a tag cannot be "
468
+ "merged into itself.",
469
+ 2,
470
+ "usage_error",
471
+ )
472
+ values = _merge_values(args.values, destination_id)
473
+
474
+ found = {
475
+ tag["id"]: tag
476
+ for tag in queries.tag.find_tags_by_ids([destination_id, *source_ids])
477
+ }
478
+ destination = found[destination_id]
479
+ sources = [found[source_id] for source_id in source_ids]
480
+
481
+ final = _hierarchy_ids(destination, sources, values, args.keep_hierarchy)
482
+ names = {
483
+ ref["id"]: ref["name"]
484
+ for tag in found.values()
485
+ for field in ("parents", "children")
486
+ for ref in _refs(tag, field)
487
+ }
488
+ _check_no_loop(final, names)
489
+
490
+ sent = dict(values) if values is not None else {}
491
+ if args.keep_hierarchy:
492
+ # Stash drops the sources' tags unless the list is sent, so send a field
493
+ # only when it differs from what the destination keeps on its own.
494
+ own = _hierarchy_ids(destination, sources, None, keep=False)
495
+ for field in ("parent_ids", "child_ids"):
496
+ if field not in sent and final[field] != own[field]:
497
+ sent[field] = final[field]
498
+ input_data: dict = {"source": source_ids, "destination": destination_id}
499
+ if sent:
500
+ input_data["values"] = {"id": destination_id, **sent}
501
+
502
+ report = _merge_report(destination, sources, values, final)
503
+ if not args.apply:
504
+ return {
505
+ "applied": False,
506
+ "operation": "tagsMerge",
507
+ "input": input_data,
508
+ **report,
509
+ }
510
+ require_force(args, ["tagsMerge"])
511
+ return {
512
+ "applied": True,
513
+ "operation": "tagsMerge",
514
+ "id": queries.tag.merge_tags(input_data),
515
+ **report,
516
+ }
517
+
518
+
519
+ def register(subparsers: argparse._SubParsersAction) -> None:
520
+ tag = subparsers.add_parser("tag", help="Tag lookups and creation.")
521
+ tag_sub = tag.add_subparsers(dest="tag_command", required=True)
522
+
523
+ add_get_command(tag_sub, "tag", queries.tag.get_tag, example_id=55)
524
+
525
+ find = add_leaf(
526
+ tag_sub,
527
+ "find",
528
+ kind=CommandKind.READ,
529
+ func=cmd_tag_find,
530
+ help=(
531
+ "Find tags by exact name or alias, or by a TagFilterType; an alias "
532
+ "returns the tag that owns it."
533
+ ),
534
+ examples=(
535
+ "stashapp-cli tag find --name hd",
536
+ "stashapp-cli tag find --filter-json '{\"favorite\": true}'",
537
+ 'stashapp-cli tag find --name hd --filter-json \'{"is_missing": "image"}\'',
538
+ "stashapp-cli tag find --per-page 2",
539
+ ),
540
+ )
541
+ add_find_options(find, FIND)
542
+
543
+ find_or_create = add_leaf(
544
+ tag_sub,
545
+ "find-or-create",
546
+ kind=CommandKind.WRITE,
547
+ func=cmd_tag_find_or_create,
548
+ help=(
549
+ "Find a tag by name or alias, creating it if missing. An existing "
550
+ "name or alias reports operation tagFind and writes nothing."
551
+ ),
552
+ examples=(
553
+ "stashapp-cli tag find-or-create hd",
554
+ "stashapp-cli --apply tag find-or-create hd",
555
+ ),
556
+ )
557
+ find_or_create.add_argument("name")
558
+
559
+ update = add_leaf(
560
+ tag_sub,
561
+ "update",
562
+ kind=CommandKind.WRITE,
563
+ func=cmd_tag_update,
564
+ help="Update a tag (dry-run by default; see global --apply).",
565
+ examples=(
566
+ "stashapp-cli tag update 55 --name 'High Definition'",
567
+ "stashapp-cli tag update 55 --description 'HD video' --favorite --apply",
568
+ "stashapp-cli tag update 55 --add-alias HD",
569
+ "stashapp-cli tag update 55 --remove-alias HD --apply",
570
+ "stashapp-cli tag update 55 --add-parent-id 3",
571
+ "stashapp-cli tag update 55 --remove-child-id 8 --apply",
572
+ "stashapp-cli tag update 55 --input @tag.json --apply",
573
+ ),
574
+ )
575
+ add_update_target(update, "TagUpdateInput")
576
+ update.add_argument(
577
+ "--name", default=None, help="Tag name; overrides name in --input."
578
+ )
579
+ update.add_argument(
580
+ "--sort-name",
581
+ default=None,
582
+ help="Sort name; overrides sort_name in --input.",
583
+ )
584
+ update.add_argument(
585
+ "--description",
586
+ default=None,
587
+ help="Description; overrides description in --input.",
588
+ )
589
+ update.add_argument(
590
+ "--favorite",
591
+ action=argparse.BooleanOptionalAction,
592
+ default=None,
593
+ help="Set or clear favorite; overrides favorite in --input.",
594
+ )
595
+ update.add_argument(
596
+ "--ignore-auto-tag",
597
+ action=argparse.BooleanOptionalAction,
598
+ default=None,
599
+ help="Set or clear ignore_auto_tag; overrides ignore_auto_tag in --input.",
600
+ )
601
+ add_list_options(update, LIST_FIELDS)
602
+ update.epilog = f"{update.epilog}" + (
603
+ "\n\nA tag that is both a parent and a child of the same tag is a "
604
+ "two-tag loop. Stash\naccepts it in one update and then refuses every "
605
+ "later update of the tag. The\ncommand stops with usage_error before "
606
+ "this happens, for the add options, for\n--input with parent_ids or "
607
+ "child_ids, and for `tag merge`. The raw `graphql`\ncommand is not "
608
+ "checked."
609
+ )
610
+
611
+ delete = add_leaf(
612
+ tag_sub,
613
+ "delete",
614
+ kind=CommandKind.DESTRUCTIVE,
615
+ func=cmd_tag_delete,
616
+ help=(
617
+ "Delete tags by ID; there is no undo (dry-run by default; needs "
618
+ "--apply --force)."
619
+ ),
620
+ examples=(
621
+ "stashapp-cli tag delete 44",
622
+ "stashapp-cli tag delete 44 45 46",
623
+ "stashapp-cli tag delete 44 45 --apply --force",
624
+ ),
625
+ )
626
+ delete.add_argument("ids", nargs="+", metavar="ID", help="Tag to delete.")
627
+ delete.epilog = f"{delete.epilog}" + (
628
+ "\n\nThe dry run reads every tag and reports, per tag, how many scenes, "
629
+ "markers,\nimages, galleries, performers, studios, and groups use it, "
630
+ "and its parent and\nchild tags. A child tag is kept but loses this "
631
+ "parent. A missing ID fails with\ntag_not_found before anything is "
632
+ "deleted. Run `stashapp-cli backup` first."
633
+ )
634
+
635
+ merge = add_leaf(
636
+ tag_sub,
637
+ "merge",
638
+ kind=CommandKind.DESTRUCTIVE,
639
+ func=cmd_tag_merge,
640
+ help=(
641
+ "Merge source tags into a destination and delete the sources "
642
+ "(dry-run by default; needs --apply --force)."
643
+ ),
644
+ examples=(
645
+ "stashapp-cli tag merge --destination 3 --source 7 --source 9",
646
+ (
647
+ "stashapp-cli tag merge --destination 3 --source 7 "
648
+ '--values \'{"description": "HD video"}\''
649
+ ),
650
+ "stashapp-cli tag merge --destination 3 --source 7 --no-keep-hierarchy",
651
+ (
652
+ "stashapp-cli tag merge --destination 3 --source 7 "
653
+ "--values @values.json --apply --force"
654
+ ),
655
+ ),
656
+ )
657
+ merge.add_argument(
658
+ "--destination", required=True, metavar="ID", help="Tag to keep."
659
+ )
660
+ merge.add_argument(
661
+ "--source",
662
+ action="append",
663
+ required=True,
664
+ metavar="ID",
665
+ help="Tag to merge and delete; may be given more than once.",
666
+ )
667
+ merge.add_argument(
668
+ "--values",
669
+ default=None,
670
+ help=(
671
+ "TagUpdateInput as JSON, @file, or @- whose fields are applied to "
672
+ "the destination; its id defaults to the destination."
673
+ ),
674
+ )
675
+ merge.add_argument(
676
+ "--keep-hierarchy",
677
+ action=argparse.BooleanOptionalAction,
678
+ default=True,
679
+ help=(
680
+ "Give the destination the parent and child tags of the sources "
681
+ "(default on); --no-keep-hierarchy drops them."
682
+ ),
683
+ )
684
+ merge.epilog = f"{merge.epilog}" + (
685
+ "\n\nStash moves the scenes, performers, and other links of each source "
686
+ "to the\ndestination and adds the source names as aliases. It drops the "
687
+ "source's\ndescription, sort name, favorite and ignore_auto_tag flags, "
688
+ "image, custom\nfields, and parent and child tags. The parent and child "
689
+ "tags are kept by\ndefault (--no-keep-hierarchy turns that off); to keep "
690
+ "the rest, put it in\n--values. The dry run lists what would be lost per "
691
+ "source (lost) and what the\ndestination gains (gains). A parent_ids or "
692
+ "child_ids in --values replaces the\ndefault. --apply without --force "
693
+ "fails with destructive_mutation. Run\n`stashapp-cli backup` first."
694
+ )