File size: 45,281 Bytes
4614287
 
2ee4481
8137781
0c4660b
 
2ee4481
304cdac
a4a5767
304cdac
 
1b77e8d
 
de48b4c
 
cb64b5f
de48b4c
4614287
125ee1c
2ee4481
a4a5767
 
 
 
 
 
 
 
 
 
9db4577
 
a4a5767
6c35a72
 
a4a5767
 
 
 
 
 
 
 
 
1b77e8d
a4a5767
 
6c35a72
0c4660b
a4a5767
6c35a72
 
1b77e8d
 
a4a5767
 
 
 
 
9db4577
 
a4a5767
 
 
 
 
1b77e8d
a4a5767
 
 
0c4660b
f4cd2d5
 
 
a4a5767
 
1b77e8d
 
a4a5767
 
 
 
63cfb2c
 
 
87def91
5dbc043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9db4577
5dbc043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9db4577
5dbc043
 
 
 
 
 
6c35a72
63cfb2c
6c35a72
 
9db4577
6c35a72
63cfb2c
 
9db4577
5dbc043
 
 
 
 
6c35a72
63cfb2c
6c35a72
 
 
 
9db4577
63cfb2c
5fa765b
5dbc043
 
 
 
 
 
 
 
 
cb64b5f
 
 
 
5dbc043
6c35a72
cb64b5f
ceed7c1
 
5dbc043
6c35a72
ceed7c1
 
cb64b5f
 
5fa765b
5dbc043
 
ceed7c1
 
 
 
 
 
 
 
 
 
cb64b5f
 
 
ceed7c1
 
 
5dbc043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da2266a
 
 
 
 
 
 
 
2ee4481
 
 
 
 
a05c9de
8679a50
 
 
 
 
 
da2266a
304cdac
a05c9de
 
 
 
 
 
 
 
 
 
 
8679a50
 
9db4577
8679a50
9db4577
 
 
8679a50
9db4577
 
 
 
 
 
 
8244a47
9db4577
8244a47
8679a50
 
 
 
 
 
9db4577
 
8679a50
9db4577
6c35a72
9db4577
 
6c35a72
a05c9de
 
 
 
6c35a72
9db4577
 
cb64b5f
 
 
 
 
 
 
 
 
9db4577
cb64b5f
 
 
 
 
de48b4c
a05c9de
 
 
9db4577
 
0c4660b
 
9db4577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a05c9de
8679a50
 
 
 
 
 
 
1b77e8d
 
 
 
 
6c35a72
 
9db4577
 
6c35a72
1b77e8d
 
 
 
9113af3
a05c9de
8679a50
6c35a72
9db4577
 
6c35a72
5dbc043
a4a5767
 
 
5fa765b
5dbc043
 
 
8679a50
8137781
63cfb2c
8679a50
 
8137781
8679a50
da2266a
4614287
 
 
6c35a72
1b77e8d
 
2ee4481
4614287
 
8244a47
4614287
1b77e8d
 
 
 
4614287
 
a4a5767
9113af3
a4a5767
 
6c35a72
a4a5767
 
6c35a72
a4a5767
 
 
 
a05c9de
 
8679a50
da2266a
 
 
 
 
 
 
 
 
2ee4481
 
 
da2266a
2ee4481
 
da2266a
2ee4481
 
da2266a
2ee4481
 
 
da2266a
f2474aa
 
9db4577
f2474aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da2266a
9074889
 
 
da2266a
9074889
 
f2474aa
 
9074889
2ee4481
 
da2266a
 
 
0c4660b
9db4577
da2266a
 
 
9db4577
2ee4481
 
 
 
 
8137781
2ee4481
fbdab60
 
da2266a
2ee4481
 
 
a90c443
9074889
2ee4481
 
1b77e8d
 
 
 
 
2ee4481
 
4614287
 
 
1b77e8d
2ee4481
9db4577
2ee4481
1b77e8d
2ee4481
 
8679a50
0c4660b
8679a50
9db4577
8679a50
0c4660b
8679a50
0c4660b
5dbc043
 
 
 
2ee4481
0c4660b
8137781
8244a47
8679a50
125ee1c
 
 
 
8679a50
 
 
 
9db4577
 
8679a50
9db4577
9c1561c
 
 
 
a05c9de
4614287
 
 
125ee1c
8244a47
 
 
9db4577
a05c9de
9c1561c
4614287
 
 
 
 
 
1b77e8d
 
6c35a72
 
 
 
 
 
 
9db4577
1b77e8d
 
125ee1c
1b77e8d
 
9113af3
9db4577
1b77e8d
 
125ee1c
 
 
 
1b77e8d
 
f419f31
 
 
1b77e8d
 
8244a47
8679a50
 
 
 
 
 
 
9db4577
8679a50
9db4577
0c4660b
8244a47
 
 
0c4660b
8244a47
 
 
0c4660b
cb64b5f
 
8244a47
9db4577
1b77e8d
 
8244a47
304cdac
0c4660b
 
 
125ee1c
 
 
 
0c4660b
 
 
 
 
 
 
 
 
 
 
8244a47
0c4660b
 
9cb8062
 
 
 
 
 
 
 
 
 
 
 
0c4660b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9cb8062
 
 
 
 
 
 
 
0c4660b
 
 
 
 
9cb8062
 
 
 
 
 
 
 
0c4660b
 
 
 
 
 
 
 
 
 
 
a4a5767
 
 
0c4660b
a4a5767
0c4660b
a4a5767
9db4577
a4a5767
9db4577
a4a5767
 
 
6c35a72
 
 
 
9db4577
 
 
6c35a72
 
 
 
cb64b5f
 
 
 
 
 
 
 
 
 
 
 
 
de48b4c
cb64b5f
 
 
de48b4c
cb64b5f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c35a72
 
cb64b5f
 
 
 
 
 
 
6c35a72
9db4577
 
4614287
 
 
 
9db4577
4614287
9db4577
 
 
4614287
 
 
 
 
9db4577
4614287
9db4577
 
4614287
 
 
 
 
 
9db4577
 
4614287
9db4577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4614287
 
125ee1c
 
 
 
 
 
 
 
 
 
 
 
4614287
fcb80e0
 
 
4614287
 
