gdharness 1.3.8 → 1.3.10

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 (78) hide show
  1. package/build/cli.js +4668 -3229
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +115 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +112 -6
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +117 -174
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +19 -29
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +39 -6
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +263 -0
  17. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -414
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  20. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  23. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  24. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +162 -483
  25. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  26. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +8 -4
  27. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  28. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  29. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +151 -8
  30. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  31. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +190 -38
  32. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  33. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  34. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  35. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  36. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  37. package/build/godot/addons/gdharness_runtime/reading.gd +112 -6
  38. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  40. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +188 -30
  41. package/build/godot/addons/gdharness_runtime/runtime_input.gd +107 -104
  42. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  43. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  44. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +320 -27
  45. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +165 -20
  46. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  47. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +31 -2
  48. package/build/godot/addons/gdharness_runtime/runtime_values.gd +250 -10
  49. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  50. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  51. package/build/godot/addons/gdharness_runtime/serialisation.gd +19 -29
  52. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  53. package/build/godot/operations/animation_reads.gd +115 -4
  54. package/build/godot/operations/audio_buses.gd +93 -7
  55. package/build/godot/operations/classdb_queries.gd +119 -7
  56. package/build/godot/operations/declared_values.gd +272 -0
  57. package/build/godot/operations/dependencies.gd +154 -41
  58. package/build/godot/operations/editor_tools.gd +29 -13
  59. package/build/godot/operations/export_presets.gd +602 -4
  60. package/build/godot/operations/file_reads.gd +115 -18
  61. package/build/godot/operations/gdscript_analysis.gd +14 -0
  62. package/build/godot/operations/gdscript_authoring.gd +12 -2
  63. package/build/godot/operations/godot_operations.gd +18 -2
  64. package/build/godot/operations/import_pipeline.gd +20 -221
  65. package/build/godot/operations/input_actions.gd +127 -51
  66. package/build/godot/operations/project_config.gd +113 -68
  67. package/build/godot/operations/project_diagnostics.gd +4 -0
  68. package/build/godot/operations/project_validation.gd +266 -0
  69. package/build/godot/operations/property_paths.gd +89 -16
  70. package/build/godot/operations/property_values.gd +180 -121
  71. package/build/godot/operations/reading.gd +112 -6
  72. package/build/godot/operations/scene_connections.gd +222 -0
  73. package/build/godot/operations/serialisation.gd +19 -29
  74. package/build/godot/operations/setting_reads.gd +121 -0
  75. package/build/godot/operations/value_shapes.gd +128 -1
  76. package/build/godot/tests/listed.gd +20 -13
  77. package/build/keeper.js +1402 -707
  78. package/package.json +1 -1
@@ -19,6 +19,27 @@ const PLATFORMS: Dictionary[String, String] = {
19
19
  "Web": "web_",
20
20
  }
21
21
 
22
+ ## Each platform's exporter, which only an editor can make: the options a preset of it takes are its.
23
+ const EXPORTERS: Dictionary[String, String] = {
24
+ "Windows Desktop": "EditorExportPlatformWindows",
25
+ "Linux": "EditorExportPlatformLinuxBSD",
26
+ "macOS": "EditorExportPlatformMacOS",
27
+ "Android": "EditorExportPlatformAndroid",
28
+ "iOS": "EditorExportPlatformIOS",
29
+ "Web": "EditorExportPlatformWeb",
30
+ }
31
+ ## What export_filter takes, as the Export dialog's Resources tab names them: everything, the scenes
32
+ ## listed under export_files and what they use, the resources listed, or everything but those listed.
33
+ const FILTERS: Array[String] = ["all_resources", "scenes", "resources", "exclude"]
34
+ ## Where the editor keeps a preset's secret options, such as a signing password, out of
35
+ ## export_presets.cfg, which a project commits.
36
+ const CREDENTIALS_FILE: String = "res://.godot/export_credentials.cfg"
37
+ ## Options of every platform that a preset lists only with the Export dialog's Advanced Options on,
38
+ ## a switch no script can reach.
39
+ const TEMPLATE_OPTIONS: Array[String] = ["custom_template/debug", "custom_template/release"]
40
+ ## The types of option a value is written for. An option of another type is left to the editor.
41
+ const WRITABLE: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_PACKED_STRING_ARRAY]
42
+
22
43
  var _log: GdhLog
