-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathtransform_editor_plugin.cpp
More file actions
1904 lines (1790 loc) · 84.9 KB
/
Copy pathtransform_editor_plugin.cpp
File metadata and controls
1904 lines (1790 loc) · 84.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-License-Identifier: MPL-2.0
//
// Transform Editor toolbox plugin for PlotJuggler 4.
// Ports the PJ3 "Custom Series" / Function Editor. Identical UI to PJ3.
// Motor: pj.data_processors.v1 — onSave hands the host a self-describing Luau class
// (N inputs -> M outputs) run live as a DerivedEngine node. Requires SDK >= 0.12.0.
// Preview uses createEphemeralTransform (SDK 0.12+) — no local Lua runtime.
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <functional>
#include <nlohmann/json.hpp>
#include <optional>
#include <pj_base/sdk/platform.hpp>
#include <pj_base/sdk/plugin_data_api.hpp>
#include <pj_base/sdk/service_traits.hpp>
#include <pj_base/sdk/toolbox_plugin_base.hpp>
#include <pj_plugins/sdk/dialog_plugin_typed.hpp>
#include <pj_plugins/sdk/widget_data.hpp>
#include <sstream>
#include <string>
#include <vector>
#include "transform_editor_dialog_ui.hpp"
#include "transform_editor_manifest.hpp"
namespace {
// Help dialog UI (cloned from the native TransformEditorHelp.ui). Shown via
// WidgetData::requestSubDialog when either Help button is clicked — same content
// for both the Single Function and Batch Functions tabs.
constexpr const char* kHelpDialogUi = R"PJHELP(<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TransformEditorHelp</class>
<widget class="QDialog" name="TransformEditorHelp">
<property name="geometry"><rect><x>0</x><y>0</y><width>800</width><height>600</height></rect></property>
<property name="windowTitle"><string>Help</string></property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTextBrowser" name="textBrowser">
<property name="openExternalLinks"><bool>true</bool></property>
<property name="html">
<string><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
<html><head><meta name="qrichtext" content="1" /><style type="text/css">
p, li { white-space: pre-wrap; }
</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;">
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:14pt; font-weight:600;">Transform editor Help</span></p>
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:14pt; font-weight:600;"><br /></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The transform editor uses the scripting language Lua. You may want to read <a href="http://tylerneylon.com/a/learn-lua/"><span style=" text-decoration: underline; color:#0000ff;">Learn Lua in 15 minutes</span></a>.</p>
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">In practice, you don't need to know that much to use it in PlotJuggler.</p>
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">You should implement the body of a <span style=" font-style:italic; color:#204a87;">function(time, value),</span> that is invoked for each point. The returned value is the point [time, your_value]</p>
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Single input, stateless functions:</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Example:</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return (value * 2) + 1</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">or you can use the <a href="https://www.tutorialspoint.com/lua/lua_math_library.htm"><span style=" text-decoration: underline; color:#0000ff;">math library from Lua</span></a>:</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return math.log(value)</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The new point will have the same time of the original point. If you want to modify the timestamp, just return two values like this:</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return time +1, (value * 2) </span></p>
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Return multiple points at once:</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Since version 3.1, you may also return multiple values at once, using a Lua table:</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> -- Calculate t1,v1, t2, v2, etc...</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return { {t1, v1}, {t2, v2} }</span></p>
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Stateful functions:</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Some transformations require some form of &quot;memory&quot;, i.e. they need the previous value. To do this you may use the &quot;global variable&quot; textbox.</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">For instance, to compute the finite difference between two consecutive value, you would add this to the <span style=" font-weight:600;">Global Variables</span> textbox:</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> prev = nil</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">And this to the <span style=" font-weight:600;">Function() </span>textbox</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> if ( prev == nil ) then</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> prev = value</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> end</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> diff = value - prev</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> prev = value</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return diff </span></p>
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Multi input, single output:</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Select an additional time series and drop it in the table on the left side. Now your function definition will look like <span style=" font-style:italic; color:#204a87;">function(time,value,v1)</span></p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Average of two time series:</p>
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return (value + v1) / 2</span></p></body></html></string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation"><enum>Qt::Horizontal</enum></property>
<property name="standardButtons"><set>QDialogButtonBox::Close</set></property>
</widget>
</item>
</layout>
</widget>
</ui>)PJHELP";
// Function Library dialog UI (cloned from PJ3's functions_library.ui / the
// buttonLibraryBox box). Shown via WidgetData::requestSubPanel as a LIVE,
// interactive sub-panel: the search box filters as you type, the table selection
// drives the preview, and double-click / Use loads the function into the editor.
constexpr const char* kFunctionLibraryUi = R"PJLIB(<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>FunctionsLibrary</class>
<widget class="QDialog" name="FunctionsLibrary">
<property name="geometry"><rect><x>0</x><y>0</y><width>440</width><height>470</height></rect></property>
<property name="minimumSize"><size><width>360</width><height>320</height></size></property>
<property name="windowTitle"><string>Function Library</string></property>
<layout class="QVBoxLayout" name="libVerticalLayout">
<item>
<widget class="QLineEdit" name="searchLineEdit">
<property name="placeholderText"><string>Search</string></property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelPreview">
<property name="font"><font><weight>75</weight><bold>true</bold></font></property>
<property name="text"><string>Function Preview:</string></property>
</widget>
</item>
<item>
<widget class="QTableWidget" name="tableFunctions">
<property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property>
<property name="selectionMode"><enum>QAbstractItemView::ExtendedSelection</enum></property>
<property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property>
<attribute name="horizontalHeaderVisible"><bool>false</bool></attribute>
<attribute name="verticalHeaderVisible"><bool>false</bool></attribute>
</widget>
</item>
<item>
<widget class="QPlainTextEdit" name="previewPlainText">
<property name="minimumSize"><size><width>0</width><height>100</height></size></property>
<property name="readOnly"><bool>true</bool></property>
<property name="styleSheet"><string notr="true">QPlainTextEdit { background-color: #ffffff; color: #000000; border: 1px solid #888; }</string></property>
<property name="font"><font><family>DejaVu Sans Mono</family><pointsize>10</pointsize></font></property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="libButtonsRow">
<item>
<widget class="QLabel" name="labelTip">
<property name="font"><font><italic>true</italic></font></property>
<property name="text"><string>Ctrl+Click to select multiple</string></property>
</widget>
</item>
<item>
<spacer name="libSpacer">
<property name="orientation"><enum>Qt::Horizontal</enum></property>
<property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="useButton"><property name="text"><string>Use</string></property></widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>)PJLIB";
// "Save current function" name prompt (PJ3 parity: QInputDialog asking for the
// function name, prefilled with the current name). Shown as a modal sub-dialog;
// the host harvests `saveFunctionName` and emits subDialogAccepted on OK.
constexpr const char* kSaveNameUi = R"PJSAVE(<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SaveFunctionName</class>
<widget class="QDialog" name="SaveFunctionName">
<property name="geometry"><rect><x>0</x><y>0</y><width>360</width><height>120</height></rect></property>
<property name="windowTitle"><string>Name of the Function</string></property>
<layout class="QVBoxLayout" name="saveNameLayout">
<item>
<widget class="QLabel" name="saveNameLabel"><property name="text"><string>Name:</string></property></widget>
</item>
<item>
<widget class="QLineEdit" name="saveFunctionName"/>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>)PJSAVE";
// Overwrite confirmation (PJ3 parity: warn when a function with the same name
// already exists). OK = overwrite, Cancel = abort.
constexpr const char* kOverwriteUi = R"PJOVW(<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>OverwriteFunction</class>
<widget class="QDialog" name="OverwriteFunction">
<property name="geometry"><rect><x>0</x><y>0</y><width>400</width><height>130</height></rect></property>
<property name="windowTitle"><string>Warning</string></property>
<layout class="QVBoxLayout" name="overwriteLayout">
<item>
<widget class="QLabel" name="overwriteLabel">
<property name="wordWrap"><bool>true</bool></property>
<property name="text"><string>A function with the same name exists already in the list of saved functions.
Overwrite it?</string></property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>)PJOVW";
// ---------------------------------------------------------------------------
// Snippet
// ---------------------------------------------------------------------------
struct Snippet {
std::string name;
std::string global_code;
std::string function_body;
std::string language = "luau"; // "luau" | "python"; legacy/builtin snippets are Luau
};
std::filesystem::path snippetLibraryPath() {
return PJ::sdk::userDataDir() / "toolbox_transform_editor" / "snippets.json";
}
// Write the snippet library as a JSON array to `path`, creating parent dirs.
// Returns false if the directory can't be created or the file can't be written
// (callers that surface Export feedback rely on this; autosave ignores it).
// Shared by the fixed-location persistence and the user-chosen Export target.
bool saveSnippetsToPath(const std::vector<Snippet>& snippets, const std::filesystem::path& path) {
try {
std::filesystem::create_directories(path.parent_path());
nlohmann::json j = nlohmann::json::array();
for (const auto& s : snippets) {
j.push_back(
{{"name", s.name},
{"global_code", s.global_code},
{"function_body", s.function_body},
{"language", s.language}});
}
std::ofstream out(path);
if (!out) {
return false;
}
out << j.dump(2);
return out.good();
} catch (...) {
return false;
}
}
void saveSnippetsToDisk(const std::vector<Snippet>& snippets) {
saveSnippetsToPath(snippets, snippetLibraryPath()); // best-effort autosave; failures are non-fatal
}
// Default snippets ported from PJ3's default.snippets.xml
std::vector<Snippet> defaultSnippets() {
return {
{"backward_difference_derivative", "prevX = 0\nprevY = 0\nis_first = true",
"if (is_first) then\n is_first = false\n prevX = time\n prevY = value\nend\n\ndx = time - prevX\ndy = value "
"- prevY\nprevX = time\nprevY = value\n\nreturn dy/dx"},
{"central_difference_derivative",
"firstX = 0\nfirstY = 0\nis_first = true\nsecondX = 0\nsecondY = 0\nis_second = false",
"if (is_first) then\n is_first = false\n is_second = true\n firstX = time\n firstY = value\nend\n\nif "
"(is_second) then\n is_second = false\n secondX = time\n secondY = value\nend\n\ndx = time - firstX\ndy = "
"value - firstY\nfirstX = secondX\nfirstY = secondY\nsecondX = time\nsecondY = value\n\nreturn dy/dx"},
{"average_two_curves", "", "return (value+v1)/2"},
{"integral", "prevX = 0\nintegral = 0\nis_first = true",
"if (is_first) then\n is_first = false\n prevX = time\nend\n\ndx = time - prevX\nprevX = time\nintegral = "
"integral + value*dx\n\nreturn integral"},
{"rad_to_deg", "", "return value*180/3.14159"},
{"remove_offset", "is_first = true\nfirst_value = 0",
"if (is_first) then\n is_first = false\n first_value = value\nend\n\nreturn value - first_value"},
{"quat_to_roll", "",
"w = value\nx = v1\ny = v2\nz = v3\n\ndcm21 = 2 * (w * x + y * z)\ndcm22 = w*w - x*x - y*y + z*z\n\nroll = "
"math.atan2(dcm21, dcm22)\n\nreturn roll"},
{"quat_to_pitch", "",
"w = value\nx = v1\ny = v2\nz = v3\n\ndcm20 = 2 * (x * z - w * y)\n\npitch = math.asin(-dcm20)\n\nreturn pitch"},
{"quat_to_yaw", "",
"w = value\nx = v1\ny = v2\nz = v3\n\ndcm10 = 2 * (x * y + w * z)\ndcm00 = w*w + x*x - y*y - z*z\n\nyaw = "
"math.atan2(dcm10, dcm00)\n\nreturn yaw"},
};
}
// Read a snippet library (JSON array) from `path`. Returns the parsed snippets
// (possibly empty, for a "[]" library), or nullopt when the file cannot be
// opened or does not hold a JSON array. The nullopt vs empty distinction lets
// callers tell "no readable library" apart from "an explicitly empty one".
// Shared by the fixed-location load and the user-chosen Import source.
std::optional<std::vector<Snippet>> loadSnippetsFromPath(const std::filesystem::path& path) {
std::ifstream in(path);
if (!in) {
return std::nullopt;
}
std::stringstream buf;
buf << in.rdbuf();
auto j = nlohmann::json::parse(buf.str(), nullptr, false);
if (!j.is_array()) {
return std::nullopt;
}
std::vector<Snippet> result;
for (auto& item : j) {
if (!item.is_object()) {
continue;
}
result.push_back(
{item.value("name", std::string{}), item.value("global_code", std::string{}),
item.value("function_body", std::string{}), item.value("language", std::string{"luau"})});
}
return result;
}
// The persisted library, or the built-in defaults when none can be read yet —
// a missing, unreadable, or corrupt file all fall back to the defaults (so a
// transient read failure never silently presents an empty library).
std::vector<Snippet> loadSnippetsFromDisk() {
if (auto loaded = loadSnippetsFromPath(snippetLibraryPath())) {
return std::move(*loaded);
}
return defaultSnippets();
}
// The output-name field doubles as a comma-separated list: "roll,pitch,yaw" declares
// three output topics and the body must `return r, p, q` (M values, positional).
// Whitespace around each name is trimmed; empty entries drop. One name => one output
// (the common case).
inline std::vector<std::string> splitOutputNames(const std::string& field) {
std::vector<std::string> names;
std::size_t start = 0;
while (start <= field.size()) {
const std::size_t comma = field.find(',', start);
const std::size_t end = (comma == std::string::npos) ? field.size() : comma;
std::string name = field.substr(start, end - start);
const std::size_t b = name.find_first_not_of(" \t");
const std::size_t e = name.find_last_not_of(" \t");
if (b != std::string::npos) {
names.push_back(name.substr(b, e - b + 1));
}
if (comma == std::string::npos) {
break;
}
start = comma + 1;
}
return names;
}
// Build a complete self-describing Luau FILTER CLASS the host can compile and run
// live as a DerivedEngine node (via createTransform). The user's global code runs
// once per instance inside a factory closure, so its locals persist across calls
// (PJ3 global-variable semantics); the body becomes the per-sample function with
// `time`/`value` (and `v1..vN` for the additional sources) in scope.
//
// `num_extra` is the count of additional sources: the body function takes
// `(time, value, v1, …, v<num_extra>)`, matching the host's MIMO calculate contract
// (calculate(self, t, v, v1..vN-1) — see pj_scripting FILTER_CLASS.md). `:calculate`
// forwards its args with `...`, so any arity works; the named params just give the
// body the v1..vN identifiers. Output count is decided host-side by the `outputs`
// passed to createTransform — `:calculate` returns the body's results unchanged
// (MULTRET), so a body that `return`s M values feeds M output topics.
inline std::string buildTransformScript(
const std::string& id, const std::string& name, const std::string& global_code, const std::string& body,
std::size_t num_extra, const std::string& language = "luau") {
std::string params = "time, value";
for (std::size_t k = 0; k < num_extra; ++k) {
params += ", v" + std::to_string(k + 1);
}
// Python backend: emit a module with a top-level class `T` (see pj_scripting's
// python_engine.h). The global section runs once at module level (so `global`
// persistent state works, PJ3-style); the body becomes the function. Python is
// whitespace-sensitive, so every body line is indented one level.
if (language == "python") {
const auto indent_block = [](const std::string& code) {
std::string out;
std::size_t start = 0;
while (start <= code.size()) {
const std::size_t nl = code.find('\n', start);
const std::string line = code.substr(start, nl == std::string::npos ? std::string::npos : nl - start);
out += " " + line + "\n";
if (nl == std::string::npos) {
break;
}
start = nl + 1;
}
return out;
};
std::string src = "# pj-script: python\n";
if (!global_code.empty()) {
src += global_code + "\n\n";
}
src += "def _pj_fn(" + params + "):\n";
src += indent_block(body.empty() ? "return value" : body);
src += "\n";
src += "class T:\n";
src += " id = \"" + id + "\"\n";
src += " name = \"" + name + "\"\n";
src += " output = \"double\"\n";
src += " @staticmethod\n";
src += " def create(params):\n return T()\n";
src += " def calculate(self, time, value, *args):\n return _pj_fn(time, value, *args)\n";
return src;
}
// The header declares the backend; the host's inferTransformBackend reads it.
std::string src = "-- pj-script: " + language + "\n";
src += "local function _pj_make()\n";
src += global_code + "\n";
src += " return function(" + params + ")\n";
src += body + "\n";
src += " end\n";
src += "end\n";
src += "local T = { id = \"" + id + "\", name = \"" + name + "\", output = \"double\" }\n";
src += "T.__index = T\n";
src += "function T.create(_) return setmetatable({ fn = _pj_make() }, T) end\n";
src += "function T:calculate(t, v, ...) return self.fn(t, v, ...) end\n";
src += "return T\n";
return src;
}
// ---------------------------------------------------------------------------
// TransformEditorDialog
// ---------------------------------------------------------------------------
class TransformEditorDialog : public PJ::DialogPluginTyped {
using PJ::DialogPluginTyped::onValueChanged;
public:
std::string manifest() const override {
return kTransformEditorManifest;
}
std::string ui_content() const override {
return kTransformEditorDialogUi;
}
PJ::WidgetData buildWidgetData() {
PJ::WidgetData wd;
// One-shot after loadConfig (Modify): force the editor onto the tab the series
// was created from, and (for batch) re-select its source. Only once, so the
// user can freely switch tabs / change the selection afterwards.
if (pending_tab_restore_) {
wd.setTabIndex("tabWidget", current_tab_);
if (current_tab_ == 1) {
wd.setSelectedItems("listBatchSources", batch_selected_);
}
pending_tab_restore_ = false;
}
// Single function tab — one table of inputs (drop target). Col 0 is the radio
// marking which row provides `value`; col 1 is the series path the trash button
// acts on; col 2 is the bound variable. The host emits row selection from the
// first text column (col 1 here, as col 0 hosts the radio widget and carries no
// text). The non-primary rows are v1, v2, … in row order.
wd.setDropTarget("tableSources");
wd.setTableHeaders("tableSources", {"", "Input timeseries", "Var"});
std::vector<std::vector<std::string>> rows;
rows.reserve(sources_.size());
for (int i = 0; i < static_cast<int>(sources_.size()); ++i) {
rows.push_back({"", sources_[static_cast<std::size_t>(i)], variableName(i)});
}
wd.setTableRows("tableSources", rows);
wd.setTableRadioColumn("tableSources", 0, primaryIndex());
// Enabled only while a row is selected, so it can never delete the whole list
// on a single click when nothing is highlighted (and it disables again the
// moment the selection is cleared).
wd.setEnabled("pushButtonDeleteCurves", !selected_paths_.empty());
wd.setText("nameLineEdit", output_name_);
// Function signature reflects the inputs: `value` (the radio-selected row) plus
// v1..vN for the remaining series, so the user sees the identifier to reference
// for each one in the body.
const std::size_t num_extra = orderedExtras().size();
std::string signature = "function( time, value";
for (std::size_t i = 0; i < num_extra; ++i) {
signature += ", v" + std::to_string(i + 1);
}
signature += " )";
wd.setText("labelFunction", signature);
const char* single_lang = (language_ == "python") ? "python" : "lua";
wd.setCodeContent("globalVarsText", global_code_).setCodeLanguage("globalVarsText", single_lang);
wd.setCodeContent("functionText", function_body_).setCodeLanguage("functionText", single_lang);
// Reflect the active language on the radios (so loading a library snippet flips
// them, not just the user clicking). Pushed every tick; matches language_.
wd.setChecked("luaButton", language_ != "python");
wd.setChecked("pythonButton", language_ == "python");
// Function Library sub-panel (cloned from PJ3's buttonLibraryBox dialog).
// One-shot open/close commands, then live population while it is open.
if (emit_open_library_) {
wd.requestSubPanel(kFunctionLibraryUi);
emit_open_library_ = false;
}
if (emit_close_library_) {
wd.closeSubPanel();
emit_close_library_ = false;
}
// Save-current-function dialogs (PJ3 parity). Name prompt is prefilled with the
// current name; the overwrite warning only appears for an existing name.
if (emit_save_name_dialog_) {
wd.setText("saveFunctionName", pending_save_name_);
wd.requestSubDialog(kSaveNameUi);
emit_save_name_dialog_ = false;
}
if (emit_save_confirm_dialog_) {
wd.requestSubDialog(kOverwriteUi);
emit_save_confirm_dialog_ = false;
}
if (library_open_) {
const std::vector<std::string> names = filteredSnippetNames();
wd.setTableHeaders("tableFunctions", {"Function", "Language"});
std::vector<std::vector<std::string>> lib_rows;
lib_rows.reserve(names.size());
for (const auto& n : names) {
auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == n; });
const std::string lang = (it != snippets_.end() && it->language == "python") ? "Python" : "Lua";
lib_rows.push_back({n, lang});
}
wd.setTableRows("tableFunctions", lib_rows);
wd.setPlainText("previewPlainText", combinedSnippetText(library_selected_));
}
// Import / Export library buttons: the host drives the native file choosers.
// Import opens an "open" dialog and Export a "save as"; both report back via
// onFileSelected, routed by widget name. Complements the library browser above.
wd.setFilePicker("buttonLoadFunctions", "", "Snippet library (*.json)", "Import snippet library");
wd.setSaveFilePicker("buttonSaveFunctions", "", "Snippet library (*.json)", "Export snippet library", "json");
// Single-tab validation terminal — same as PJ3's onUpdatePreview: list every
// blocking problem; the terminal HIDES once the function is valid, and Create is
// enabled iff there are none. validation_error_ is the host's real compile/run
// error (refreshPreview ran the script as an ephemeral transform). PJ4 keeps its
// Modify-by-name behaviour, so an already-existing name is NOT an error here.
std::string single_term;
if (output_name_.empty()) {
single_term += "- Missing name of the new time series.\n";
} else if (std::find(sources_.begin(), sources_.end(), output_name_) != sources_.end()) {
single_term += "- The name of the new timeseries is the same of one of its inputs.\n";
}
if (sources_.empty()) {
single_term += "- Missing source time series.\n";
}
if (function_body_.empty()) {
single_term += "- Missing function body.\n";
}
if (!validation_error_.empty()) {
single_term += "- " + validation_error_ + "\n";
}
// A one-shot Import/Export status line shows in the terminal for this render
// (above any validation problems) and then clears on the next build.
if (!io_status_.empty()) {
single_term = single_term.empty() ? io_status_ : io_status_ + "\n" + single_term;
io_status_.clear();
}
// Red fill on the name field when it's missing (PJ3 parity).
wd.setFieldValid("nameLineEdit", !output_name_.empty(), output_name_.empty() ? "Name is required" : "");
wd.setPlainText("terminalPlainText", single_term);
// PJ3 parity: the chart and the terminal share the same area and are mutually
// exclusive — the graph only appears once the function is valid; while there are
// problems the terminal takes its place.
wd.setVisible("terminalPlainText", !single_term.empty());
wd.setVisible("framePlotPreview", single_term.empty());
// Create button enabled
// Batch tab content (set first so the validation terminal + Create gating below
// see the current state).
wd.setListItems("listBatchSources", batch_filtered_sources_);
const char* batch_lang = (batch_language_ == "python") ? "python" : "lua";
wd.setCodeContent("globalVarsTextBatch", batch_global_code_).setCodeLanguage("globalVarsTextBatch", batch_lang);
wd.setCodeContent("functionTextBatch", batch_function_body_).setCodeLanguage("functionTextBatch", batch_lang);
wd.setChecked("luaBatchButton", batch_language_ != "python");
wd.setChecked("pythonBatchButton", batch_language_ == "python");
// Batch validation terminal — same messages and behaviour as PJ3's
// onUpdatePreviewBatch (function_editor.cpp): list every blocking problem; the
// terminal HIDES entirely once the batch is valid, and Create is enabled iff
// there are no problems. batch_validation_error_ is the host's real compile/run
// error (validateBatch).
std::string batch_term;
if (batch_suffix_.empty()) {
batch_term += "- Missing prefix/suffix.\n";
}
if (batch_selected_.empty()) {
batch_term += "- No input series.\n";
}
if (batch_function_body_.empty()) {
batch_term += "- Missing function body.\n";
}
if (!batch_validation_error_.empty()) {
batch_term += "- " + batch_validation_error_ + "\n";
}
wd.setPlainText("terminalBatchPlainText", batch_term);
wd.setVisible("terminalBatchPlainText", !batch_term.empty());
// Create-enable: Single needs source+name+body; Batch needs an empty terminal
// (no blocking problems) — mirrors PJ3 enabling Create only when valid, so an
// empty prefix/suffix now blocks creation.
const bool can_create = (current_tab_ == 0) ? single_term.empty() : batch_term.empty();
wd.setEnabled("pushButtonCreate", can_create);
// Create vs Modify (PJ3 parity): in explicit edit mode (single OR batch), or
// when the Single-tab output name already exists, the button reads "Modify".
const bool is_modify = edit_mode_ || (current_tab_ == 0 && outputNameExists(output_name_));
wd.setButtonText("pushButtonCreate", is_modify ? "Modify Time Series" : "Create New Time Series");
// Lock the identity while editing so a rename can't fork a new series (PJ3
// parity). Single: the name field. Batch: the inputs that form the name —
// source selection + filter + prefix/suffix.
wd.setEnabled("nameLineEdit", !edit_mode_);
wd.setEnabled("listBatchSources", !edit_mode_);
wd.setEnabled("lineEditTab2Filter", !edit_mode_);
wd.setEnabled("suffixLineEdit", !edit_mode_);
wd.setEnabled("radioButtonPrefix", !edit_mode_);
wd.setEnabled("radioButtonSuffix", !edit_mode_);
// Preview chart — always set (even if empty) so ChartPreviewWidget is
// instantiated from the start, matching PJ3 behaviour where the empty
// plot area is visible as soon as the editor opens.
wd.setChartSeries("framePlotPreview", preview_series_);
wd.setChartAutoZoom("framePlotPreview", autozoom_);
return wd;
}
bool onTextChanged(std::string_view name, std::string_view text) override {
if (name == "nameLineEdit") {
output_name_ = std::string(text);
return true;
}
// Live search filter in the function library box.
if (name == "searchLineEdit") {
library_search_ = std::string(text);
return true;
}
// Name typed in the "Save current function" prompt (harvested on OK).
if (name == "saveFunctionName") {
pending_save_name_ = std::string(text);
return true;
}
if (name == "lineEditTab2Filter") {
batch_filter_ = std::string(text);
updateBatchFilter();
return true;
}
if (name == "suffixLineEdit") {
batch_suffix_ = std::string(text);
return true;
}
return false;
}
bool onCodeChanged(std::string_view name, std::string_view text) override {
if (name == "globalVarsText") {
global_code_ = std::string(text);
validateSyntax();
preview_dirty_ = true;
return true;
}
if (name == "functionText") {
function_body_ = std::string(text);
validateSyntax();
preview_dirty_ = true;
return true;
}
if (name == "globalVarsTextBatch") {
batch_global_code_ = std::string(text);
batch_dirty_ = true;
return true;
}
if (name == "functionTextBatch") {
batch_function_body_ = std::string(text);
batch_dirty_ = true;
return true;
}
return false;
}
bool onClicked(std::string_view name) override {
if (name == "pushButtonCreate") {
save_requested_ = true;
return true;
}
if (name == "pushButtonCancel") {
close_requested_ = true;
return true;
}
// Function library box (PJ3's buttonLibraryBox): open the interactive sub-panel.
if (name == "buttonLibraryBox") {
library_open_ = true;
emit_open_library_ = true;
library_search_.clear();
library_selected_.clear();
return true;
}
// "Use" in the library box: load the selected function(s) and dismiss the box.
// There is no Close button, so Use also doubles as the way to close it.
if (name == "useButton") {
loadSnippetsIntoEditor(library_selected_);
library_open_ = false;
emit_close_library_ = true;
return true;
}
// Synthetic event the host sends when the user dismisses the sub-panel.
if (name == "subPanelClosed") {
library_open_ = false;
return true;
}
// Save current function (PJ3 parity): prompt for a name (prefilled with the
// current one), then warn before overwriting an existing entry. Opens the
// name-prompt modal; the actual save happens on subDialogAccepted.
if (name == "buttonSaveCurrent") {
pending_save_name_ = output_name_;
save_stage_ = SaveStage::NamePrompt;
emit_save_name_dialog_ = true;
return true;
}
// A modal sub-dialog was accepted (OK). Drives the save state machine.
if (name == "subDialogAccepted") {
if (save_stage_ == SaveStage::NamePrompt) {
if (pending_save_name_.empty()) {
save_stage_ = SaveStage::None;
} else if (snippetExists(pending_save_name_)) {
save_stage_ = SaveStage::OverwriteConfirm;
emit_save_confirm_dialog_ = true; // ask before overwriting
} else {
doSaveSnippet(pending_save_name_);
save_stage_ = SaveStage::None;
}
} else if (save_stage_ == SaveStage::OverwriteConfirm) {
doSaveSnippet(pending_save_name_); // user confirmed overwrite
save_stage_ = SaveStage::None;
}
return true;
}
if (name == "pushButtonDeleteCurves") {
// Remove the selected row(s) only. The button is disabled when nothing is
// selected (see buildWidgetData), so an empty selection here is a no-op
// rather than a "clear everything" — guards against deleting the whole list
// on a stray click. The selection arrives as series paths (column-0 text).
if (selected_paths_.empty()) {
return true;
}
{
// Remember the primary's path so the radio follows the same series after
// the surviving rows renumber.
const std::string primary_path = primarySource();
sources_.erase(
std::remove_if(
sources_.begin(), sources_.end(),
[this](const std::string& s) {
return std::find(selected_paths_.begin(), selected_paths_.end(), s) != selected_paths_.end();
}),
sources_.end());
selected_paths_.clear();
// Keep the primary on the same series if it survived, else fall back to row 0.
primary_index_ = 0;
for (int i = 0; i < static_cast<int>(sources_.size()); ++i) {
if (sources_[static_cast<std::size_t>(i)] == primary_path) {
primary_index_ = i;
break;
}
}
}
if (sources_.empty()) {
primary_index_ = -1;
}
preview_dirty_ = true;
return true;
}
if (name == "pushButtonHelp") {
help_requested_ = true;
return true;
}
return false;
}
bool onTick() override {
if (close_requested_) {
close_requested_ = false;
pending_close_ = true;
if (on_teardown_preview_) {
on_teardown_preview_();
}
}
if (save_requested_) {
save_requested_ = false;
if (on_save_) {
on_save_();
}
}
// Always refresh the preview every tick so a live/streaming source keeps
// moving in the chart (mirrors the FFT toolbox). refreshPreview re-reads the
// latest samples and re-applies the Lua function each time.
preview_dirty_ = false;
if (on_refresh_preview_) {
on_refresh_preview_();
}
// Re-validate the batch function only when its fields changed (not every tick).
if (batch_dirty_) {
batch_dirty_ = false;
if (on_validate_batch_) {
on_validate_batch_();
}
}
return true;
}
std::string widget_data() override {
PJ::WidgetData wd = buildWidgetData();
if (help_requested_) {
help_requested_ = false;
wd.requestSubDialog(kHelpDialogUi);
}
if (pending_close_) {
pending_close_ = false;
wd.requestClose("user_closed");
}
return wd.toJson();
}
bool onSelectionChanged(std::string_view name, const std::vector<std::string>& items) override {
if (name == "tableFunctions") {
library_selected_ = items;
return true;
}
if (name == "listBatchSources") {
batch_selected_ = items;
return true;
}
if (name == "tableSources") {
selected_paths_ = items;
return true;
}
return false;
}
// Radio in the sources table: make `row` the series that provides `value`.
bool onTableRadioSelected(std::string_view name, int row) override {
if (name == "tableSources" && row >= 0 && row < static_cast<int>(sources_.size())) {
primary_index_ = row;
preview_dirty_ = true;
return true;
}
return false;
}
bool onToggled(std::string_view name, bool checked) override {
if (name == "radioButtonPrefix") {
batch_use_prefix_ = checked;
return true;
}
if (name == "radioButtonSuffix") {
batch_use_prefix_ = !checked;
return true;
}
// Single-tab script language (Lua / Python). Only "luau" actually runs today;
// selecting Python re-validates so the host's "unsupported language" surfaces.
if (name == "luaButton" && checked) {
language_ = "luau";
validateSyntax();
return true;
}
if (name == "pythonButton" && checked) {
language_ = "python";
validateSyntax();
return true;
}
if (name == "luaBatchButton" && checked) {
batch_language_ = "luau";
batch_dirty_ = true;
return true;
}
if (name == "pythonBatchButton" && checked) {
batch_language_ = "python";
batch_dirty_ = true;
return true;
}
return false;
}
bool onTabChanged(std::string_view name, int index) override {
if (name == "tabWidget") {
current_tab_ = index;
return true;
}
return false;
}
bool onItemsDropped(std::string_view widget_name, const std::vector<std::string>& items) override {
if (widget_name == "tableSources") {
const bool was_empty = sources_.empty();
for (const auto& item : items) {
if (std::find(sources_.begin(), sources_.end(), item) == sources_.end()) {
sources_.push_back(item);
}
}
// The first series dropped becomes the primary (`value`) by default.
if (was_empty && !sources_.empty()) {
primary_index_ = 0;
}
preview_dirty_ = true;
return true;
}
return false;
}
bool onItemDoubleClicked(std::string_view name, int index) override {
// Double-click in the library box: load the current selection (or the
// double-clicked row if nothing is selected yet) and dismiss the box. The
// index is into the FILTERED list shown in the table, not into snippets_.
if (name == "tableFunctions") {
std::vector<std::string> to_load = library_selected_;
if (to_load.empty()) {
const auto names = filteredSnippetNames();
if (index >= 0 && index < static_cast<int>(names.size())) {
to_load = {names[static_cast<std::size_t>(index)]};
}
}
loadSnippetsIntoEditor(to_load);
library_open_ = false;
emit_close_library_ = true;
return true;
}
return false;
}
// Merge `incoming` into the library by name: an incoming snippet replaces a
// same-named one, otherwise it is appended. Existing snippets whose names are
// absent from `incoming` are kept — so Import is additive, never destructive.
void mergeSnippets(const std::vector<Snippet>& incoming) {
for (const auto& s : incoming) {
auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& e) { return e.name == s.name; });
if (it != snippets_.end()) {
*it = s;
} else {
snippets_.push_back(s);
}
}
}
// Both Import and Export report the chosen path here (the host opens an "open"
// dialog for one and a "save as" for the other, per the widget's action). Each
// sets io_status_, a one-shot line the next widget_data() shows then clears.
bool onFileSelected(std::string_view widget_name, std::string_view path) override {
if (widget_name == "buttonLoadFunctions") {
// Import: merge the chosen library into the current one (additive, so the
// user's other snippets survive) and persist so it outlives a restart.
if (auto loaded = loadSnippetsFromPath(std::filesystem::path(path))) {
if (loaded->empty()) {
io_status_ = "The selected file contains no functions.";
} else {
mergeSnippets(*loaded);
saveSnippetsToDisk(snippets_);
io_status_ = "Imported " + std::to_string(loaded->size()) + " function(s).";
}
} else {
io_status_ = "Could not read a function library from the selected file.";
}
return true;
}
if (widget_name == "buttonSaveFunctions") {
// Export: write the current library to the chosen file.
const bool ok = saveSnippetsToPath(snippets_, std::filesystem::path(path));
io_status_ =
ok ? "Exported " + std::to_string(snippets_.size()) + " function(s)." : "Could not write the selected file.";
return true;
}
return false;
}
void onAccepted(std::string_view /*json*/) override {}
std::string saveConfig() const override {
nlohmann::json cfg;
cfg["output_name"] = output_name_;
cfg["global_code"] = global_code_;
cfg["function_body"] = function_body_;
cfg["sources"] = sources_;
cfg["primary_index"] = primaryIndex();
cfg["language"] = language_; // restore the Lua/Python radio on Modify
cfg["mode"] = "single"; // reopen on the Single tab when modified (PJ3 parity)
// Back-compat mirror: an older editor reads source_series + extra_sources, so
// expose the primary as the source and the rest as extras in order.
cfg["source_series"] = primarySource();
cfg["extra_sources"] = orderedExtras();
return cfg.dump();
}
// Build the editor config for ONE batch-created series so that editing it later
// reopens the BATCH tab (PJ3 parity), repopulated with this series' source,
// prefix/suffix, global, body and language.
static std::string makeBatchConfig(
const std::string& name, const std::string& global, const std::string& body, const std::string& source,
const std::string& language, const std::string& suffix, bool use_prefix) {