125ee1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4614287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
const wordsFrequencyTableTitleText = "Words Freq. Stats"
const wordsFrequencyTableTitleMobileText = "Words Freq. Stats"
let wfo = {
    "words_frequency": {},
    "nTotalRows": null,
    "rowArray": []
}
const editorFieldLabel = "editor"
const remoteWebServer = "http://localhost:7860"
const underlinedPrimary = "underlinedBlue"
const underlinedClicked = "underlinedDarkViolet"
const underlinedPrimaryTable = "underlinedBlueTable"
const underlinedClickedTable = "underlinedDarkVioletTable"
const objectChildNodeNamesToParse = {
    "#text": "textContent",
    "SPAN": "textContent"
}
const mobileInnerSize = 767
const minNCharsMore = 10

/**
 * Object containing functions for word frequency analysis.
 *
 * @property {function} 'id-input-webserver-wordfreq-checkbox' - Analyzes input text using webserver API.
 * @property {function} 'stemmer-embedded' - Analyzes input text using embedded functionality.
 */
const wordsFrequencyAnalyzers = {
    /**
     * Analyzes input text using 'My Ghost Writer' webserver API.
     *
     * @param {Array<Object>} arrayOfValidTextChildWithNrow - Array of objects representing text rows to analyze.
     * @returns {Promise<void>} Populates the frequency tables with the response from the webserver.
     */
    "id-input-webserver-wordfreq-checkbox": async function(arrayOfValidTextChildWithNrow) {
        let bodyRequest = {"text": arrayOfValidTextChildWithNrow}
        console.log("use the webserver for word freq analysis...")
        const wordsFrequencyURL = parseWebserverDomain()
        try {
            let response = await fetch(wordsFrequencyURL, {
                method: "POST",
                body: JSON.stringify(bodyRequest)
            })
            console.assert(response.status === 200, `response.status: ${response.status}!`)
            let bodyResponseJson = await response.json()
            setElementCssClassById("waiting-for-be", "display-none")
            let freq = bodyResponseJson["words_frequency"]
            let nTotalRows = bodyResponseJson["n_total_rows"]
            console.log(`wordsFrequencyAnalyzers::nTotalRows: '${nTotalRows}'`)
            populateWordsFrequencyTables(freq, nTotalRows, arrayOfValidTextChildWithNrow)
        } catch (err) {
            console.error("wordsFrequencyAnalyzers::err on webserver request/response:", err, "#")
            console.log(`wordsFrequencyAnalyzers::wordsFrequencyURL: ${typeof wordsFrequencyURL}:`, wordsFrequencyURL, "#")
            setElementCssClassById("waiting-for-be", "display-none")
            setElementCssClassById("waiting-for-be-error", "display-block")
        }
    },
    /**
     * Analyzes input text using embedded functionality.
     *
     * @param {Array<Object>} inputText - Array of objects representing text rows to analyze.
     * @returns {void} Populates the frequency tables with the embedded analysis.
     */
    'stemmer-embedded': function(inputText) {
        console.log("use the embedded functionality for word freq analysis...")
        try {
            const bodyResponseJson = textStemming(inputText)
            setElementCssClassById("waiting-for-be", "display-none")
            let freq = bodyResponseJson["wordsStemsDict"]
            let nTotalRows = bodyResponseJson["nTotalRows"]
            console.log(`getWordsFreq::nTotalRows: '${nTotalRows}', populateWordsFrequencyTables...`)
            populateWordsFrequencyTables(freq, nTotalRows, inputText)
            // temp until we have the new UI
            let hiddenOutputSpan = document.getElementById("id-hidden-editor")
            hiddenOutputSpan.textContent = JSON.stringify(freq, null, 2)
        } catch (err) {
            console.error("getWordsFrequency::err on useWordfreqWebserver:", err, "#")
            setElementCssClassById("waiting-for-be", "display-none")
            setElementCssClassById("waiting-for-be-error", "display-block")
        }
    }
}