23
44
 
24
45
 
@@ -92,8 +113,10 @@ static func _runnable(config: ConfigFile, section: String) -> bool:
92
113
 
93
114
 
94
115
  ## Adds an export preset to export_presets.cfg, written with the keys the editor's Project > Export
95
- ## writes for a new one. The editor's exporter fills every platform option the file leaves out with
96
- ## its default when it loads the file, which is what a preset fresh from that dialog holds too.
116
+ ## writes for a new one, and every option in [code]known_options[/code] (what
117
+ ## [method export_options] answers for the platform) at its default. The editor also writes the
118
+ ## options a new preset hides until another option shows them; its exporter fills those, and any
119
+ ## option the file leaves out, with their defaults when it loads the file.
97
120
  func add_export_preset(params: Dictionary) -> Dictionary:
98
121
  var preset_name: String = str(params.get("preset", "")).strip_edges()
99
122
  var platform: String = str(params.get("platform", ""))
@@ -150,10 +173,23 @@ func add_export_preset(params: Dictionary) -> Dictionary:
150
173
  }
151
174
  for key: String in values:
152
175
  config.set_value(section, key, values[key])
176
+ # A secret's default stays out of the file, as the editor keeps secrets in CREDENTIALS_FILE.
177
+ var known: Variant = params.get("known_options", [])
178
+ if known is Array:
179
+ for option: Variant in known:
180
+ if option is Dictionary:
181
+ var described: Dictionary = option
182
+ if not GdhRead.as_bool(described.get("secret", false)):
183
+ config.set_value(
184
+ section + ".options",
185
+ str(described.get("name", "")),
186
+ str_to_var(str(described.get("defaultText", "null")))
187
+ )
153
188
  # A section with nothing in it is not written, and the editor's loader reports a missing options
154
189
  # section as an error; these two are options of every platform, empty in a new preset.
155
- config.set_value(section + ".options", "custom_template/debug", "")
156
- config.set_value(section + ".options", "custom_template/release", "")
190
+ for name: String in TEMPLATE_OPTIONS:
191
+ if not config.has_section_key(section + ".options", name):
192
+ config.set_value(section + ".options", name, "")
157
193
  var saving: Error = config.save(PRESETS_FILE)
158
194
  if saving != OK:
159
195
  return _log.failure("Failed to save export_presets.cfg: " + error_string(saving))
@@ -230,3 +266,565 @@ static func _marks_self_contained(directory: String) -> bool:
230
266
  FileAccess.file_exists(directory.path_join("._sc_"))
231
267
  or FileAccess.file_exists(directory.path_join("_sc_"))
232
268
  )
269
+
270
+
271
+ ## The options a preset of [param params]'s platform lists: the name, the type, the hint and hint
272
+ ## string of one that is an enum or a list, the default (also as Godot text, which keeps its type),
273
+ ## and whether the editor keeps it as a secret. An exporter lists an option only while the preset's
274
+ ## other options make it visible, as codesign/identity shows once codesign/enable is on, so the
275
+ ## options the preset [code]preset[/code] holds in [code]presets_file[/code] are applied first, then
276
+ ## those [code]asked[/code] gives, until nothing more comes into view.
277
+ func export_options(params: Dictionary) -> Dictionary:
278
+ var platform: String = str(params.get("platform", ""))
279
+ if platform not in EXPORTERS:
280
+ return _log.failure(
281
+ "Unknown platform '%s'. The platforms are %s." % [platform, ", ".join(EXPORTERS.keys())]
282
+ )
283
+ if not Engine.is_editor_hint():
284
+ return _log.failure("The export options are read in an engine started with --editor.")
285
+ var exporter: Variant = ClassDB.instantiate(EXPORTERS[platform])
286
+ if not exporter is Object:
287
+ return _log.failure("The engine made no %s exporter." % platform)
288
+ var made: Object = exporter
289
+ var preset: Variant = made.call("create_preset")
290
+ var standing: Variant = made.call("create_preset")
291
+ if not preset is Object or not standing is Object:
292
+ return _log.failure("The %s exporter made no preset." % platform)
293
+ var fresh: Object = preset
294
+ var state: Object = standing
295
+ var presets_file: String = str(params.get("presets_file", ""))
296
+ var config: ConfigFile = ConfigFile.new()
297
+ if not presets_file.is_empty() and config.load(presets_file) == OK:
298
+ var index: int = 0
299
+ while config.has_section("preset." + str(index)):
300
+ var section: String = "preset." + str(index)
301
+ if str(config.get_value(section, "name", "")) == str(params.get("preset", "")):
302
+ if config.has_section(section + ".options"):
303
+ for key: String in config.get_section_keys(section + ".options"):
304
+ state.set(key, config.get_value(section + ".options", key))
305
+ break
306
+ index += 1
307
+ var asked: Dictionary = {}
308
+ if params.get("asked") is Dictionary:
309
+ asked = params["asked"]
310
+ var pending: Array = asked.keys()
311
+ var listed: Dictionary = _options_shown(state, fresh)
312
+ var came: bool = true
313
+ while came and not pending.is_empty():
314
+ came = false
315
+ for key: Variant in pending.duplicate():
316
+ var name: String = str(key)
317
+ if not listed.has(name):
318
+ continue
319
+ pending.erase(key)
320
+ var option: Dictionary = listed[name]
321
+ var fitted: Variant = _fitted(name, asked[key], option)
322
+ if not _is_refusal(fitted):
323
+ state.set(name, fitted)
324
+ came = true
325
+ listed = _options_shown(state, fresh)
326
+ # Every asked option the preset would not list, including one an earlier round listed and a later
327
+ # one hid, as turning codesign/enable off in the same call hides codesign/identity.
328
+ var hidden: Dictionary = {}
329
+ for key: Variant in asked:
330
+ if listed.has(str(key)):
331
+ continue
332
+ var shown_by: String = _shown_by(state, str(key), listed)
333
+ if not shown_by.is_empty():
334
+ hidden[str(key)] = shown_by
335
+ return {"platform": platform, "options": listed.values(), "hidden": hidden}
336
+
337
+
338
+ ## Which one change to a listed true-or-false or enum option of [param state] would list
339
+ ## [param name], as "codesign/enable is true", or an empty string when none would.
340
+ static func _shown_by(state: Object, name: String, listed: Dictionary) -> String:
341
+ for option: Dictionary in listed.values():
342
+ var other: String = option["name"]
343
+ var held: Variant = state.get(other)
344
+ var tries: Dictionary = {}
345
+ if option["type"] == TYPE_BOOL:
346
+ tries[not held] = str(not held)
347
+ elif option["type"] == TYPE_INT and option["hint"] == PROPERTY_HINT_ENUM:
348
+ var choices: PackedStringArray = str(option["hintString"]).split(",", false)
349
+ for at: int in choices.size():
350
+ var label: String = choices[at].get_slice(":", 0)
351
+ tries[int(choices[at].get_slice(":", 1)) if choices[at].contains(":") else at] = label
352
+ for value: Variant in tries:
353
+ if _same(value, held):
354
+ continue
355
+ state.set(other, value)
356
+ var shows: bool = _lists(state, name)
357
+ state.set(other, held)
358
+ if shows:
359
+ return "%s is %s" % [other, tries[value]]
360
+ return ""
361
+
362
+
363
+ static func _lists(state: Object, name: String) -> bool:
364
+ for property: Dictionary in state.get_property_list():
365
+ if str(property.get("name", "")) == name:
366
+ return true
367
+ return false
368
+
369
+
370
+ ## Each option [param state] lists, by name, described with the default [param fresh] holds, and
371
+ ## the options every platform has behind Advanced Options.
372
+ static func _options_shown(state: Object, fresh: Object) -> Dictionary:
373
+ var listed: Dictionary = {}
374
+ for property: Dictionary in state.get_property_list():
375
+ var usage: int = GdhRead.as_int(property.get("usage", 0))
376
+ var name: String = str(property.get("name", ""))
377
+ if usage & PROPERTY_USAGE_CATEGORY or usage & PROPERTY_USAGE_GROUP or not name.contains("/"):
378
+ continue
379
+ listed[name] = {
380
+ "name": name,
381
+ "type": GdhRead.as_int(property.get("type", TYPE_NIL)),
382
+ "hint": GdhRead.as_int(property.get("hint", PROPERTY_HINT_NONE)),
383
+ "hintString": str(property.get("hint_string", "")),
384
+ "default": fresh.get(name),
385
+ "defaultText": var_to_str(fresh.get(name)),
386
+ "secret": usage & PROPERTY_USAGE_SECRET != 0,
387
+ }
388
+ for name: String in TEMPLATE_OPTIONS:
389
+ if not listed.has(name):
390
+ listed[name] = {
391
+ "name": name,
392
+ "type": TYPE_STRING,
393
+ "hint": PROPERTY_HINT_GLOBAL_FILE,
394
+ "hintString": "",
395
+ "default": "",
396
+ "defaultText": var_to_str(""),
397
+ "secret": false,
398
+ }
399
+ return listed
400
+
401
+
402
+ ## Changes what [param params] gives of a preset in export_presets.cfg: its export path, its
403
+ ## resource filter and the files it names, its include and exclude filters, its custom features and
404
+ ## its platform's options, each option checked against [code]known_options[/code], the list
405
+ ## [method export_options] answers for the preset's platform. Nothing is written when any of it is
406
+ ## refused.
407
+ func set_export_preset(params: Dictionary) -> Dictionary:
408
+ var loaded: Variant = _preset_named(str(params.get("preset", "")).strip_edges())
409
+ if loaded is String:
410
+ return _log.failure(str(loaded))
411
+ var found: Dictionary = loaded
412
+ var config: ConfigFile = found["config"]
413
+ var section: String = found["section"]
414
+ var platform: String = str(config.get_value(section, "platform", ""))
415
+ var top: Dictionary = {}
416
+ if params.has("output_path"):
417
+ top["export_path"] = str(params["output_path"]).trim_prefix("res://")
418
+ for key: String in ["include_filter", "exclude_filter", "custom_features"]:
419
+ if params.has(key):
420
+ top[key] = str(params[key])
421
+ var filter: String = str(
422
+ params.get("export_filter", config.get_value(section, "export_filter", "all_resources"))
423
+ )
424
+ if params.has("export_filter"):
425
+ if filter not in FILTERS:
426
+ return _log.failure(GdhRead.offered("export_filter takes no %s" % filter, filter, FILTERS) + ".")
427
+ top["export_filter"] = filter
428
+ if params.has("files"):
429
+ var listed: Variant = _files_for(filter, params["files"])
430
+ if listed is String:
431
+ return _log.failure(str(listed))
432
+ top["export_files"] = listed
433
+ elif filter != "all_resources" and params.has("export_filter"):
434
+ var kept: Variant = config.get_value(section, "export_files", PackedStringArray())
435
+ var kept_files: PackedStringArray = kept if kept is PackedStringArray else PackedStringArray()
436
+ if kept is PackedStringArray and kept_files.is_empty():
437
+ return (
438
+ _log
439
+ . failure(
440
+ (
441
+ "export_filter %s exports the files the preset lists, and it lists none: give them in files."
442
+ % filter
443
+ )
444
+ )
445
+ )
446
+ var options: Variant = params.get("options", {})
447
+ if not options is Dictionary:
448
+ return _log.failure("options is an object of option names and values.")
449
+ var asked: Dictionary = options
450
+ var known: Variant = params.get("known_options", [])
451
+ var hidden: Dictionary = {}
452
+ if params.get("hidden_options") is Dictionary:
453
+ hidden = params["hidden_options"]
454
+ var written: Variant = _options_for(platform, asked, known, hidden)
455
+ if written is String:
456
+ return _log.failure(str(written))
457
+ var values: Dictionary = written
458
+ if top.is_empty() and values.is_empty():
459
+ return _log.failure(
460
+ (
461
+ "Nothing to change: give outputPath, exportFilter, files, includeFilter, excludeFilter, "
462
+ + "customFeatures or options."
463
+ )
464
+ )
465
+ var changed: Dictionary = {}
466
+ var unchanged: Array[String] = []
467
+ for key: String in top:
468
+ var before: Variant = config.get_value(section, key) if config.has_section_key(section, key) else null
469
+ if _same(before, top[key]):
470
+ unchanged.append(key)
471
+ continue
472
+ changed[key] = {"before": before, "now": top[key]}
473
+ config.set_value(section, key, top[key])
474
+ var described: Dictionary = {}
475
+ if known is Array:
476
+ for option: Variant in known:
477
+ if option is Dictionary:
478
+ var one: Dictionary = option
479
+ described[str(one.get("name", ""))] = one
480
+ var credentials: ConfigFile = ConfigFile.new()
481
+ var _no_credentials: Error = credentials.load(CREDENTIALS_FILE)
482
+ var secrets: Array[String] = []
483
+ var missing: Array[String] = []
484
+ var missing_secrets: Array[String] = []
485
+ for name: String in values:
486
+ var option: Dictionary = described.get(name, {})
487
+ var secret: bool = GdhRead.as_bool(option.get("secret", false))
488
+ # The editor writes a secret where the project's version control does not reach.
489
+ var holder: ConfigFile = credentials if secret else config
490
+ var held: Variant = str_to_var(str(option.get("defaultText", "null")))
491
+ if holder.has_section_key(section + ".options", name):
492
+ held = holder.get_value(section + ".options", name)
493
+ if _same(held, values[name]):
494
+ unchanged.append(name)
495
+ continue
496
+ holder.set_value(section + ".options", name, values[name])
497
+ if secret:
498
+ secrets.append(name)
499
+ changed[name] = {"secret": true}
500
+ else:
501
+ changed[name] = {"before": held, "now": values[name]}
502
+ var gone: String = _missing_path(values[name], option)
503
+ if not gone.is_empty() and secret:
504
+ missing_secrets.append(name)
505
+ elif not gone.is_empty():
506
+ missing.append(gone)
507
+ var answer: Dictionary = {
508
+ "preset": config.get_value(section, "name"),
509
+ "platform": platform,
510
+ "changed": changed,
511
+ "file": PRESETS_FILE,
512
+ }
513
+ if not unchanged.is_empty():
514
+ answer["unchanged"] = unchanged
515
+ if changed.is_empty():
516
+ answer["saved"] = false
517
+ return answer
518
+ if not secrets.is_empty():
519
+ var _made: Error = DirAccess.make_dir_recursive_absolute(CREDENTIALS_FILE.get_base_dir())
520
+ var keeping: Error = credentials.save(CREDENTIALS_FILE)
521
+ if keeping != OK:
522
+ return _log.failure("Failed to save %s: %s" % [CREDENTIALS_FILE, error_string(keeping)])
523
+ answer["credentials"] = CREDENTIALS_FILE
524
+ answer["secretNote"] = (
525
+ (
526
+ "%s %s secret, so %s written to %s, where the editor keeps secrets out of the "
527
+ + "export_presets.cfg a project commits, and %s not answered."
528
+ )
529
+ % [
530
+ _and_joined(secrets),
531
+ "is a" if secrets.size() == 1 else "are",
532
+ "it is" if secrets.size() == 1 else "they are",
533
+ CREDENTIALS_FILE,
534
+ "its value is" if secrets.size() == 1 else "their values are",
535
+ ]
536
+ )
537
+ var saving: Error = config.save(PRESETS_FILE)
538
+ if saving != OK:
539
+ return _log.failure("Failed to save export_presets.cfg: " + error_string(saving))
540
+ # Written as given, since the file can come after the preset names it; said, since an export can
541
+ # fail on it until it does. A secret's path is a secret too, so only its option is named.
542
+ if not missing.is_empty():
543
+ answer["missing_files"] = missing
544
+ if not missing_secrets.is_empty():
545
+ answer["secretsNamingNoFile"] = missing_secrets
546
+ var absent: Array[String] = missing.duplicate()
547
+ for name: String in missing_secrets:
548
+ absent.append("the file %s names" % name)
549
+ if not absent.is_empty():
550
+ answer["note"] = (
551
+ (
552
+ "%s %s not exist, and an exporter can refuse the preset until %s, as the Windows "
553
+ + "exporter refuses a missing icon as an invalid icon path."
554
+ )
555
+ % [
556
+ _and_joined(absent),
557
+ "does" if absent.size() == 1 else "do",
558
+ "it does" if absent.size() == 1 else "they do",
559
+ ]
560
+ )
561
+ return answer
562
+
563
+
564
+ static func _and_joined(items: Array[String]) -> String:
565
+ if items.size() < 2:
566
+ return "".join(items)
567
+ return ", ".join(items.slice(0, items.size() - 1)) + " and " + items[items.size() - 1]
568
+
569
+
570
+ ## Removes a preset from export_presets.cfg, numbering those after it down by one as the editor
571
+ ## saves them, and gives its platform's runnable mark to the first other preset of that platform.
572
+ func remove_export_preset(params: Dictionary) -> Dictionary:
573
+ var preset_name: String = str(params.get("preset", "")).strip_edges()
574
+ var loaded: Variant = _preset_named(preset_name)
575
+ if loaded is String:
576
+ return _log.failure(str(loaded))
577
+ var found: Dictionary = loaded
578
+ var config: ConfigFile = found["config"]
579
+ var index: int = found["index"]
580
+ var section: String = found["section"]
581
+ var platform: String = str(config.get_value(section, "platform", ""))
582
+ var last: int = index
583
+ while config.has_section("preset." + str(last + 1)):
584
+ last += 1
585
+ _renumber(config, index, last)
586
+ var answer: Dictionary = {"removed": preset_name, "platform": platform, "file": PRESETS_FILE}
587
+ # The secrets are kept by the same numbers, so left alone they would pass to the next preset.
588
+ var credentials: ConfigFile = ConfigFile.new()
589
+ if FileAccess.file_exists(CREDENTIALS_FILE) and credentials.load(CREDENTIALS_FILE) == OK:
590
+ _renumber(credentials, index, last)
591
+ var keeping: Error = credentials.save(CREDENTIALS_FILE)
592
+ if keeping != OK:
593
+ return _log.failure("Failed to save %s: %s" % [CREDENTIALS_FILE, error_string(keeping)])
594
+ answer["credentials"] = CREDENTIALS_FILE
595
+ if str(config.get_value(RUNNABLE_SECTION, platform, "")) == preset_name:
596
+ config.set_value(RUNNABLE_SECTION, platform, null)
597
+ answer["runnableNow"] = null
598
+ var at: int = 0
599
+ while config.has_section("preset." + str(at)):
600
+ if str(config.get_value("preset." + str(at), "platform", "")) == platform:
601
+ var next: String = str(config.get_value("preset." + str(at), "name", ""))
602
+ config.set_value(RUNNABLE_SECTION, platform, next)
603
+ answer["runnableNow"] = next
604
+ break
605
+ at += 1
606
+ var saving: Error = config.save(PRESETS_FILE)
607
+ if saving != OK:
608
+ return _log.failure("Failed to save export_presets.cfg: " + error_string(saving))
609
+ answer["presets"] = names()
610
+ return answer
611
+
612
+
613
+ ## [param config] with preset [param index] taken out and those after it, up to [param last],
614
+ ## numbered down by one.
615
+ static func _renumber(config: ConfigFile, index: int, last: int) -> void:
616
+ for at: int in range(index, last + 1):
617
+ for part: String in ["", ".options"]:
618
+ var into: String = "preset.%d%s" % [at, part]
619
+ var from: String = "preset.%d%s" % [at + 1, part]
620
+ if config.has_section(into):
621
+ config.erase_section(into)
622
+ if at < last and config.has_section(from):
623
+ for key: String in config.get_section_keys(from):
624
+ config.set_value(into, key, config.get_value(from, key))
625
+
626
+
627
+ ## export_presets.cfg loaded, with the section and index of the preset called [param preset_name],
628
+ ## or why there is none.
629
+ static func _preset_named(preset_name: String) -> Variant:
630
+ if preset_name.is_empty():
631
+ return "preset is required: the name of the preset in export_presets.cfg"
632
+ if not FileAccess.file_exists(PRESETS_FILE):
633
+ return "This project has no export_presets.cfg; project_export add_preset adds a preset."
634
+ var config: ConfigFile = ConfigFile.new()
635
+ var loading: Error = config.load(PRESETS_FILE)
636
+ if loading != OK:
637
+ return "Failed to parse export_presets.cfg: " + error_string(loading)
638
+ var held: Array[String] = names()
639
+ var index: int = held.find(preset_name)
640
+ if index == -1:
641
+ if held.is_empty():
642
+ return "export_presets.cfg has no preset, so none called %s." % preset_name
643
+ return GdhRead.offered("There is no export preset %s" % preset_name, preset_name, held) + "."
644
+ return {"config": config, "section": "preset." + str(index), "index": index}
645
+
646
+
647
+ ## The files a preset exporting by [param filter] lists, from [param given], or why they are not.
648
+ static func _files_for(filter: String, given: Variant) -> Variant:
649
+ if filter == "all_resources":
650
+ return (
651
+ "files are what a preset exports when export_filter is scenes, resources or exclude, and "
652
+ + "this one exports all_resources: give exportFilter too."
653
+ )
654
+ if not given is Array:
655
+ return "files is a list of res:// paths."
656
+ var listed: PackedStringArray = []
657
+ var missing: PackedStringArray = []
658
+ var not_scenes: PackedStringArray = []
659
+ for entry: Variant in given:
660
+ var path: String = str(entry)
661
+ if not path.begins_with("res://"):
662
+ path = "res://" + path
663
+ if not FileAccess.file_exists(path):
664
+ var _missing: bool = missing.append(path)
665
+ elif filter == "scenes" and path.get_extension() not in ["tscn", "scn"]:
666
+ var _not_scene: bool = not_scenes.append(path)
667
+ elif not listed.has(path):
668
+ var _listed: bool = listed.append(path)
669
+ if not missing.is_empty():
670
+ return (
671
+ "Nothing was written: %s %s not in the project."
672
+ % [", ".join(missing), "is" if missing.size() == 1 else "are"]
673
+ )
674
+ if not not_scenes.is_empty():
675
+ return (
676
+ "Nothing was written: export_filter scenes lists scenes, and %s %s not one; resources lists any file."
677
+ % [", ".join(not_scenes), "is" if not_scenes.size() == 1 else "are"]
678
+ )
679
+ return listed
680
+
681
+
682
+ ## The values [param asked] gives [param platform]'s options, each fitted to the option's type in
683
+ ## [param known], or why one is refused. [param hidden] says, for an option the preset has and does
684
+ ## not list as it would stand, what would list it.
685
+ static func _options_for(platform: String, asked: Dictionary, known: Variant, hidden: Dictionary) -> Variant:
686
+ var values: Dictionary = {}
687
+ if asked.is_empty():
688
+ return values
689
+ var by_name: Dictionary = {}
690
+ if known is Array:
691
+ for option: Variant in known:
692
+ if option is Dictionary:
693
+ var described: Dictionary = option
694
+ by_name[str(described.get("name", ""))] = described
695
+ for key: Variant in asked:
696
+ var name: String = str(key)
697
+ if not by_name.has(name) and hidden.has(name):
698
+ return (
699
+ (
700
+ "A %s preset lists %s only while %s, as the Export dialog shows it, and this one "
701
+ + "would not: give %s in the same call, or set it first."
702
+ )
703
+ % [platform, name, hidden[name], str(hidden[name]).replace(" is ", " ")]
704
+ )
705
+ if not by_name.has(name):
706
+ return (
707
+ GdhRead.offered("A %s preset has no option %s" % [platform, name], name, by_name.keys()) + "."
708
+ )
709
+ var option: Dictionary = by_name[name]
710
+ var fitted: Variant = _fitted(name, asked[key], option)
711
+ if _is_refusal(fitted):
712
+ var refusal: Dictionary = fitted
713
+ return str(refusal["refused"])
714
+ values[name] = fitted
715
+ return values
716
+
717
+
718
+ static func _is_refusal(fitted: Variant) -> bool:
719
+ if not fitted is Dictionary:
720
+ return false
721
+ var held: Dictionary = fitted
722
+ return held.has("refused")
723
+
724
+
725
+ ## Whether [param before] already is [param now], in its type too, so a write of it changes nothing.
726
+ static func _same(before: Variant, now: Variant) -> bool:
727
+ return typeof(before) == typeof(now) and before == now
728
+
729
+
730
+ ## The file [param value] names for [param option] that is not there, or an empty string: a res://
731
+ ## path, or a path on the machine for an option taking one, such as a custom template.
732
+ static func _missing_path(value: Variant, option: Dictionary) -> String:
733
+ if not value is String or str(value).is_empty():
734
+ return ""
735
+ var path: String = value
736
+ var hint: int = GdhRead.as_int(option.get("hint", PROPERTY_HINT_NONE))
737
+ var on_machine: bool = (
738
+ hint in [PROPERTY_HINT_GLOBAL_FILE, PROPERTY_HINT_GLOBAL_DIR] and path.is_absolute_path()
739
+ )
740
+ if not path.begins_with("res://") and not on_machine:
741
+ return ""
742
+ if FileAccess.file_exists(path) or DirAccess.dir_exists_absolute(path):
743
+ return ""
744
+ return path
745
+
746
+
747
+ ## Each file the preset [param preset_name] names that is not there, as the option or list naming
748
+ ## it and the path: an option's res:// path or custom template, or a file export_files lists. A
749
+ ## secret's path is left out, as every answer leaves out a secret's value.
750
+ static func missing_named(preset_name: String) -> Array[String]:
751
+ var missing: Array[String] = []
752
+ var loaded: Variant = _preset_named(preset_name)
753
+ if not loaded is Dictionary:
754
+ return missing
755
+ var found: Dictionary = loaded
756
+ var config: ConfigFile = found["config"]
757
+ var section: String = found["section"]
758
+ # A secret can name a file too, as an Android preset's keystore does.
759
+ var credentials: ConfigFile = ConfigFile.new()
760
+ var _no_credentials: Error = credentials.load(CREDENTIALS_FILE)
761
+ for holder: ConfigFile in [config, credentials]:
762
+ if not holder.has_section(section + ".options"):
763
+ continue
764
+ for key: String in holder.get_section_keys(section + ".options"):
765
+ var option: Dictionary = {"hint": PROPERTY_HINT_GLOBAL_FILE} if key in TEMPLATE_OPTIONS else {}
766
+ var gone: String = _missing_path(holder.get_value(section + ".options", key), option)
767
+ if gone.is_empty():
768
+ continue
769
+ if holder == credentials:
770
+ missing.append("%s (a secret, so its path is left out)" % key)
771
+ else:
772
+ missing.append("%s: %s" % [key, gone])
773
+ var listed: Variant = config.get_value(section, "export_files", PackedStringArray())
774
+ if listed is PackedStringArray:
775
+ var files: PackedStringArray = listed
776
+ for path: String in files:
777
+ if not FileAccess.file_exists(path):
778
+ missing.append("export_files: " + path)
779
+ return missing
780
+
781
+
782
+ ## [param value] as option [param name], described by [param option], holds it, or {refused} saying
783
+ ## why it cannot.
784
+ static func _fitted(name: String, value: Variant, option: Dictionary) -> Variant:
785
+ var type: int = GdhRead.as_int(option.get("type", TYPE_NIL))
786
+ var hint: int = GdhRead.as_int(option.get("hint", PROPERTY_HINT_NONE))
787
+ var choices: PackedStringArray = str(option.get("hintString", "")).split(",", false)
788
+ var shown: String = JSON.stringify(value)
789
+ if type not in WRITABLE:
790
+ return {
791
+ "refused":
792
+ (
793
+ "%s is a %s, which this tool does not write; the editor's Export dialog does."
794
+ % [name, type_string(type)]
795
+ )
796
+ }
797
+ if type == TYPE_BOOL:
798
+ return value if value is bool else {"refused": "%s is true or false, not %s." % [name, shown]}
799
+ if type == TYPE_INT and hint == PROPERTY_HINT_ENUM:
800
+ var named: PackedStringArray = []
801
+ for at: int in choices.size():
802
+ var choice: String = choices[at].get_slice(":", 0)
803
+ var number: int = int(choices[at].get_slice(":", 1)) if choices[at].contains(":") else at
804
+ var _named: bool = named.append(choice)
805
+ if str(value) == choice or ((value is int or value is float) and value == number):
806
+ return number
807
+ return {"refused": "%s is one of %s, not %s." % [name, ", ".join(named), shown]}
808
+ if type == TYPE_INT:
809
+ if value is int:
810
+ return value
811
+ if value is float:
812
+ var whole: float = value
813
+ if whole == floorf(whole):
814
+ return int(whole)
815
+ return {"refused": "%s is a whole number, not %s." % [name, shown]}
816
+ if type == TYPE_FLOAT:
817
+ if value is int or value is float:
818
+ var amount: float = value
819
+ return amount
820
+ return {"refused": "%s is a number, not %s." % [name, shown]}
821
+ if type == TYPE_STRING:
822
+ if not value is String:
823
+ return {"refused": "%s is text, not %s." % [name, shown]}
824
+ if hint == PROPERTY_HINT_ENUM and not choices.is_empty() and str(value) not in choices:
825
+ return {"refused": "%s is one of %s, not %s." % [name, ", ".join(choices), shown]}
826
+ return value
827
+ if not value is Array:
828
+ return {"refused": "%s is a list of text, not %s." % [name, shown]}
829
+ var items: Array = value
830
+ return PackedStringArray(items)