// lunr.stemmer
// Copyright (C) 2020 Oliver Nightingale, Code included under the MIT license
// Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt
const porterStemmer = (function(){
    let step2list = {
            "ational" : "ate",
            "tional" : "tion",
            "enci" : "ence",
            "anci" : "ance",
            "izer" : "ize",
            "bli" : "ble",
            "alli" : "al",
            "entli" : "ent",
            "eli" : "e",
            "ousli" : "ous",
            "ization" : "ize",
            "ation" : "ate",
            "ator" : "ate",
            "alism" : "al",
            "iveness" : "ive",
            "fulness" : "ful",
            "ousness" : "ous",
            "aliti" : "al",
            "iviti" : "ive",
            "biliti" : "ble",
            "logi" : "log"
        },

        step3list = {
            "icate" : "ic",
            "ative" : "",
            "alize" : "al",
            "iciti" : "ic",
            "ical" : "ic",
            "ful" : "",
            "ness" : ""
        },

        c = "[^aeiou]",          // consonant
        v = "[aeiouy]",          // vowel
        C = c + "[^aeiouy]*",    // consonant sequence
        V = v + "[aeiou]*",      // vowel sequence

        mgr0 = "^(" + C + ")?" + V + C,               // [C]VC... is m>0
        meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$",  // [C]VC[V] is m=1
        mgr1 = "^(" + C + ")?" + V + C + V + C,       // [C]VCVC... is m>1
        s_v = "^(" + C + ")?" + v;                   // vowel in stem

    return function (w) {
        let stem,
            suffix,
            firstch,
            re,
            re2,
            re3,
            re4,
            origword = w;

        if (w.length < 3) { return w; }

        firstch = w.substr(0,1);
        if (firstch === "y") {
            w = firstch.toUpperCase() + w.substr(1);
        }

        // Step 1a
        re = /^(.+?)(ss|i)es$/;
        re2 = /^(.+?)([^s])s$/;

        if (re.test(w)) { w = w.replace(re,"$1$2"); }
        else if (re2.test(w)) {	w = w.replace(re2,"$1$2"); }

        // Step 1b
        re = /^(.+?)eed$/;
        re2 = /^(.+?)(ed|ing)$/;
        if (re.test(w)) {
            let fp = re.exec(w);
            re = new RegExp(mgr0);
            if (re.test(fp[1])) {
                re = /.$/;
                w = w.replace(re,"");
            }
        } else if (re2.test(w)) {
            let fp = re2.exec(w);
            stem = fp[1];
            re2 = new RegExp(s_v);
            if (re2.test(stem)) {
                w = stem;
                re2 = /(at|bl|iz)$/;
                re3 = new RegExp("([^aeiouylsz])\\1$");
                re4 = new RegExp("^" + C + v + "[^aeiouwxy]$");
                if (re2.test(w)) {	w = w + "e"; }
                else if (re3.test(w)) { re = /.$/; w = w.replace(re,""); }
                else if (re4.test(w)) { w = w + "e"; }
            }
        }

        // Step 1c
        re = /^(.+?)y$/;
        if (re.test(w)) {
            let fp = re.exec(w);
            stem = fp[1];
            re = new RegExp(s_v);
            if (re.test(stem)) { w = stem + "i"; }
        }

        // Step 2
        re = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/;
        if (re.test(w)) {
            let fp = re.exec(w);
            stem = fp[1];
            suffix = fp[2];
            re = new RegExp(mgr0);
            if (re.test(stem)) {
                w = stem + step2list[suffix];
            }
        }

        // Step 3
        re = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/;
        if (re.test(w)) {
            let fp = re.exec(w);
            stem = fp[1];
            suffix = fp[2];
            re = new RegExp(mgr0);
            if (re.test(stem)) {
                w = stem + step3list[suffix];
            }
        }

        // Step 4
        re = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/;
        re2 = /^(.+?)(s|t)(ion)$/;
        if (re.test(w)) {
            let fp = re.exec(w);
            stem = fp[1];
            re = new RegExp(mgr1);
            if (re.test(stem)) {
                w = stem;
            }
        } else if (re2.test(w)) {
            let fp = re2.exec(w);
            stem = fp[1] + fp[2];
            re2 = new RegExp(mgr1);
            if (re2.test(stem)) {
                w = stem;
            }
        }

        // Step 5
        re = /^(.+?)e$/;
        if (re.test(w)) {
            let fp = re.exec(w);
            stem = fp[1];
            re = new RegExp(mgr1);
            re2 = new RegExp(meq1);
            re3 = new RegExp("^" + C + v + "[^aeiouwxy]$");
            if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) {
                w = stem;
            }
        }

        re = /ll$/;
        re2 = new RegExp(mgr1);
        if (re.test(w) && re2.test(w)) {
            re = /.$/;
            w = w.replace(re,"");
        }

        // and turn initial Y back to y
        if (firstch === "y") {
            w = firstch.toLowerCase() + w.substr(1);
        }

        return w;
    }
})();

/**
 * Filters elements from an array based on specified conditions.
 * @param {Array} inputArray - The array of elements to filter.
 * @param {boolean} [filterWhitespaces=false] - Whether to remove elements that are only whitespace.
 * @param {Array<string>} [filterArgs=["", " "]] - List of elements to exclude from the array.
 * @returns {Array} - The filtered array.
 */
function filterElementsFromList(inputArray,  filterWhitespaces = false, filterArgs=["", " "]) {
    if (filterWhitespaces) {
        inputArray = inputArray.filter(e => String(e).trim());
    }
    return inputArray.filter((x) => !filterArgs.includes(x));
}

/**
 * Tokenizes a string using a custom pattern and filters out specified elements.
 * @param {string} s - The input string to tokenize.
 * @param {RegExp} [pattern=/([A-Za-zÀ-ÿ-]+|[0-9._]+|.|!|\?|'|"|:|;|,|-)/i] - The regex pattern for tokenization.
 * @param {boolean} [filterWhitespaces=true] - Whether to remove whitespace elements after tokenization.
 * @returns {Array<string>} - The list of tokens filtered based on specified conditions.
 */
function customWordPunctTokenize(s, pattern = /([A-Za-zÀ-ÿ-]+|[0-9._]+|.|!|\?|'|"|:|;|,|-)/i, filterWhitespaces=true) {
    const results = s.split(pattern)
    return filterElementsFromList(results, filterWhitespaces)
}

/**
 * Applies Porter Stemmer algorithm to reduce words in a given text to their base form,
 * then produces a dictionary of word frequencies with, for every recognized base form,
 * a list of these repeated words with their position.
 *
 * Handles nested SPAN childNodes and tracks idxRow, idxRowChild, idxRowParent.
 *
 * @param {Array<Object>} textSplitNewline - Array of objects: {idxRow, text, idxRowChild, idxRowParent}
 * @returns {Object} - { nTotalRows, wordsStemsDict }
 */
function textStemming(textSplitNewline) {
    // textSplitNewline: [{idxRow: number, text: string, idxRowChild: number|null, idxRowParent: number|null}]
    const wordsStemsDict = {};
    let nTotalRows = textSplitNewline.length;

    textSplitNewline.forEach((data) => {
        const row = data.text;
        const idxRow = data.idxRow;
        const idxRowChild = data.idxRowChild ?? null;
        const idxRowParent = data.idxRowParent ?? null;
        const tokens = customWordPunctTokenize(row);
        const offsets = getOffsets(row, tokens);

        tokens.forEach((word, i) => {
            const wordLower = word.toLowerCase();
            const stem = porterStemmer(wordLower);
            if (!wordsStemsDict[stem]) {
                wordsStemsDict[stem] = { count: 0, word_prefix: stem, offsets_array: [] };
            }
            wordsStemsDict[stem].count += 1;
            wordsStemsDict[stem].offsets_array.push({
                word: word, // keep original casing for display
                offsets: [offsets[i].start, offsets[i].end],
                n_row: idxRow,
                n_row_child: idxRowChild,
                n_row_parent: idxRowParent
            });
        });
    });
    return { nTotalRows, wordsStemsDict };
}

/**
 * Get the offsets of each token in the original text.
 *
 * @param {string} text - The original text.
 * @param {Array<string>} tokens - The tokens extracted from the text.
 * @returns {Array<Object>} - Array of objects containing the start and end offsets of each token.
 */
function getOffsets(text, tokens) {
    const offsets = [];
    let currentIndex = 0;

    tokens.forEach(token => {
        const start = text.indexOf(token, currentIndex);
        const end = start + token.length;
        offsets.push({ start, end });
        currentIndex = end;
    });

    return offsets;
}

/**
 * Retrieves the value of a form field by its key from a FormData object.
 *
 * @param {string} formId - The ID of the HTML form element that contains the field.
 * @param {string} key - The name attribute of the form field to retrieve.
 * @returns {*} The value of the form field, or null if it does not exist in the FormData object.
 */
function getFormDataByKey(formId, key) {
    let formElement = document.getElementById(formId)
    const data = new FormData(formElement);
    let dataValue = data.get(key)
    return dataValue
}

/**
 * Read and preview a selected text file.
 *
 * @function previewFile
 * @description Displays the contents of a selected text file within an element with id 'editor'.
 */
function previewFile() {
    const editor = document.getElementById(editorFieldLabel);
    const [file] = document.querySelector("input[type=file]").files;
    const reader = new FileReader();
    reader.addEventListener("load", () => {
            // this will then display a text file
            editor.innerText = reader.result;
        }, false
    );
    if (file) {
        reader.readAsText(file);
    }
}

/**
 * Scrolls to the specified point within an element.
 *
 * @param {HTMLElement} editorElement - The target element to scroll.
 * @param {number} yClientOffset - The offset from the top of the element to scroll to.
 * @param {number} [negativeMultiplierFontSize=3] - Multiplier for font size adjustment.
 */
function scrollToGivenPoint(editorElement, yClientOffset, negativeMultiplierFontSize = 3) {
    const editorComputedStyle = window.getComputedStyle(editorElement)
    const fontSize = parseInt(editorComputedStyle.getPropertyValue("font-size"), 10)
    const negativeOffset = fontSize * negativeMultiplierFontSize
    const scrollHeight = editorElement.scrollHeight
    if (yClientOffset < (scrollHeight - negativeOffset)) {
        yClientOffset -= negativeOffset
    }
    editorElement.scrollTo(0, yClientOffset)
}

/**
 * Scrolls the editor to a specified position and sets the caret at that point.
 *
 * @param {number} line - The line/row number (0-indexed) where the caret should be placed.
 * @param {Array<number>} offsetColumn - A number array containing two numbers representing the column offsets for the start and end of the selection range.
 * @param {number} nRowChild - The index of the child node (if applicable).
 * @param {number} nRowParent - The index of the parent node (if applicable).
 */
function setCaret(line, offsetColumn, nRowChild, nRowParent) {
    const editorElement = document.getElementById(editorFieldLabel)
    editorElement.scrollTo(0, 0) // workaround: first reset the scroll position moving it at editorElement beginning

    const childNodes = editorElement.childNodes
    let rng = document.createRange();
    let sel = window.getSelection();
    let col0 = offsetColumn[0]
    let col1 = offsetColumn[1]
    let childNode = childNodes[line]
    let subChildNode;
    /// handle case of childNodes not of type #text, e.g., SPAN
    if (nRowParent !== null) {
        console.assert(line === nRowParent, `line ${line} nth === parent line ${nRowParent} nth???`)
    }
    switch (childNode.nodeName) {
        case "#text":
            rng.setStart(childNode, col0)
            rng.setEnd(childNode, col1)
            break
        case "SPAN":
            subChildNode = childNode.childNodes[nRowChild]
            rng.setStart(subChildNode, col0)
            rng.setEnd(subChildNode, col1)
            break
        default:
            throw Error(`childNode.nodeName ${childNode.nodeName} not yet handled!`)
    }
    sel.removeAllRanges();
    sel.addRange(rng);
    editorElement.focus();

    const offsetsEditor = getOffsetsWithElement(editorElement)
    const yBase = offsetsEditor.top
    const {y} = getBoundingClientRect(rng)
    const yClientOffset = y - yBase

    scrollToGivenPoint(editorElement, yClientOffset)
}

/**
 * Gets the offsetTop and offsetHeight of an element.
 * @param {HTMLElement} el - The element to get offsets from.
 * @returns {Object} An object with 'top' and 'height' properties.
 */
function getOffsetsWithElement(el) {
    return {top: el.offsetTop, height: el.offsetHeight}
}

/**
 * Gets the bounding client rectangle of an element or range.
 * @param {HTMLElement|Range} el - The element or range to get bounding rect from.
 * @returns {Object} An object with x, y, bottom, and top properties.
 */
function getBoundingClientRect(el) {
    let bounds = el.getBoundingClientRect();
    return {x: bounds.left, y: bounds.y, bottom: bounds.bottom, top: bounds.top};
}

/**
 * Updates the CSS class of an HTML element with the specified ID.
 *
 * @param {string} elementId - The ID of the HTML element to update.
 * @param {string} currentClass - The new CSS class to apply to the element.
 */
function setElementCssClassById(elementId, currentClass) {
    let elementWithClassToChange = document.getElementById(elementId)
    elementWithClassToChange.setAttribute("class", currentClass)
}

/**
 * Sets a CSS class by replacing an old class.
 * @param {string} oldClassName - The old class name to replace.
 * @param {string} currentClass - The new class name to set.
 */
function setElementCssClassByOldClass(oldClassName, currentClass) {
    try {
        let oldClassElement = document.getElementsByClassName(oldClassName)
        oldClassElement[0].className = currentClass
    } catch {}
}

/**
 * Parses the web server domain from an input element and returns the full API endpoint.
 * @returns {string} The parsed web server domain with /words-frequency endpoint.
 */
function parseWebserverDomain () {
    const remoteWebServerEl = document.getElementById("id-input-webserver-wordfreq")
    console.log("remoteWebServer.value:", remoteWebServerEl.value, "#")
    const remoteWebServerValue = remoteWebServerEl.value ?? remoteWebServer
    const remoteWebServerDomain = remoteWebServerValue.trim().replace(/\/+$/, '')
    return `${remoteWebServerDomain}/words-frequency`
}

/**
 * Fetches words frequency data from the server and populates the words frequency tables.
 * The user can choose to use an embedded stemmer or a remote web server for processing.
 * 
 * @async
 * @function getWordsFrequency
 */
async function getWordsFrequency() {
    if (isMobile()) {
        toggleElementWithClassById('id-container-desktop-menu')
    }
    let {validChildContent} = getValidChildNodesFromEditorById(editorFieldLabel)
    setElementCssClassById("waiting-for-be-error", "display-none")
    setElementCssClassById("waiting-for-be", "display-block")
    let wordsFrequencyTableTitleEl = document.getElementById("id-words-frequency-table-title")
    let wordsFrequencyTableTitleElMobile = document.getElementById("id-words-frequency-table-title-mobile")

    wordsFrequencyTableTitleEl.innerText = wordsFrequencyTableTitleText
    wordsFrequencyTableTitleElMobile.innerText = wordsFrequencyTableTitleMobileText
    let listOfWords = document.getElementById("id-list-of-words")
    listOfWords.innerHTML = ""
    let currentTableOfWords = document.getElementById("id-current-table-of-words")
    currentTableOfWords.innerHTML = ""
    let currentTableTitle = document.getElementById("id-current-table-of-words-title")
    currentTableTitle.innerText = ""
    const choiceWordFreqAnalyzerEl = document.getElementById('id-input-webserver-wordfreq-checkbox')
    console.log("choiceWordFreqAnalyzerEl checked:", typeof choiceWordFreqAnalyzerEl.checked, choiceWordFreqAnalyzerEl.checked, "#")
    switch (choiceWordFreqAnalyzerEl.checked) {
        case true: // webserver
            await wordsFrequencyAnalyzers['id-input-webserver-wordfreq-checkbox'](validChildContent)
            break;
        case false: // embedded
            wordsFrequencyAnalyzers['stemmer-embedded'](validChildContent)
            break;
        default:
            console.warn("No valid analyzer selected.");
            break;
    }
}

/**
 * Returns a sorting function for the given property and order.
 *
 * @param {string} property - The property to sort by.
 * @param {string} order - The order of sorting ('asc' or 'desc').
 * @returns {function} A comparison function that sorts data in the specified order.
 */
function dynamicSort(property, order) {
    let sort_order = order === "desc" ? -1 : 1
    return function (a, b){
        // a should come before b in the sorted order
        if(a[property] < b[property]){
            return -1 * sort_order;
        // a should come after b in the sorted order
        }else if(a[property] > b[property]){
            return 1 * sort_order;
        // a and b are the same
        }
        return 0 * sort_order;
    }
}

/**
 * Recursively extracts all string values from any level of a nested object or array.
 *
 * Traverses the input (object, array, or primitive) and collects every string value found.
 * The result is a flat array of all string values found within the input.
 *
 * @param {*} obj - The input object, array, or value to search for strings.
 * @returns {Array<string>} An array containing all string values found in the input.
 */
function extractStringValues(obj) {
    let result = [];
    if (typeof obj === "string") {
        result.push(obj);
    } else if (Array.isArray(obj)) {
        for (const item of obj) {
            result = result.concat(extractStringValues(item));
        }
    } else if (typeof obj === "object" && obj !== null) {
        for (const key in obj) {
            result = result.concat(extractStringValues(obj[key]));
        }
    }
    return result;
}

/**
 * Filters an array of objects by checking if a specified value exists within any nested property.
 * The search is case-insensitive and recursively collects all string values from each object,
 * then checks if the concatenated string contains the search value.
 *
 * @param {Array<Object>} array - The array of objects to filter.
 * @param {string} nestedValue - The value to search for within the objects.
 * @returns {Array<Object>} - A new array containing objects where the nested value is found.
 */
function arrayFilterNestedValue(array, nestedValue) {
    return array.filter(item => {
        let valuesFromObject = extractStringValues(item).join(" ")
        return valuesFromObject.toLowerCase().includes(nestedValue.toLowerCase());
    });
}

/**
 * Updates the words frequency tables with new data.
 *
 * Called whenever a change in form input fields or the uploaded text file affects the word frequency table's content.
 * Sorts and filters the word groups based on user preferences, and updates the HTML elements containing these tables.
 *
 * @function updateWordsFrequencyTables
 */
function updateWordsFrequencyTables() {
    let nTotalRows = wfo["nTotalRows"]
    if (nTotalRows === null || nTotalRows < 1) {
        alert("let's get some data before updating the result table...")
    }

    let _wfo = wfo["words_frequency"]
    let reduced = Object.values(_wfo)
    let order = getFormDataByKey("id-form-order-by", "order")
    let sort = getFormDataByKey("id-form-sort-by", "sort")
    reduced.sort(dynamicSort(sort, order))

    let inputFilter = document.getElementById("filter-words-frequency")
    let inputFilterValue = inputFilter.value
    if (inputFilterValue !== undefined && inputFilter.value !== "") {
        reduced = arrayFilterNestedValue(reduced, inputFilterValue)
    }

    let listOfWords = document.getElementById("id-list-of-words")
    listOfWords.innerHTML = ""
    let currentTableOfWords = document.getElementById("id-current-table-of-words")
    currentTableOfWords.innerHTML = ""

    let wordsFrequencyTableTitleEl = document.getElementById("id-words-frequency-table-title")
    wordsFrequencyTableTitleEl.innerText = `${wordsFrequencyTableTitleText} (${reduced.length} word groups, ${nTotalRows} rows)`
    let wordsFrequencyTableTitleMobileEl = document.getElementById("id-words-frequency-table-title-mobile")
    wordsFrequencyTableTitleMobileEl.innerText = `${wordsFrequencyTableTitleMobileText} (${reduced.length} word groups, ${nTotalRows} rows)`

    const wordListElement = document.createElement("list")
    for (let i=0; i<reduced.length; i++ ) {
        insertListOfWords(i, reduced[i], wordListElement, currentTableOfWords);
    }
    listOfWords.append(wordListElement)
}

/**
 * Populate the word frequency tables in the UI with data from the provided object or JSON string.
 *
 * @param {Object|string} wordsFrequencyObj - The object or JSON string containing word frequencies.
 * @param {number} nTotalRows - The total number of lines/rows to display for each word group.
 * @param rowArray - An array of objects representing the rows of text, each containing the text and its corresponding index.
 */
function populateWordsFrequencyTables(wordsFrequencyObj, nTotalRows, rowArray) {
    wfo["words_frequency"] = wordsFrequencyObj
    if (typeof wordsFrequencyObj === "string") {
        wfo["words_frequency"] = JSON.parse(wordsFrequencyObj)
    }
    wfo["nTotalRows"] = nTotalRows
    wfo["rowArray"] = Object.values(rowArray)
    updateWordsFrequencyTables()
}

function getRepetitionsText(iReduced) {
    return isMobile() || isMobilePortrait() ? `${iReduced["word_prefix"]}: ${iReduced["count"]} reps.` : `${iReduced["word_prefix"]}: ${iReduced["count"]} repetitions`
}

/**
 * Inserts a table into the DOM displaying the frequency of word prefixes and their corresponding row nths and offsets.
 *
 * @param {number} i - The current index being processed (needed for adding unique HTML id/aria-labels).
 * @param {Object} iReduced - An object containing the reduced data for the current index, including word prefix, count, and offsets array.
 * @param {HTMLElement} currentTableOfWords - A container element to hold the current table representing chosen word positions.
 */
function insertCurrentTable(i, iReduced, currentTableOfWords) {
    let currentTableWordsFreq = document.createElement("table")
    currentTableWordsFreq.setAttribute("class", "border-black")
    currentTableWordsFreq.setAttribute("id", `id-table-${i}-nth`)
    currentTableWordsFreq.setAttribute("aria-label", `id-table-${i}-nth`)

    // let currentCaption = currentTableWordsFreq.createCaption()
    // currentCaption.setAttribute("aria-label", `id-table-${i}-caption`
    const titleCurrentTable = document.getElementById("id-current-table-of-words-title")
    titleCurrentTable.innerText = getRepetitionsText(iReduced)
    let currentTBody = document.createElement("tbody")
    let offsetsArray = iReduced.offsets_array
    for (let ii = 0; ii < offsetsArray.length; ii++) {
        insertCellIntoTRow(currentTBody, i, ii, offsetsArray[ii])
    }
    currentTableWordsFreq.appendChild(currentTBody)

    // Wrap the table in a scrollable container
    let scrollableDiv = document.createElement("div")
    scrollableDiv.className = "scrollable-table-container"
    scrollableDiv.appendChild(currentTableWordsFreq)
    currentTableOfWords.appendChild(scrollableDiv)
}

/**
 * Inserts a list of words into a word list element based on the current table of words.
 * @param {number} i - The index of the current row.
 * @param {Object} iReduced - An object containing information about the current word prefix and count.
 * @param {HTMLElement} wordListElement - The element to insert the list of words into.
 * @param {HTMLElement} currentTableOfWords - The element to insert the current table of words into.
 */
function insertListOfWords(i, iReduced, wordListElement, currentTableOfWords) {
    const li = document.createElement("li");
    const a = document.createElement("a")
    a.innerText = getRepetitionsText(iReduced)
    a.addEventListener("click",  function() {
        currentTableOfWords.innerHTML = ""
        console.log(`insertListOfWords::'a', ${iReduced["word_prefix"]}: ${iReduced["count"]} repetitions`)
        insertCurrentTable(i, iReduced, currentTableOfWords)
        setElementCssClassByOldClass(underlinedClicked, underlinedPrimary)
        a.className = underlinedClicked
        console.log("insertListOfWords::click event:", isMobilePortrait(), "#")
        if(isMobilePortrait()) {
            gotoCurrentTableOfWords()
        }
    });
    a.className = underlinedPrimary
    a.setAttribute("id", `id-list-of-words-${i}-nth`)
    a.setAttribute("aria-label", `id-list-of-words-${i}-nth`)
    console.log(`insertListOfWords::a:`, a.id, a.ariaLabel, "=>", a, "#")
    li.appendChild(a);
    wordListElement.appendChild(li);
}

/**
 * Inserts a new table row into the specified tbody with an associated click event listener.
 *
 * @param {HTMLTableSectionElement} currentTBody - The tbody element where the new row will be inserted.
 * @param {number} i - A reference number for the parent's position in the DOM (needed for adding unique HTML id/aria-labels).
 * @param {number} ii - A counter of how many lines/rows have been added to the table (needed for adding unique HTML id/aria-labels).
 * @param {Object} nthOffset - An object containing information about a single offset word, including its row number and word text.
 */
function insertCellIntoTRow(currentTBody, i, ii, nthOffset) {
    let rowArray = wfo["rowArray"]
    let nthRowBody = currentTBody.insertRow()
    nthRowBody.setAttribute("id", `id-table-${i}-row-${ii}-nth`)
    nthRowBody.setAttribute("aria-label", `id-table-${i}-row-${ii}-nth`)
    const nthRowIdx = nthOffset["n_row"]
    let currentCell = nthRowBody.insertCell()
    let currentUrl = document.createElement("a")
    currentUrl.addEventListener("click", function() {
        let nRow = nthRowIdx
        let nRowChild = nthOffset["n_row_child"]
        let nRowParent = nthOffset["n_row_parent"]
        let offsetWord = nthOffset["offsets"]
        setCaret(nRow, offsetWord, nRowChild, nRowParent)
        setElementCssClassByOldClass(underlinedClickedTable, underlinedPrimaryTable)
        currentUrl.className = underlinedClickedTable
    })
    currentUrl.className = underlinedPrimary
    const wfoContainerWidth = getStylePropertyById("id-col2-words-frequency", "width", "int")
    const listOfWordsWidth = getStylePropertyById("id-list-of-words", "width", "int")
    const sentencesContainerWidth = wfoContainerWidth - listOfWordsWidth
    let nCharsMore = Math.floor(sentencesContainerWidth / 20)
    if (nCharsMore < minNCharsMore) {
        nCharsMore = minNCharsMore
    }
    console.log(`insertCellIntoTRow::sentencesContainerWidth: ${sentencesContainerWidth}px, nCharsMore: ${nCharsMore}.`)
    const {substring0, substringWord, substring2} = getSubstringForTextWithGivenOffset(rowArray, nthRowIdx, nthOffset, nCharsMore)

    const span0 = document.createElement("span").innerText = substring0
    const spanWord = document.createElement("span")
    spanWord.setAttribute("class", "font-weight-bold")
    spanWord.innerText = substringWord
    const span2 = document.createElement("span").innerText = substring2
    currentUrl.append(span0)
    currentUrl.append(spanWord)
    currentUrl.append(span2)
    currentCell.appendChild(currentUrl)
}

/** Given a rowArray (array of objects) and an nthOffset (object), it returns an object containing three substrings:
 * - substring0: the substring before the sliced word
 * - substringWord: the word sliced from the text with the given offset from the nthOffset arg
 * - substring2: the substring after the sliced word
 *
 * @param {Array} rowArray - The array of objects containing text and their corresponding indices.
 * @param {number} nthRowIdx - The index of the row to process.
 * @param {Object} nthOffset - The object containing the offsets and other properties of the word.
 * @param {number} [nCharsMore=30] - The number of characters to include before and after the selected word.
 *
 * @returns {Object} - An object containing the substring before, the selected word, and the substring after.
 * */
function getSubstringForTextWithGivenOffset(rowArray, nthRowIdx, nthOffset, nCharsMore = 30) {
    try {
        const currentRowArr = rowArray.filter(item => {
            if (item.idxRowChild !== null) {
                return item.idxRow === nthRowIdx && item.idxRowChild === nthOffset["n_row_child"] && item.idxRowParent === nthOffset["n_row_parent"]
            }
            return item.idxRow === nthRowIdx
        })
        const currentRow = currentRowArr[0]
        const text = currentRow.text
        let offset = nthOffset["offsets"]
        let start = offset[0]
        let end = offset[1]
        let currentWord = nthOffset["word"]
        let startOffset = Math.max(0, start - nCharsMore)
        let endOffset = Math.min(text.length, end + nCharsMore)
        let substringWord = text.substring(start, end)

        // Prune incomplete word at the start
        let substring0 = text.substring(startOffset, start)
        substring0 = substring0.replace(/^\S*\s?/, '') // remove partial word at the start

        // Prune incomplete word at the end
        let substring2 = text.substring(end, endOffset)
        substring2 = substring2.replace(/\s?\S*$/, '') // remove partial word at the end

        // Rebuild substring for validation
        let substring = substring0 + substringWord + substring2

        if (substringWord !== currentWord || substring !== substring0 + substringWord + substring2) {
            console.assert(substringWord === currentWord,
                `text.substring(${start}, ${end}) !== currentWord: '${substringWord}', '${currentWord}'.`
            )
            console.assert(substring === substring0 + substringWord + substring2,
                `## text.substring(${startOffset}, ${endOffset}) !== text.substring(${startOffset}, ${start}) + currentWord + text.substring(${end}, ${endOffset}).`
            )
            throw Error(`text.substring(${start}, ${end}): (${substringWord}) !== currentWord (${currentWord}).`)
        }
        return {substring0, substringWord, substring2};
    } catch (e) {
        console.error(`getSubstringForTextWithGivenOffset::error:`, e, ` #`)
        throw e
    }
}

/** Get the value of a CSS property for an element by its ID.
 *
 * @param {string} id - The ID of the element.
 * @param {string} property - The CSS property to retrieve.
 * @param {string} [parsing=""] - Optional parsing type ("int", "float").
 *
 * returns {string|number} - The value of the CSS property, parsed if specified.
 * */
function getStylePropertyById(id, property, parsing="") {
    const element = document.getElementById(id)
    return getStylePropertyWithElement(element, property, parsing)
}

/** Get the value of a CSS property for a given element.
 *
 * @param {HTMLElement} element - The element to retrieve the property from.
 * @param {string} property - The CSS property to retrieve.
 * @param {string} [parsing=""] - Optional parsing type ("int", "float").
 *
 * returns {string|number} - The value of the CSS property, parsed if specified.
 * */
function getStylePropertyWithElement(element, property, parsing="") {
    const howToParse = {
        "int": parseInt,
        "float": parseFloat
    }
    const elementStyle = window.getComputedStyle(element)
    let value = elementStyle.getPropertyValue(property)
    if (howToParse[parsing] !== undefined) {
        value = howToParse[parsing](value, 10)
    }
    return value
}

/**
 * Updates the word frequency tables with new data if enter key is pressed.
 * If the event target has a value (i.e., it's an input field) and the event key is "Enter",
 * call the updateWordsFrequencyTables function to update the word frequency tables.
 *
 * @returns {void}
 */
function updateWordsFreqIfPressEnterSimple() {
    if(event.key==='Enter'){
        updateWordsFrequencyTables()
    }
}

/**
 * Retrieves valid child nodes from an editor element by ID.
 * Traverses the DOM structure of the editor and collects valid text content from text nodes and SPANs.
 * Handles nested SPANs and tracks their positions for later processing.
 *
 * @param {string} idElement - The ID of the editor element to retrieve child nodes from.
 * @returns {Object} An object containing arrays of valid child nodes and their corresponding content, as well as the editor element itself.
 */
function getValidChildNodesFromEditorById(idElement) {
    const editorElement = document.getElementById(idElement);
    let validChildContent = [];
    const validNodeNames = Object.keys(objectChildNodeNamesToParse);

    // Helper: check if a node has at least one valid child node with non-empty text.
    function hasValidChild(node) {
        for (let i = 0; i < node.childNodes.length; i++) {
            const child = node.childNodes[i];
            if (validNodeNames.includes(child.nodeName)) {
                const prop = objectChildNodeNamesToParse[child.nodeName];
                if (child[prop] && child[prop].trim() !== "") {
                    return true;
                }
            }
        }
        return false;
    }

    // Recursive helper function.
    // topIdx: index of the top-level child from the editor element.
    // childIdx: index within the parent node, or null if top-level.
    // parentId: for nested nodes, the top-level parent's index (from a SPAN); otherwise null.
    function processNode(node, topIdx, childIdx = null, parentId = null) {
        // For nodes that are not SPAN, push their text if valid.
        if (node.nodeName !== "SPAN" && validNodeNames.includes(node.nodeName)) {
            const textField = objectChildNodeNamesToParse[node.nodeName];
            const textContent = node[textField];
            if (textContent && textContent.trim() !== "") {
                validChildContent.push({
                    idxRow: topIdx,
                    text: textContent,
                    idxRowChild: childIdx,
                    idxRowParent: parentId
                });
            }
        }
        // For SPAN nodes, decide: if it has no valid child, then push its own text;
        // otherwise, rely on its children.
        if (node.nodeName === "SPAN") {
            if (!hasValidChild(node)) {
                const textField = objectChildNodeNamesToParse[node.nodeName];
                const textContent = node[textField];
                if (textContent && textContent.trim() !== "") {
                    validChildContent.push({
                        idxRow: topIdx,
                        text: textContent,
                        idxRowChild: childIdx,
                        idxRowParent: parentId
                    });
                }
            }
        }
        // Recurse into childNodes.
        if (node.childNodes && node.childNodes.length > 0) {
            let newParentId = parentId;
            if (node.nodeName === "SPAN") {
                newParentId = topIdx;  // for nested nodes, use parent's top-level index.
            }
            for (let i = 0; i < node.childNodes.length; i++) {
                processNode(node.childNodes[i], topIdx, i, newParentId);
            }
        }
    }

    // Process each top-level child node of the editor.
    for (let i = 0; i < editorElement.childNodes.length; i++) {
        processNode(editorElement.childNodes[i], i);
    }

    return { validChildContent, editorElement };
}

/** Needed by lite.koboldai.net */
function toggleElementWithClassById(idElement, className="collapse") {
    let elementWithClassToChange = document.getElementById(idElement)
    if (elementWithClassToChange.classList.contains(className)) {
        elementWithClassToChange.classList.remove(className);
    } else {
        elementWithClassToChange.classList.add(className);
    }
}

function addClassById(idElement, className) {
    let elementWithClassToChange = document.getElementById(idElement)
    elementWithClassToChange.classList.add(className);
}

function closeWordsFreqTopNav(idElement) {
    addClassById(idElement, "collapse")
}

function removeClassById(idElement, className) {
    let elementWithClassToChange = document.getElementById(idElement)
    elementWithClassToChange.classList.remove(className);
}

function toggleOrCloseByBoolAndId(idElement, boolFlag, className="collapse") {
    switch (boolFlag) {
        case boolFlag === true:
            toggleElementWithClassById(idElement, className)
            break;
        case boolFlag === false:
            closeWordsFreqTopNav(idElement)
            break;
        default:
            console.error("toggleOrCloseByBoolAndId::something is wrong: idElement => ", idElement, "#")
            console.error("toggleOrCloseByBoolAndId::something is wrong: boolFlag => ", boolFlag, "#")
    }
}

async function updateWordsFreqIfPressEnter() {
    if (event.key === 'Enter') {
        closeWordsFreqTopNav('wordsFreqNavbarNavDropdown')
        const webserverIsCheckedEl = document.getElementById("id-input-webserver-wordfreq-checkbox")
        const webserverIsChecked = webserverIsCheckedEl.checked
        if (!webserverIsChecked) {
            await getWordsFrequency()
        } else {
            // in case id-input-webserver-wordfreq-checkbox is checked, this will only fire updateWordsFrequencyTables()
            // use instead btn4-get-words-frequency-get to fire getWordsFrequency()
            updateWordsFrequencyTables()
        }
    }
}

function gotoCurrentTableOfWords() {
    if (isMobilePortrait()) {
        console.log("gotoCurrentTableOfWords::isMobilePortrait()...")
        addClassById("id-current-table-of-words-btn-back", "display-block")
        removeClassById("id-current-table-of-words-btn-back", "collapse")
        addClassById("id-current-table-of-words-container", "display-block")
        removeClassById("id-current-table-of-words-container", "collapse")
        addClassById("id-list-of-words", "collapse")
        removeClassById("id-list-of-words", "display-block")
    }
}

function toggleWebserverCheckbox() {
    const checked = document.getElementById("id-input-webserver-wordfreq-checkbox").checked
    document.getElementById('id-input-webserver-wordfreq').disabled=!checked;
    document.getElementById('id-wordfreq-show-analyzer').innerText=checked?'webserver':'embedded';
}

function backToListFromCurrentTable() {
    if (isMobilePortrait()) {
        removeClassById("id-current-table-of-words-container", "display-block")
        addClassById("id-current-table-of-words-container", "collapse")
        removeClassById("id-list-of-words", "collapse")
        addClassById("id-list-of-words", "display-block")
    }
}

function isMobilePortrait() {
    console.log("isMobilePortrait::window.innerWidth:", window.innerWidth, window.screen.orientation, "#")
    const orientation = window.screen.orientation
    return window.innerWidth <= mobileInnerSize && (orientation.type === "portrait-primary" || orientation.type === "portrait-secondary")
}

function handleMobileWindow() {
    if (isMobile()) {
        closeWordsFreqTopNav("id-container-desktop-menu")
        closeWordsFreqTopNav("id-container-filter-sort-order")
        addClassById('id-words-frequency-table-title', "collapse");
        removeClassById('id-words-frequency-table-title-mobile', "collapse");
        removeClassById('id-container-mobile-menu', "collapse");
        removeClassById('id-container-filter-sort-order', "display-flex");
        removeClassById('id-container-filter-word-list', "width-50perc");
        removeClassById('id-container-sort-order-word-list', "width-50perc");

        removeClassById('id-current-table-of-words-container', "margin10px");
        addClassById('id-current-table-of-words-container', "margin2px");
    } else {
        closeWordsFreqTopNav("id-container-mobile-menu")
        // Always show desktop container on desktop
        removeClassById('id-container-desktop-menu', "collapse");
        removeClassById('id-container-filter-sort-order', "collapse");
        addClassById('id-container-filter-sort-order', "display-flex");
        addClassById('id-words-frequency-table-title-mobile', "collapse");
        addClassById('id-container-filter-word-list', "width-50perc");
        removeClassById('id-words-frequency-table-title', "collapse");
        addClassById('id-current-table-of-words-container', "margin10px");
        removeClassById('id-current-table-of-words-container', "margin2px");
    }
}

function isMobile() {
    return window.innerWidth <= mobileInnerSize || window.innerHeight <= mobileInnerSize;
}

window.addEventListener('resize', handleMobileWindow);
window.addEventListener('DOMContentLoaded', handleMobileWindow);
console.log('DOMContentLoaded');