MIDAS
Loading...
Searching...
No Matches
sequencer.js
Go to the documentation of this file.
1/*
2
3 Sequencer syntax highlighting and operation functions
4
5 Created by Zaher Salman on September 16th, 2023
6
7 Usage
8 =====
9
10 Some of these functions, although specific to the midas sequencer,
11 can be used for general syntax highlighting and editing.
12
13 All files created and read from subdirectories within
14 experiment_directory/userfiles/sequencer. The path defined in ODB
15 /Sequencer/State/Path is added to this folder.
16
17 Generate highlighted script text:
18 syntax_msl(seqLines,keywordGroups)
19 - seqLines - is the text of the script (or array of lines).
20 - keywordGroups - (optional) object defining the command groups for
21 highlighting. If not provided, a msl default will
22 be used.
23
24 Highlight the current line:
25 hlLine(lineNum,color,editor,msgs)
26 - lineNums- the line number to be highlighted (or an array of numbers)
27 - color - (optional) background color
28 - editor - (optional) if provided, scroll to highlighted line in editor
29 - msgs - (optional) if provided, use as title/s for the highlighted line/s
30
31 todo:
32 - syntax validate ODBSET, ODBGET etc. have a valid ODB parameter
33*/
34
35// Using Solarized color scheme - https://ethanschoonover.com/solarized/
36
37// Default msl definitions, keywords, indentation and file name
38var SeqODB = "/Sequencer";
39
40var mslDefs = {
41 get groups() {
42 if (SeqODB === "/PySequencer") {
43 return {
44 strings: {
45 regex: [
46 {pattern: String.raw`(?<!(?:''|\s))'(?:(?!').)*?(?<!')'|(?<!(?:""|\s))(\s*)"(?:(?!").)*?(?<!")"`, flags: "g" }
47 ],
48 color: "#008000",
49 mslclass: "msl_string"
50 },
51 dataManagement: {
52 keywords: [
53 "def", "return", "if", "elif", "else", "pass", "yield", "with", "as", "lambda",
54 "assert", "global", "nonlocal", "del", "await", "async"
55 ],
56 kwregex: ["^(\\s*)\\b(",")\\b","gim"],
57 color: "#268bd2",
58 mslclass: "msl_data_management"
59 },
60 variables: {
61 regex: [
62 { "pattern": "\\$\\w+", "flags": "g" },
63 { "pattern": "^(\\s*)(\\w+)(?=\\s*=)", "flags": "gm" }
64 ],
65 color: "#8B0000",
66 fontWeight: "",
67 mslclass: "msl_variable",
68 },
69 controlFlow: {
70 keywords: ["for", "while", "break", "continue"],
71 kwregex: ["\\b(", ")\\b", "g"],
72 color: "#d33682", // magenta
73 mslclass: "msl_control_flow"
74 },
75 exceptions: {
76 keywords: ["try", "except", "raise", "finally"],
77 kwregex: ["\\b(", ")\\b", "g"],
78 color: "#cb4b16", // orange
79 mslclass: "msl_exceptions"
80 },
81 imports: {
82 keywords: ["import", "from", "as"],
83 kwregex: ["\\b(", ")\\b", "g"],
84 color: "#6c71c4", // purple
85 mslclass: "msl_imports"
86 },
87 operators: {
88 keywords: ["in", "is", "not", "and", "or"],
89 kwregex: ["\\b(", ")\\b", "g"],
90 color: "#2aa198", // teal
91 mslclass: "msl_operators"
92 },
93 datatypes: {
94 keywords: ["int", "float", "str", "list", "dict", "set", "tuple", "bool", "complex"],
95 kwregex: ["\\b(", ")\\b", "g"],
96 color: "#859900", // green
97 mslclass: "msl_datatypes"
98 },
99 builtins: {
100 keywords: [
101 "print", "len", "range", "input", "open", "enumerate", "zip",
102 "map", "filter", "any", "all", "sum", "min", "max", "type", "dir"
103 ],
104 kwregex: ["\\b(", ")\\b", "g"],
105 color: "#b58900", // yellow
106 mslclass: "msl_builtins"
107 },
108 bool: {
109 keywords: ["True", "False", "None"],
110 kwregex: ["\\b(", ")\\b", "g"],
111 color: "#61b7b7",
112 mslclass: "msl_bool"
113 },
114 numbers: {
115 regex: [
116 { pattern: "\\b\\d+(\\.\\d+)?([eE][-+]?\\d+)?\\b", flags: "g" }
117 ],
118 color: "#b58900",
119 mslclass: "msl_number"
120 },
121 multilineComments: {
122 regex: [{
123 pattern: String.raw`^(\s*)("""|''')([^]*?)\2`,
124 flags: "gm"
125 }],
126 color: "#839496",
127 fontWeight: "italic",
128 mslclass: "msl_comment",
129 },
130 comments: {
131 regex: [
132 { pattern: "#.*?$", flags: "gm" }
133 ],
134 color: "#839496",
135 fontWeight: "italic",
136 mslclass: "msl_comment"
137 },
138 };
139 } else {
140 return {
141 strings: {
142 regex: [
143 { "pattern": "([\"'])(.*?)(?<!\\\\‍)\\1", "flags": "g" } // supports escaped quotes
144 ],
145 color: "#008000",
146 fontWeight: "",
147 mslclass: "msl_string",
148 },
149 variables: {
150 regex: [
151 { "pattern": "\\$\\w+", "flags": "g" },
152 { "pattern": "^(\\s*)(\\w+)(?=\\s*=)", "flags": "gm" }
153 ],
154 keywords: ["PARAM","CAT","SET"],
155 kwregex: ["^(\\s*)(?:", ")\\s+(\\w+)\\b", "gim"],
156 color: "#8B0000",
157 fontWeight: "",
158 mslclass: "msl_variable",
159 },
160 dataManagement: {
161 keywords: ["ODBSET", "ODBGET", "ODBCREATE", "ODBDELETE", "ODBINC", "ODBLOAD", "ODBSAVE", "PARAM", "SET", "CAT"],
162 kwregex: ["^(\\s*)\\b(",")\\b","gim"],
163 color: "#2aa198", // cyan
164 fontWeight: "",
165 mslclass: "msl_data_management",
166 },
167 dataTypes: {
168 keywords: ["UNIT8", "INT8", "UNIT16", "INT16", "UNIT32", "INT32", "BOOL", "FLOAT", "DOUBLE", "STRING"],
169 kwregex: ["(?<=,\\s*)\\b(",")\\b","gim"],
170 color: "#859900", // green
171 fontWeight: "",
172 mslclass: "msl_data_types",
173 },
174 loops: {
175 keywords: ["BREAK", "LOOP", "ENDLOOP", "ODBSUBDIR", "ENDODBSUBDIR"],
176 kwregex: ["^(\\s*)\\b(",")\\b","gim"],
177 color: "#d33682", // magenta
178 fontWeight: "",
179 mslclass: "msl_loops",
180 },
181 controlFlow: {
182 keywords: ["GOTO", "CALL", "SCRIPT", "SUBROUTINE", "ENDSUBROUTINE", "TRANSITION", "INCLUDE", "EXIT"],
183 kwregex: ["^(\\s*)\\b(",")\\b","gim"],
184 color: "#268bd2", // blue
185 fontWeight: "",
186 mslclass: "msl_control_flow",
187 },
188 info: {
189 keywords: ["RUNDESCRIPTION", "LIBRARY", "MESSAGE", "MSG"],
190 kwregex: ["^(\\s*)\\b(",")\\b","gim"],
191 color: "#6c71c4", // violet
192 fontWeight: "",
193 mslclass: "msl_info",
194 },
195 cond: {
196 keywords: ["IF", "ELSE", "ENDIF", "WAIT"],
197 kwregex: ["^(\\s*)\\b(",")\\b","gim"],
198 color: "#c577f6", // pink (not solarized)
199 fontWeight: "",
200 mslclass: "msl_cond",
201 },
202 units: {
203 keywords: ["SECONDS", "EVENTS", "ODBVALUE"],
204 kwregex: ["\\b(",")\\b","gi"],
205 color: "#cb4b16", // orange
206 fontWeight: "",
207 mslclass: "msl_units",
208 },
209 actions: {
210 keywords: ["start", "stop", "pause", "resume"],
211 kwregex: ["\\b(", ")\\b", "gi"], //["\\b(",")\\b(\\s*)$","gim"],
212 color: "#dc322f", // red
213 fontWeight: "",
214 mslclass: "msl_actions",
215 },
216 numbers: {
217 //regex: [/\b(\d+(\.\d+)?([eE][-+]?\d+)?)\b/g],
218 regex: [
219 { "pattern": "\\b\\d+(\\.\\d+)?([eE][-+]?\\d+)?\\b", "flags": "g" }
220 ],
221 color: "#b58900", // yellow
222 fontWeight: "",
223 mslclass: "msl_number",
224 },
225 bool: {
226 keywords: ["true","false"],
227 kwregex: ["\\b(",")\\b","gim"],
228 color: "#61b7b7",
229 fontWeight: "",
230 mslclass: "msl_bool",
231 },
232 comments: {
233 regex: [
234 { pattern: "(^\\s*COMMENT\\b.*?$|#.*?$)", flags: "gim" }
235 ],
236 color: "#839496",
237 fontWeight: "italic",
238 mslclass: "msl_comment"
239 }
240 }
241 }
242 },
243
244 get defIndent() {
245 if (SeqODB === "/PySequencer") {
246 return {
247 indentplus: ["def", "for", "if", "elif", "else", "try", "except", "finally", "with", "while"],
248 indentminos: ["elif", "else", "except", "finally"],
249 nIndent: 4,
250 };
251 } else {
252 return {
253 indentplus: ["IF", "LOOP", "ELSE", "SUBROUTINE", "ODBSUBDIR"],
254 indentminos: ["ENDIF", "ENDLOOP", "ELSE", "ENDSUBROUTINE", "ENDODBSUBDIR"],
255 nIndent: 3,
256 };
257 }
258 },
259
260 get filename() {
261 return {
262 path : "sequencer",
263 next : `${SeqODB}/State/Next Filename`,
264 ext : SeqODB === "/PySequencer" ? "*.py" : "*.msl"
265 };
266 },
267};
268
269var seq_css = `
270/* For buttons */
271.seqbtn {
272 display: none;
273}
274
275#iconsRow img {
276 cursor: pointer;
277 padding: 0.4em;
278 margin: 0;
279}
280#iconsRow img:hover {
281 background: #C0D0D0;
282}
283
284.edt_linenum {
285 background-color: #f0f0f0;
286 user-select: none;
287 width: 3em;
288 overflow: hidden;
289 color: gray;
290 text-align: right;
291 padding: 5px 5px 20px 5px;
292 box-sizing: border-box;
293 vertical-align: top;
294 display: inline-block;
295 margin: 0 0 0 0;
296 font-family: monospace;
297 white-space: pre;
298 -moz-user-select: text;
299 pointer-events: none;
300}
301.edt_linenum span {
302 display: block;
303}
304.edt_linenum_curr {
305 color: #000000;
306 font-weight: bold;
307}
308.edt_area {
309 width: calc(100% - 3em);
310 max-width: calc(100% - 3em);
311 overflow:auto;
312 /* resize: horizontal; */
313 background-color:white;
314 color: black;
315 padding: 5px 5px 20px 5px;
316 box-sizing: border-box;
317 vertical-align: top;
318 display: inline-block;
319 margin: -1px 0 0 0;
320 border-top: 1px solid gray;
321 font-family: monospace;
322 white-space: pre;
323 -moz-user-select: text;
324}
325
326/*
327 * Allow the editor to fill available vertical space.
328 * Scoped to the AutoRun editor container (.etabcontent) to avoid impacting
329 * any other <pre> usage on the page.
330 */
331/*
332.etabcontent {
333 display: flex;
334 width: 100%;
335 flex: 1 1 auto;
336 min-height: 0;
337}
338
339.etabcontent .edt_linenum {
340 flex: 0 0 3em;
341 height: 100%;
342}
343
344.etabcontent .edt_area {
345 flex: 1 1 auto;
346 min-width: 0;
347 height: 100%;
348}
349
350.etabcontent .info_column {
351 flex: 0 0 285px;
352 min-width: 0;
353 height: 100%;
354}
355*/
356.sline {
357 display: inline-block;
358}
359.esline {
360 display: inline-block;
361}
362
363.msl_comment, .msl_comment * {
364 all: revert !important;
365 color: #839496 !important;
366 font-weight: italic !important;
367 background: none !important;
368}
369
370.msl_current_line {
371 background-color: #FFFF00;
372 font-weight: bold;
373}
374.msl_current_line:hover {
375 opacity : 0.8;
376}
377.msl_fatal {
378 background-color: #FF0000;
379 font-weight: bold;
380}
381.msl_err {
382 background-color: #FF8800;
383 font-weight: bold;
384}
385.msl_msg {
386 background-color: #11FF11;
387 font-weight: bold;
388}
389.msl_current_cmd {
390 background-color: #FFFF00;
391 font-weight: bold;
392}
393.info_column{
394 overflow:auto;
395 /*resize: horizontal;*/
396 align-items: start;
397 padding-left: 5px;
398}
399.infotable {
400 width: 100%;
401 border-spacing: 0px;
402 border: 1px solid black;
403 border-radius: 5px;
404 text-align: left;
405 padding: 0px;
406 float: right;
407 line-height: 30px; /* since modbvalue forces resize */
408}
409.infotable th{
410 white-space: nowrap;
411}
412.infotable tr:first-child th:first-child,
413.infotable tr:first-child td:first-child {
414 border-top-left-radius: 5px;
415}
416.infotable tr:first-child th:last-child,
417.infotable tr:first-child td:last-child {
418 border-top-right-radius: 5px;
419}
420.infotable tr:last-child td:first-child {
421 border-bottom-left-radius: 5px;
422}
423.infotable tr:last-child td:last-child {
424 border-bottom-right-radius: 5px;
425}
426.waitlooptxt {
427 z-index: 33;
428 position: absolute;
429 top: 1px;
430 left: 5px;
431 display: inline-block;
432}
433.waitloopbar {
434 z-index: 32;
435 width: calc(100% - 2px);
436 height: 100%;
437 text-align: left;
438}
439
440/* Dropdown button styles*/
441.dropmenu {
442 background-color: Transparent;
443 font-size: 100%;
444 font-family: verdana,tahoma,sans-serif;
445 border: none;
446 cursor: pointer;
447 text-align: left;
448 padding: 5px;
449 line-height: 1;
450 color:#404040;
451}
452.dropmenu:hover {
453 background-color: #C0D0D0;
454}
455
456/* Style the dropdown content (hidden by default) */
457.dropdown {
458 position: relative;
459 display: inline-block;
460}
461.dropdown-content {
462 display: none;
463 position: absolute;
464 background-color: #f9f9f9;
465 min-width: 150px;
466 box-shadow: 0 8px 16px rgba(0,0,0,0.2);
467 z-index: 10;
468 font-size: 90%;
469 color: black;
470 white-space: nowrap;
471 cursor: pointer;
472}
473.dropdown-content a{
474 display: block;
475 padding: 4px 8px;
476}
477.dropdown-content div{
478 display: flex;
479 justify-content: space-between;
480 align-items: center;
481 padding: 4px 8px;
482}
483.dropdown-content a:hover {
484 background-color: #C0D0D0;
485}
486.dropdown-content div:hover {
487 background-color: #C0D0D0;
488}
489.dropdown:hover .dropdown-content {
490 display: block;
491}
492
493 /* Style the tab */
494.etab {
495 position: relative;
496 top: 2px;
497 z-index: 1;
498 overflow-x: auto;
499 white-space: nowrap;
500 width: 100%;
501 display: block;
502 -webkit-overflow-scrolling: touch;
503 border: none;
504}
505.etab button {
506 background-color: #D0D0D0;
507 float: left;
508 margin: 4px 2px 0px 2px;
509 border-top-left-radius: 10px;
510 border-top-right-radius: 10px;
511 border: none;
512 border-top: 1px solid Transparent;
513 border-right: 1px solid Transparent;
514 border-left: 1px solid Transparent;
515 cursor: pointer;
516 padding: 3px 5px 3px 5px;
517}
518.etab button:hover {
519 background-color: #FFFFFF;
520 border-bottom: 5px solid #FFFFFF;
521}
522.etab button.edt_active:hover {
523 background-color: #FFFFFF;
524 border-bottom: 5px solid #FFFFFF;
525}
526.etab button.edt_active {
527 background-color: white;/*Transparent;*/
528 border-top: 1px solid gray;
529 border-right: 1px solid gray;
530 border-left: 1px solid gray;
531 border-bottom: 5px solid white;
532 color: black;
533}
534.etabcontent {
535 display: none;
536 padding: 0;
537 border-top: none;
538 height: 100%;
539}
540.editbtn{
541 background-color: #f0f0f0;
542 border: 1px solid black;
543 border-radius: 3px;
544 cursor: pointer;
545 padding: 0px 2px 0px 2px;
546 margin-left: 3px;
547}
548.editbtn:hover{
549 background-color: #C0D0D0;
550}
551.closebtn {
552 font-family: verdana,tahoma,sans-serif;
553 border: none;
554 border-radius: 3px;
555 cursor: pointer;
556 padding: 2px;
557 margin-left: 5px;
558}
559.closebtn:hover {
560 background-color: #FD5E59;
561}
562
563.progress {
564 width: 200px;
565 font-size: 0.7em;
566}
567.dlgProgress {
568 position: fixed;
569 display: none;
570 flex-direction: column;
571 min-width: max-content;
572 min-height: max-content;
573 border: 1px solid black;
574 box-shadow: 6px 6px 10px 4px rgba(0,0,0,0.2);
575 border-radius: 6px;
576 position: fixed;
577 display: none;
578}
579
580.empty-dragrow td{
581 border: 2px dashed #6bb28c;
582 background-color: white;
583}
584tr.dragstart td{
585 background-color: gray;
586 color: white;
587}
588tr.dragrow td{
589 overflow: hidden;
590 text-overflow: ellipsis;
591 max-width: calc(10em - 30px);
592}
593#nextFNContainer img {
594 cursor: pointer;
595 height: 1em;
596 vertical-align: middle;
597}
598`;
599const base_seq_css = seq_css;
600
601// line connector string
602const lc = "\n";
603// revisions array, maximum nRevisions
604const previousRevisions = {};
605const nRevisions = 20;
606var revisionIndex = {};
607var saveRevision = {};
608// Meta combo keydown flag
609var MetaCombo = false;
610// Deal with Chrome issues
611var isChrome = browserType();
612// Make current state global
613var stateText = "Unknown";
614
615// -- Sequencer specific functions --
616// Implement colors and styles from KeywordsGroups in CSS
617function seqCSS() {
618 let syntaxCSS = "";
619 for (const group in mslDefs.groups) {
620 const { mslclass, color, fontWeight } = mslDefs.groups[group];
621 if (mslclass) {
622 if (fontWeight) {
623 syntaxCSS += `.${mslclass} { color: ${color}; font-weight: ${fontWeight}; }\n`;
624 } else {
625 syntaxCSS += `.${mslclass} { color: ${color};}\n`;
626 }
627 }
628 }
629 seq_css = base_seq_css + syntaxCSS;
630 let seqStyle = document.getElementById("seq-editor-style");
631 if (!seqStyle) {
632 seqStyle = document.createElement("style");
633 seqStyle.id = "seq-editor-style";
634 document.head.appendChild(seqStyle);
635 }
636 seqStyle.textContent = seq_css;
637}
638seqCSS();
639
640// Setup the correct sequencer state visually
641function seqState(funcCall) {
642 /* Arguments:
643 funcCall - (optional) a function to be called when the state is set (with the state text)
644 */
645 stateText = "Stopped";
646 // Check sequence state
647 mjsonrpc_db_get_values([`${SeqODB}/State/Running`,`${SeqODB}/State/Paused`,`${SeqODB}/State/Finished`,`${SeqODB}/State/Debug`,`${SeqODB}/State/Stop after run`]).then(function(rpc) {
648 if (rpc.result.data[0] && !rpc.result.data[1] && !rpc.result.data[2] && !rpc.result.data[4]) {
649 stateText = "Running";
650 } else if (rpc.result.data[1] && rpc.result.data[0] && !rpc.result.data[2] && !rpc.result.data[4]) {
651 stateText = "Paused";
652 } else if (rpc.result.data[0] && !rpc.result.data[1] && !rpc.result.data[2] && rpc.result.data[4]) {
653 // Check if stop after run is set and running
654 stateText = "Running.StopAfter"
655 } else if (rpc.result.data[0] && rpc.result.data[1] && !rpc.result.data[2] && rpc.result.data[4]) {
656 // Check if stop after run is set and paused
657 stateText = "Paused.StopAfter"
658 } else {
659 stateText = "Stopped";
660 }
661
662 if (funcCall) {
663 funcCall(stateText);
664 }
665 return stateText;
666 }).catch (function (error) {
667 console.error(error);
668 return "Unknown";
669 });
670}
671
672// Ask user to edit current sequence
673function askToEdit(flag,event) {
674 if (flag) {
675 openETab(document.getElementById("etab1-btn"));
676 const [lineNumbers,editor,btnLabel,label] = editorElements();
677 // make editable and add event listeners
678 //editor.contentEditable = true;
679 addETab(document.getElementById("addETab"));
680 seqOpen(label.title.split("\n")[0]);
681 event.stopPropagation();
682 return;
683 }
684 const message = "To edit the sequence it must be opened in a separate tab.<br>Would you like to proceed?";
685 dlgConfirm(message,function(resp) {
686 if (resp) {
687 const label = editorElements()[3];
688 addETab(document.getElementById("addETab"));
689 seqOpen(label.title.split("\n")[0]);
690 }
691 });
692}
693
694
695// Main tab editor, opens automatically when sequencer is stopped
696// Defauls button color is red, becomes white if someone start the sequencer
697function mainEditor() {
698 openETab(document.getElementById("etab1-btn"));
699 let [lineNumbers,mainTab,btnLabel,label] = editorElements();
700 mjsonrpc_db_get_values([`${SeqODB}/Script/Lines`,`${SeqODB}/State/Running`,`${SeqODB}/State/SCurrent line number`,`${SeqODB}/State/Filename`,`${SeqODB}/State/SFilename`,`${SeqODB}/State/Path`]).then(function(rpc) {
701 let seqLines = rpc.result.data[0];
702 let seqState = rpc.result.data[1];
703 let currLine = rpc.result.data[2];
704 let filename = rpc.result.data[3] ? rpc.result.data[3] : "Untitled";
705 let sfilename = rpc.result.data[4] ? rpc.result.data[4].split('userfiles/sequencer/')[1] : "";
706 filename = (mslDefs.filename.path + "/" + rpc.result.data[5] + "/" + filename).replace("//","/");
707 if (!seqState) {
708 // Hide main tab
709 lineNumbers.style.display = "none";
710 mainTab.style.display = "none";
711 btnLabel.parentElement.style.display = "none";
712 // Insert tab in second position
713 const btn = (document.querySelector(".edt_main")) ? document.querySelector(".edt_main") : addETab(document.getElementById("addETab"),2);
714 btn.querySelector(".closebtn").style.display = "none";
715 // Make sure you switch to main editor tab
716 btn.click();
717 [lineNumbers,editor,btnLabel,label] = editorElements();
718 btn.style.backgroundColor = "var(--mred)";
719 btn.className += " edt_main";
720 if (seqLines) {
721 editor.innerHTML = syntax_msl(seqLines).join(lc);
722 updateLineNumbers(editor.previousElementSibling,editor);
723 }
724 let file = filename.split("/").pop();
725 let path = filename.substring(0, filename.lastIndexOf("/") + 1).replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
726 btnLabel.title = filename.replaceAll(/\/+/g, '/');
727 btnLabel.innerText = file;
728 sessionStorage.setItem("fileName",file);
729 sessionStorage.setItem("pathName",path);
730 } else {
731 // Is there an editor already?
732 const btn = document.querySelector(".edt_main");
733 if (btn) {
734 // Check if it is changed
735 openETab(btn);
736 if (seqIsChanged()) {
737 btn.classList.remove("edt_main");
738 btn.style.backgroundColor = "";
739 dlgAlert("Warning: Sequencer started from a different browser.<br>Make save you save your changes to a different file name!");
740 btn.querySelector(".closebtn").style.display = "";
741 btn.children[0].title = "";
742 btn.children[0].innerText = "Untitled";
743 } else {
744 btn.querySelector(".closebtn").click();
745 }
746 }
747 // Show main tab
748 lineNumbers.style.display = "";
749 mainTab.style.display = "";
750 btnLabel.parentElement.style.display = "";
751 }
752 windowResize();
753 }).catch (function (error) {
754 console.error(error);
755 });
756}
757
758// Enable editing of sequence
759function editorEventListeners() {
760 let [lineNumbers,editor] = editorElements();
761 editor.contentEditable = true;
762 // Attached syntax highlight event editor
763 editor.addEventListener("keydown",checkSyntaxEventDown);
764 editor.addEventListener("keyup",checkSyntaxEventUp);
765 editor.addEventListener("paste", checkSyntaxEventPaste,true);
766 // Change in text
767 editor.addEventListener("input", function() {
768 seqIsChanged(true);
769 });
770 document.addEventListener("selectionchange", function(event) {
771 if (event.target.activeElement === editor) markCurrLineNum();
772 });
773 // Synchronize the scroll position of lineNumbers with editor
774 editor.addEventListener("scroll", function() {
775 lineNumbers.scrollTop = editor.scrollTop;
776 });
777 if (isChrome === 1) {
778 editor.addEventListener("keydown", arrowKeysChrome);
779 }
780}
781
782// apply changes of filename in the ODB (triggers reload)
783function seqChange(filename) {
784 /* Arguments:
785 filename - full file name with path to change
786 */
787 if (!filename) return;
788 const lastIndex = filename.lastIndexOf('/');
789 const path = filename.substring(0, lastIndex).replace(new RegExp('^' + mslDefs.filename.path),"").replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
790 const file = filename.substring(lastIndex + 1);
791 // set path and filename, wait for completion and return
792 mjsonrpc_db_paste([`${SeqODB}/State/Path`,`${SeqODB}/State/Filename`],[path,file]).then(function (rpc1) {
793 sessionStorage.removeItem("depthDir");
794 if (rpc1.result.status[0] === 1 && rpc1.result.status[1] === 1) {
795 mjsonrpc_db_paste([`${SeqODB}/Command/Load new file`],[true]).then(function (rpc2) {
796 if (rpc2.result.status[0] === 1) {
797 return;
798 }
799 }).catch(function (error) {console.error(error);});
800 } else {
801 dlgAlert("Something went wrong, I could not set the filename!");
802 }
803 }).catch(function (error) {console.error(error);});
804}
805
806// Save sequence text in filename.
807function seqSave(filename) {
808 /* Arguments:
809 filename (opt) - save to provided filename with path. If undefined save to original
810 filename and if empty trigger file_picker.
811 */
812 let [lineNumbers,editor,label,btn] = editorElements();
813 let currFilename = document.getElementById("etab1-btn").title.split("\n")[0];
814 let text = editor.innerText;
815 let ext = mslDefs.filename.ext.replace("*","");
816 if (editor.id !== "editorTab1") {
817 if (filename === undefined) {
818 // take name from button title
819 filename = label.title;
820 if (filename.endsWith(ext)) {
821 if (filename == currFilename && stateText === "Running") {
822 const message = `Sequence ${filename} is currently running.<br>Are you sure you want to overwrite it?`
823 dlgConfirm(message,function(resp) {
824 if (resp) {
825 file_save_ascii_overwrite(filename,text);
826 seqIsChanged(false);
827 }
828 });
829 } else {
830 file_save_ascii_overwrite(filename,text);
831 seqIsChanged(false);
832 }
833 } else {
834 seqSave("");
835 }
836 } else if (filename === "") {
837 filename = label.title;
838 let file = filename.split("/").pop();
839 let path = filename.substring(0, filename.lastIndexOf("/") + 1).replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
840 // If file/path are empty start with default value
841 if (path === "")
842 path = sessionStorage.getItem("pathName") ? sessionStorage.getItem("pathName") : mslDefs.filename.path + "/";
843 if (file === "")
844 file = sessionStorage.getItem("fileName") ? sessionStorage.getItem("fileName") : "filename" + ext;
845 file_picker(path,mslDefs.filename.ext,seqSave,true,{},true);
846 } else {
847 file_save_ascii_overwrite(filename,text);
848 let file = filename.split("/").pop();
849 let path = filename.substring(0, filename.lastIndexOf("/") + 1).replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
850 label.title = filename;
851 label.innerText = file;
852 sessionStorage.setItem("fileName",file);
853 sessionStorage.setItem("pathName",path);
854 seqIsChanged(false);
855 }
856 // Check if filename is currently in editorTab1 and reload
857 // If we are saving the stopped-state main editor, make the saved file
858 // the selected sequence as well. This makes Save As from the main editor
859 // behave like users expect: the visible main file remains the active one.
860 if (btn.classList.contains("edt_main") && stateText === "Stopped" && filename.endsWith(ext)) {
861 seqChange(filename);
862 }
863 return;
864 }
865}
866
867// Open filename
868function seqOpen(filename) {
869 /* Arguments:
870 filename - file name to open (empty trigger file_picker)
871 */
872 // if a full filename is provided, open and return
873 if (filename && filename !== "") {
874 // Identify active tab
875 let [lineNumbers,editor,label] = editorElements();
876 // Check the option to open in new tab, also open in new tab if sequence is running
877 if ((document.getElementById("inNewTab").checked && (label.title !== "" || editor.id !== "editorTab1")) ||
878 (editor.id === "editorTab1" && stateText === "Running")) {
879 addETab(document.getElementById("addETab"));
880 [lineNumbers,editor,label] = editorElements();
881 }
882 let file = filename.split("/").pop();
883 let path = filename.substring(0, filename.lastIndexOf("/") + 1).replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
884 label.title = filename.replaceAll(/\/+/g, '/');
885 label.innerText = file;
886 sessionStorage.setItem("fileName",file);
887 sessionStorage.setItem("pathName",path);
888 if (editor.id === "editorTab1") {
889 seqChange(filename);
890 } else {
891 file_load_ascii(filename, function(text) {
892 editor.innerHTML = syntax_msl(text).join(lc).slice(0,-1);
893 updateLineNumbers(lineNumbers,editor);
894 // Change state to not edited
895 seqIsChanged(false);
896 // save first state
897 saveState(text,editor);
898 });
899 }
900 return;
901 } else {
902 // empty or undefined file name
903 mjsonrpc_db_get_values([`${SeqODB}/State/Path`]).then(function(rpc) {
904 let path = mslDefs.filename.path + "/" + rpc.result.data[0].replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
905 sessionStorage.setItem("pathName",path);
906 file_picker(path,mslDefs.filename.ext,seqOpen,false);
907 }).catch(function (error) {
908 mjsonrpc_error_alert(error);
909 });
910}
911}
912
913// Show/hide buttons according to sequencer state
914function updateBtns(state) {
915 /* Arguments:
916 state - the state of the sequencer
917 */
918 const seqState = {
919 Running: {
920 color: "var(--mgreen)",
921 },
922 Stopped: {
923 color: "var(--mred)",
924 },
925 Editing: {
926 color: "var(--mred)",
927 },
928 Paused: {
929 color: "var(--myellow)",
930 },
931 }
932 const color = seqState[state.split(".")[0]].color;
933 const nclass = state.split(".").length;
934 const seqStateSpans = document.querySelectorAll('.seqstate');
935 seqStateSpans.forEach(e => {e.style.backgroundColor = color;});
936 // hide all buttons
937 const hideBtns = document.querySelectorAll('.seqbtn');
938 hideBtns.forEach(button => {
939 button.style.display = "none";
940 });
941 // then show only those belonging to the current state
942 const showBtns = document.querySelectorAll('.seqbtn.' + state);
943 showBtns.forEach(button => {
944 if (button.tagName === "IMG") {
945 button.style.display = "inline-block";
946 } else {
947 button.style.display = "flex";
948 }
949 });
950 // Hide exclusive buttons
951 if (nclass === 1) {
952 const exclBtns = document.querySelectorAll('.seqbtn.Exclusive');
953 exclBtns.forEach(button => {
954 button.style.display = "none";
955 });
956 }
957 // Hide progress modal when stopped
958 const hideProgress = document.getElementById("Progress");
959 if (state === "Stopped" && hideProgress) hideProgress.style.display = "none";
960 //mainEditor();
961}
962
963// Show sequencer messages if present
964function mslMessage(message) {
965 // Empty message, return
966 if (!message) return; // Check message and message wait
967 mjsonrpc_db_get_values([`${SeqODB}/State/Message`,`${SeqODB}/State/Message Wait`]).then(function(rpc) {
968 const message = rpc.result.data[0];
969 const hold = rpc.result.data[1];
970 if (hold) {
971 dlgMessage("Message", message, true, false,clrMessage);
972 } else {
973 dlgAlert(message);
974 }
975 }).catch (function (error) {
976 console.error(error);
977 });
978}
979
980// Clear sequencer messages
981function clrMessage() {
982 mjsonrpc_db_paste([`${SeqODB}/State/Message`], [""]).then(function (rpc) {
983 return;
984 }).catch(function (error) {
985 console.error(error);
986 });
987}
988
989// Adjust size of sequencer editor according to browser window size
990function windowResize() {
991 const [lineNumbers, editor] = editorElements();
992 const seqTable = document.getElementById("seqTable");
993 const infoColumn = document.getElementById("infoColumn");
994
995 // Calculate scrollbar width
996 const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
997
998 // Visible width of the window accounting for scrollbar
999 let winWidth = Math.max(document.documentElement.clientWidth, window.innerWidth) - scrollbarWidth;
1000
1001 // Visible height of the window
1002 let winHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
1003
1004 // Set seqTable height and width to fit the remaining space in the viewport
1005 seqTable.style.width = winWidth - seqTable.getBoundingClientRect().left + "px";
1006 seqTable.style.height = winHeight - seqTable.getBoundingClientRect().top - 20 + "px";
1007
1008 // Adjust editor dimensions
1009 const editorTop = editor.getBoundingClientRect().top;
1010 editor.style.height = winHeight - editorTop - 20 + "px";
1011 editor.style.width = winWidth - editor.getBoundingClientRect().left - infoColumn.getBoundingClientRect().width - 10 + "px";
1012 editor.style.maxWidth = editor.style.width;
1013
1014 // Adjust infoColumn and lineNumbers heights to match the editor height
1015 infoColumn.style.height = editor.style.height;
1016 lineNumbers.style.height = editor.style.height;
1017}
1018
1019// Load the current sequence from ODB (only on main tab)
1020function load_msl() {
1021 const editor = document.getElementById("editorTab1");
1022 const btn = document.getElementById("etab1-btn");
1023 mjsonrpc_db_get_values([`${SeqODB}/Script/Lines`,`${SeqODB}/State/Running`,`${SeqODB}/State/SCurrent line number`,`${SeqODB}/State/Filename`,`${SeqODB}/State/SFilename`,`${SeqODB}/State/Path`]).then(function(rpc) {
1024 let seqLines = rpc.result.data[0];
1025 let seqState = rpc.result.data[1];
1026 let currLine = rpc.result.data[2];
1027 let filename = rpc.result.data[3];
1028 let sfilename = rpc.result.data[4] ? rpc.result.data[4].split('userfiles/sequencer/')[1] : "";
1029 filename = (mslDefs.filename.path + "/" + rpc.result.data[5] + "/" + filename).replace("//","/");
1030 // syntax highlight
1031 if (seqLines) {
1032 editor.innerHTML = syntax_msl(seqLines).join(lc);
1033 updateLineNumbers(editor.previousElementSibling,editor);
1034 if (seqState) hlLine(currLine);
1035 }
1036 let file = filename.split("/").pop();
1037 let path = filename.substring(0, filename.lastIndexOf("/") + 1).replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
1038 sessionStorage.setItem("fileName",file);
1039 sessionStorage.setItem("pathName",path);
1040 // change button title to add sfilename if present
1041 btn.title = (sfilename && sfilename !== filename) ? filename + "\n" + sfilename : filename;
1042 // also change validation icon to defaule
1043 let vldBtn = document.getElementById("vldBtn");
1044 vldBtn.src = "icons/validate-syntax.svg";
1045 document.querySelectorAll('.seqbtn').forEach(btn => {
1046 btn.style.backgroundColor = "";
1047 });
1048 }).catch (function (error) {
1049 console.error(error);
1050 });
1051}
1052
1053// Highlight (background color) and scroll to current line
1054function hlLine(lineNums,color,editor,msgs = []) {
1055 /* Arguments:
1056 lineNums- the line number to be highlighted (or an array of numbers)
1057 color - (optional) background color
1058 editor - (optional) if provided, scroll to highlighted line in editor
1059 msgs - (optional) if provided, use as title/s for the highlighted line/s
1060 */
1061
1062 // Remove highlight from all lines with the class "msl_current_line"
1063 const highlightedLines = document.querySelectorAll(".msl_current_line");
1064 highlightedLines.forEach((line) => line.classList.remove("msl_current_line"));
1065
1066 // If single value make an array
1067 lineNums = Array.isArray(lineNums) ? lineNums : [lineNums];
1068 msgs = Array.isArray(msgs) ? msgs : [msgs];
1069 let counter = 0;
1070 lineNums.forEach(lineNum => {
1071 const lineId = "sline" + lineNum;
1072 const lineHTML = (editor) ? editor.querySelector(`#${lineId}`) : document.getElementById(lineId);
1073
1074 if (lineHTML) {
1075 lineHTML.classList.add("msl_current_line");
1076 if (color) lineHTML.style.backgroundColor = color;
1077 if (msgs[counter]) lineHTML.title = msgs[counter];
1078 // Scroll to the highlighted line if the checkbox is checked
1079 const scrollToCurrCheckbox = document.getElementById("scrollToCurr");
1080 if (((scrollToCurrCheckbox && scrollToCurrCheckbox.checked) || editor) && (counter == 0)) {
1081 lineHTML.scrollIntoView({ block: "center" });
1082 }
1083 }
1084 counter++;
1085 });
1086}
1087
1088// Scroll to make line appear in the center of editor
1089function scrollToCurr(scrToCur) {
1090 if (scrToCur.checked) {
1091 localStorage.setItem("scrollToCurr",true);
1092 const currLine = document.querySelector(".msl_current_line");
1093 if (currLine) {
1094 currLine.scrollIntoView({ block: "center" });
1095 }
1096 } else {
1097 localStorage.removeItem("scrollToCurr",true);
1098 }
1099}
1100
1101// Open files in new tabs
1102function toggleCheck(e) {
1103 if (e.checked) {
1104 localStorage.setItem(e.id,true);
1105 } else {
1106 localStorage.removeItem(e.id);
1107 }
1108}
1109
1110// shortcut event handling to overtake default behaviour
1111function shortCutEvent(event) {
1112 const parEditor = editorElements();
1113 const notFirstTab = (parEditor[1].id !== "editorTab1");
1114 if (notFirstTab) {
1115 // Check these only for editors
1116 if (event.altKey && event.key === 's') {
1117 event.preventDefault();
1118 //save as with file_picker
1119 seqSave('');
1120 event.preventDefault();
1121 } else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
1122 event.preventDefault();
1123 //save to the same filename
1124 seqSave();
1125 event.preventDefault();
1126 } else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
1127 event.preventDefault();
1128 undoEdit(event.target);
1129 event.preventDefault();
1130 } else if ((event.ctrlKey || event.metaKey) && event.key === 'r') {
1131 event.preventDefault();
1132 redoEdit(event.target);
1133 event.preventDefault();
1134 }
1135 }
1136 if (!notFirstTab) {
1137 // Check these only for first tab
1138 if ((event.ctrlKey || event.metaKey) && event.key === 'e') {
1139 // open new tab and load current sequence
1140 event.preventDefault();
1141 addETab(document.getElementById("addETab"));
1142 seqOpen(parEditor[3].title.split("\n")[0]);
1143 event.preventDefault();
1144 }
1145 }
1146 // Check these for all tabs
1147 if (event.altKey && event.key === 'n') {
1148 // open new tab
1149 event.preventDefault();
1150 addETab(document.getElementById("addETab"));
1151 event.preventDefault();
1152 } else if (event.altKey && event.key === 'o') {
1153 event.preventDefault();
1154 seqOpen();
1155 event.preventDefault();
1156 }
1157
1158}
1159
1160// Trigger syntax highlighting on keyup events
1161function checkSyntaxEventUp(event) {
1162 if (event.ctrlKey || event.altKey || event.metaKey || MetaCombo) return;
1163 if (event.keyCode >= 0x30 || event.key === ' '
1164 || event.key === 'Backspace' || event.key === 'Delete'
1165 || event.key === 'Enter'
1166 ) {
1167
1168 const e = event.target;
1169 let caretPos = getCurrentCursorPosition(e);
1170 let currText = e.innerText;
1171 // Indentation keywords
1172 const defIndent = mslDefs.defIndent;
1173 const nIndent = defIndent.nIndent || 3;
1174 // save current revision for undo
1175 if (event.key === ' ' || event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Enter') {
1176 saveState(currText,e);
1177 seqIsChanged(true);
1178 }
1179
1180 // Indent according to previous line
1181 if (event.key === 'Enter') {
1182 event.preventDefault();
1183 // get previous and current line elements (before and after enter)
1184 let pline = whichLine(e,-1);
1185 let cline = whichLine(e);
1186 let plineText = (pline) ? pline.innerText : "";
1187 let clineText = (cline) ? cline.innerText : "";
1188 let indentLevel = 0;
1189 let preSpace = 0;
1190 let indentString = "";
1191 if (plineText) {
1192 // indent line according to the previous line text
1193 // if, loop, else, subroutine - increase indentation
1194 const indentPlus = defIndent.indentplus.some(keyword => plineText.trim().toLowerCase().startsWith(keyword.toLowerCase()));
1195 // else, endif, endloop, endsubroutine - decrease indentation
1196 const indentMinos = defIndent.indentminos.some(keyword => plineText.trim().toLowerCase().startsWith(keyword.toLowerCase()));
1197 /* (indentMinos/indentPlus)
1198 true/false - pline indent -1, cline indent 0
1199 fale/true - pline indent 0, cline indent +1
1200 true/true - pline indent -1, cline indent +1
1201 false/false- pline indent 0, cline indent 0
1202 */
1203 // Count number of white spaces at begenning of pline
1204 preSpace = plineText.replace("\n","").search(/\S|$/);
1205 pPreSpace = preSpace - indentMinos * nIndent;
1206 if (pPreSpace < 0) pPreSpace = 0;
1207 cPreSpace = pPreSpace + indentPlus * nIndent;
1208 // Calculate and insert indentation
1209 pIndentString = " ".repeat(pPreSpace);
1210 cIndentString = " ".repeat(cPreSpace);
1211 cline.innerText = cIndentString + clineText.trimStart();
1212 caretPos += cline.innerText.length - clineText.length;
1213 pline.innerText = pIndentString + plineText.trimStart();
1214 caretPos += pline.innerText.length - plineText.length;
1215 }
1216 event.preventDefault();
1217 }
1218 e.innerHTML = syntax_msl(e.innerText).join(lc).slice(0,-1);
1219 setCurrentCursorPosition(e, caretPos);
1220 updateLineNumbers(e.previousElementSibling,e);
1221 e.focus();
1222 }
1223 return;
1224}
1225
1226// Handle arrow up/down keys in Chrome
1227function arrowKeysChrome(event) {
1228 // Skip combos with special keys
1229 if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
1230 if ((event.key === "ArrowUp" || event.key === "ArrowDown") && event.shiftKey ) {
1231 event.preventDefault();
1232 startChromeSelecting(event.target);
1233 } else if ((event.key === "ArrowUp" || event.key === "ArrowDown") && !event.shiftKey ) {
1234 event.preventDefault();
1235 const e = event.target;
1236 let caretPos = getCurrentCursorPosition(e);
1237 let ncaretPos = caretPos;
1238 let currText = e.innerText;
1239 let lines = currText.split("\n");
1240 // Determine the current line number (0-based index)
1241 let clineNum = getLinesInSelection(e)[0] - 1;
1242 // Determine the direction: -1 for up, +1 for down
1243 let addLine = (event.key === "ArrowDown") ? 1 : -1;
1244 // Determine the target line number
1245 let nlineNum = clineNum + addLine;
1246 if (lines[nlineNum] !== undefined) {
1247 // line exists can move cursor
1248 let clineStart = currText.split("\n").slice(0, clineNum).join("\n").length + (clineNum > 0 ? 1 : 0);
1249 let caretInLine = caretPos - clineStart;
1250
1251 let nlength = lines[nlineNum].length;
1252 let nlineStart = currText.split("\n").slice(0, nlineNum).join("\n").length + (nlineNum > 0 ? 1 : 0);
1253 if (nlength >= caretInLine && caretInLine >= 0) {
1254 // If the target line is longer or equal to the caret's position in the current line
1255 ncaretPos = nlineStart + caretInLine;
1256 } else if (caretInLine < 0) {
1257 ncaretPos = nlineStart + nlength + 1;
1258 } else {
1259 // If the target line is shorter than the caret's position in the current line
1260 ncaretPos = nlineStart + nlength;
1261 }
1262 setCurrentCursorPosition(e, ncaretPos);
1263 }
1264 }
1265}
1266
1267
1268// Trigger syntax highlighting on keydown events
1269function checkSyntaxEventDown(event) {
1270 sessionStorage.setItem("keydown",event.target.innerText);
1271 // take care of Mac odd keyup behaviour
1272 if (event.metaKey && (/^[a-z]$/.test(event.key) || event.shiftKey || event.altKey)) {
1273 MetaCombo = true;
1274 } else {
1275 MetaCombo = false;
1276 }
1277
1278 if (event.ctrlKey || event.altKey || event.metaKey) return;
1279 // Quickly return for anything but these keys
1280 if (event.key !== 'Tab' && event.key !== 'Escape' && event.key !== 'Backspace' && event.key !== 'Delete' && event.key !== 'Enter') return;
1281 const e = event.target;
1282 event.preventDefault();
1283 const textSelected = isTextSelected(e);
1284 if (textSelected && event.key !== 'Tab' && event.key !== 'Escape') {
1285 deleteSelectedText(e);
1286 }
1287 let caretPos = getCurrentCursorPosition(e);
1288 let currText = e.innerText;
1289
1290 if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Enter') {
1291 //if (event.key === 'Enter') {
1292 // Handle Backspace, Delete and Enter manually for better compatibility and control
1293 if (event.key === 'Enter') {
1294 currText = currText.substring(0,caretPos) + "\n" + currText.substring(caretPos);
1295 caretPos = caretPos + 1 <= currText.length ? caretPos + 1 : caretPos;
1296 if (currText.substring(caretPos) == "" && isChrome === 1) currText = currText + "\n";
1297 } else if (event.key === 'Backspace') {
1298 currText = currText.substring(0,caretPos).slice(0,-1) + currText.substring(caretPos);
1299 caretPos = caretPos - 1 >= 0 ? caretPos - 1 : caretPos;
1300 } else if (event.key === 'Delete') {
1301 if (textSelected) {
1302 currText = currText.substring(0,caretPos) + currText.substring(caretPos).slice(0);
1303 } else {
1304 currText = currText.substring(0,caretPos) + currText.substring(caretPos).slice(1);
1305 }
1306 }
1307 e.innerHTML = syntax_msl(currText).join(lc).slice(0,-1);
1308 setCurrentCursorPosition(e, caretPos);
1309 keepCaretVisible(e);
1310 return;
1311 } else if (event.key === 'Tab' || event.key === 'Escape') {
1312 let lines = getLinesInSelection(e);
1313 if (event.shiftKey && event.key === 'Tab') {
1314 indent_msl(lines,-1);
1315 } else if (event.key === 'Tab') {
1316 indent_msl(lines,+1);
1317 } else if (event.key === 'Escape') {
1318 indent_msl(lines);
1319 }
1320 e.innerHTML = syntax_msl(e.innerText).join(lc).slice(0,-1);
1321 let newText = e.innerText;
1322 setCurrentCursorPosition(e, caretPos + newText.length - currText.length);
1323 if (lines[0] !== lines[1]) selectLines(lines,e);
1324 return;
1325 }
1326}
1327
1328// Trigger syntax highlighting when you paste text
1329function checkSyntaxEventPaste(event) {
1330 // set time out to allow default to go first
1331 setTimeout(() => {
1332 let e = event.target;
1333 // make sure you paste in the editor area
1334 if (e.tagName !== 'PRE') e = e.parentElement;
1335 if (!e) return;
1336 while (e && e.tagName !== 'PRE') e = e.parentElement;
1337 if (!e) return;
1338 let caretPos = getCurrentCursorPosition(e);
1339 let currText = e.innerText;
1340 // save current revision for undo
1341 saveState(currText,e);
1342 e.innerHTML = syntax_msl(currText).join(lc).slice(0,-1);
1343 setCurrentCursorPosition(e, caretPos);
1344 updateLineNumbers(e.previousElementSibling,e);
1345 e.focus();
1346 }, 0);
1347 return;
1348}
1349
1350// Find on which line is the current carret position in e
1351// This assumes each line has an id="sline#" where # is the line number.
1352function whichLine(e,offset = 0) {
1353 // offset allows to pick previous line (after enter)
1354 let pos = getCurrentCursorPosition(e);
1355 if (!pos) return;
1356 let lineNum = e.innerText.substring(0,pos).split("\n").length + offset;
1357 let sline = e.querySelector("#sline" + lineNum.toString());
1358 return sline;
1359}
1360
1361// Return an array with the first and last line numbers of the selected region
1362/* This assumes that the lines are in a <pre> element and that
1363 each line has an id="sline#" where # is the line number.
1364 When the caret in in an empty line, the anchorNode is the <pre> element.
1365*/
1366function getLinesInSelection(e) {
1367 const selection = window.getSelection();
1368 if (selection.rangeCount === 0) return [0,0];
1369 // is it a single line?
1370 const singleLine = selection.isCollapsed;
1371 if (singleLine) {
1372 const line = whichLine(e);
1373 if (line) {
1374 const startLine = parseInt(line.id.replace("sline",""));
1375 return [startLine,startLine];
1376 } else {
1377 return [0,0];
1378 }
1379 }
1380 const anchorNode = selection.anchorNode;
1381 const range = selection.getRangeAt(0);
1382 let startNode,endNode;
1383 if (anchorNode.tagName === 'PRE') {
1384 let startOffset = range.startOffset;
1385 let endOffset = range.endOffset;
1386 startNode = range.startContainer.childNodes[startOffset];
1387 endNode = range.startContainer.childNodes[endOffset-1];
1388 } else {
1389 startNode = (range.startContainer && range.startContainer.parentElement.tagName !== 'PRE') ? range.startContainer : range.startContainer.nextSibling;
1390 if (startNode && startNode.tagName === 'PRE') startNode = startNode.firstChild;
1391 endNode = (range.endContainer && range.endContainer.parentElement.tagName !== 'PRE') ? range.endContainer : range.endContainer.previousSibling;
1392 if (endNode && endNode.tagName === 'PRE') endNode = endNode.lastChild;
1393 }
1394 let startID = (startNode && startNode.id) ? startNode.id : "";
1395 let endID = (endNode && endNode.id) ? endNode.id : "";
1396 // get first line
1397 while (startNode && !startID.startsWith("sline") && startNode.tagName !== 'PRE') {
1398 startNode = (startNode.parentNode.tagName !== 'PRE') ? startNode.parentNode : startNode.nextSibling;
1399 startID = (startNode && startNode.id) ? startNode.id : "";
1400 }
1401 // get last line
1402 while (endNode && !endID.startsWith("sline") && endNode.tagName !== 'PRE') {
1403 endNode = (endNode.parentNode.tagName !== 'PRE') ? endNode.parentNode : endNode.previousSibling;
1404 endID = (endNode && endNode.id) ? endNode.id : "";
1405 }
1406 let startLine = (startNode && startNode.id) ? parseInt(startNode.id.replace("sline","")) : 0;
1407 let endLine = (endNode && endNode.id) ? parseInt(endNode.id.replace("sline","")) : 0;
1408 if (singleLine) {
1409 startLine = endLine = Math.min(startLine, endLine);
1410 }
1411 return [startLine,endLine];
1412}
1413
1414// get current caret position in chars within element parent
1415function getCurrentCursorPosition(parent) {
1416 let sel = window.getSelection();
1417 if (!sel.focusNode || !parent) return;
1418 const range = sel.getRangeAt(0);
1419 const prefix = range.cloneRange();
1420 prefix.selectNodeContents(parent);
1421 prefix.setEnd(range.endContainer, range.endOffset);
1422 return prefix.toString().length;
1423}
1424
1425// set current caret position at pos within element parent
1426function setCurrentCursorPosition(parent,pos) {
1427 for (const node of parent.childNodes) {
1428 if (node.nodeType === Node.TEXT_NODE) {
1429 if (node.length >= pos) {
1430 const range = document.createRange();
1431 const sel = window.getSelection();
1432 range.setStart(node, pos);
1433 range.collapse(true);
1434 sel.removeAllRanges();
1435 sel.addRange(range);
1436 return -1;
1437 } else {
1438 pos = pos - node.length;
1439 }
1440 } else {
1441 pos = setCurrentCursorPosition(node, pos);
1442 if (pos < 0) {
1443 return pos;
1444 }
1445 }
1446 }
1447 return pos;
1448}
1449
1450// Update line numbers in lineNumbers div
1451function updateLineNumbers(lineNumbers,editor) {
1452 if (lineNumbers === undefined || editor === undefined)
1453 [lineNumbers,editor] = editorElements();
1454 // Clear existing line numbers
1455 lineNumbers.innerHTML = "";
1456 // Get the number of lines accurately
1457 let lineCount = editor.querySelectorAll('[id^="sline"]').length;
1458 let lineTextCount = editor.innerText.split("\n").length;
1459 lineCount = (lineTextCount - lineCount) < 2 ? lineTextCount : lineTextCount - 1;
1460 // Add line numbers to lineNumbers
1461 for (let i = 1; i <= lineCount; i++) {
1462 const lineNumber = document.createElement('span');
1463 lineNumber.id = "lNum" + i.toString();
1464 lineNumber.textContent = i;
1465 lineNumbers.appendChild(lineNumber);
1466 }
1467 lineNumbers.scrollTop = editor.scrollTop;
1468 windowResize();
1469}
1470
1471// Utility function to escape special characters in a string for use in a regular expression
1472function escapeRegExp(s) {
1473 if (!s) return "";
1474 return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
1475}
1476
1477function syntax_msl(seqLines,keywordGroups) {
1478 // Use default if none provided
1479 if (!keywordGroups) {
1480 keywordGroups = mslDefs.groups;
1481 }
1482
1483 // Convert to text
1484 let mslText = Array.isArray(seqLines) ? seqLines.join("\n") : seqLines;
1485 mslText = escapeSpecialCharacters(mslText);
1486 const seqLines_org = mslText.split(/\n/);
1487
1488 // Loop over all keyword groups
1489 for (const [groupName, group] of Object.entries(keywordGroups)) {
1490 const className = group.mslclass;
1491 const spanStart = `<span class='${className}'>`;
1492 const spanEnd = `</span>`;
1493
1494 // If kwregex and keywords are defined
1495 if (group.keywords && group.kwregex && group.kwregex.length === 3) {
1496 const [prefix, suffix, flags] = group.kwregex;
1497 const regexStr = prefix + group.keywords.join("|") + suffix;
1498 const regex = new RegExp(regexStr, flags);
1499
1500 mslText = mslText.replace(regex, (...args) => {
1501 const match = args[0];
1502 const captureGroups = args.slice(1, -2); // remove offset & input
1503 if (captureGroups.length === 0) {
1504 return `${spanStart}${match}${spanEnd}`;
1505 }
1506 // Wrap last capturing group
1507 let result = "";
1508 let index = 0;
1509 let lastIndex = 0;
1510 const raw = match;
1511 for (let i = 0; i < captureGroups.length; i++) {
1512 const part = captureGroups[i];
1513 if (part === undefined) continue;
1514 const idx = raw.indexOf(part, lastIndex);
1515 result += raw.slice(lastIndex, idx);
1516 if (i === captureGroups.length - 1) {
1517 result += `${spanStart}${part}${spanEnd}`;
1518 } else {
1519 result += part;
1520 }
1521 lastIndex = idx + part.length;
1522 }
1523 result += raw.slice(lastIndex);
1524 return result;
1525 });
1526 }
1527
1528 // If explicit regex patterns are provided
1529 if (group.regex) {
1530 group.regex.forEach(r => {
1531 if (r.pattern) {
1532 const flags = r.flags || "g";
1533 const regex = new RegExp(r.pattern, flags);
1534 if (groupName === 'multilineComments') {
1535 mslText = mslText.replace(regex, (fullMatch) => {
1536 // Split the full match by lines
1537 const lines = fullMatch.split('\n');
1538 // Process each line individually
1539 const processedLines = lines.map(line => {
1540 return `${spanStart}${line}${spanEnd}`;
1541 });
1542 // Join back with newlines
1543 return processedLines.join('\n');
1544 });
1545 } else {
1546 mslText = mslText.replace(regex, `${spanStart}$&${spanEnd}`);
1547 }
1548 }
1549 });
1550 }
1551 }
1552
1553 // Split processed lines
1554 seqLines = mslText.split("\n");
1555
1556 // Firefox / Chrome handling
1557 const emptyClass = (typeof isChrome !== 'undefined' && isChrome === 1) ? "esline" : "";
1558
1559 for (let j = 0; j < seqLines_org.length; j++) {
1560 const line = seqLines_org[j];
1561 // Add span wrapper for editor tracking
1562 if (line === "") {
1563 const id = `sline${j + 1}`;
1564 seqLines[j] = `<span class='${emptyClass}' id='${id}'></span>`;
1565 } else {
1566 const id = `sline${j + 1}`;
1567 seqLines[j] = `<span class='sline' id='${id}'>${seqLines[j]}</span>`;
1568 }
1569 }
1570
1571 return seqLines;
1572}
1573
1574// Syntax highlight any text according to provided rules
1575function syntax_msl2(seqLines,keywordGroups) {
1576 // If not provided use the default msl keywords and groups
1577 if (!keywordGroups) {
1578 // take msl default
1579 keywordGroups = mslDefs.groups;
1580 }
1581
1582 // Make full text if you get an array of lines
1583 let mslText = Array.isArray(seqLines) ? seqLines.join("\n") : seqLines;
1584 // Make some cleanup of illegal characters
1585 mslText = escapeSpecialCharacters(mslText);
1586 // Keep original sequence lines (as array)
1587 let seqLines_org = mslText.split(/\n/);
1588 let nLines = (mslText.match(/\n/g) || []).length;
1589
1590 // These can be done on the text in one go
1591 // Strings
1592 let reg = /(["'])(.*?)\1/g;
1593 mslText = mslText.replace(reg,'<span class="msl_string">$1$2$1</span>');
1594
1595 // Comments
1596 //reg = /^(COMMENT|#.*?)(.*)$/gim;
1597 //mslText = mslText.replace(reg,'<span class="msl_comment">$&</span>');
1598
1599 // Variables
1600 reg = /(?:\$[\w]+|^\b\w+(?=\s*=))/gm; // starting with $ or something =
1601 mslText = mslText.replace(reg,'<span class="msl_variable">$&</span>');
1602 reg = new RegExp("(^(?:\\s*)\\b(" + keywordGroups.variables.keywords.join("|") + ")\\s+)(\\w+)\\b", "gim"); // after PARAM, CAT and SET
1603 mslText = mslText.replace(reg,'$1<span class="msl_variable">$3</span>');
1604
1605 // Data Management group excluding variables (must be after variables)
1606 reg = new RegExp("^(\\s*)\\b(" + keywordGroups.dataManagement.keywords.join("|") + ")\\b", "gim");
1607 mslText = mslText.replace(reg, "$1<span class='msl_data_management'>$2</span>");
1608
1609 // Data Type group (must have comma before the keyword)
1610 reg = new RegExp("(?<=,\\s*)\\b(" + keywordGroups.dataTypes.keywords.join("|") + ")\\b", "gim");
1611 mslText = mslText.replace(reg, "<span class='msl_data_types'>$1</span>");
1612
1613 // Loops group (must be at the begenning of the line)
1614 reg = new RegExp("^(\\s*)\\b(" + keywordGroups.loops.keywords.join("|") + ")\\b", "gim");
1615 mslText = mslText.replace(reg, "$1<span class='msl_loops'>$2</span>");
1616
1617 // Control Flow group
1618 reg = new RegExp("^(\\s*)\\b(" + keywordGroups.controlFlow.keywords.join("|") + ")\\b", "gim");
1619 mslText = mslText.replace(reg, "$1<span class='msl_control_flow'>$2</span>");
1620
1621 // Data Management group
1622 reg = new RegExp("^(\\s*)\\b(" + keywordGroups.dataManagement.keywords.join("|") + ")\\b", "gim");
1623 mslText = mslText.replace(reg, "$1<span class='msl_data_managemen'>$2</span>");
1624
1625 // Information group
1626 reg = new RegExp("^(\\s*)\\b(" + keywordGroups.info.keywords.join("|") + ")\\b", "gim");
1627 mslText = mslText.replace(reg, "$1<span class='msl_info'>$2</span>");
1628
1629 // Conditional group
1630 reg = new RegExp("^(\\s*)\\b(" + keywordGroups.cond.keywords.join("|") + ")\\b", "gim");
1631 mslText = mslText.replace(reg,"$1<span class='msl_cond'>$2</span>");
1632
1633 // Units group
1634 reg = new RegExp("\\b(" + keywordGroups.units.keywords.join("|") + ")\\b", "gi");
1635 mslText = mslText.replace(reg, "<span class='msl_units'>$1</span>");
1636
1637 // Action group
1638 reg = new RegExp("\\b(" + keywordGroups.actions.keywords.join("|") + ")\\b(\\s*)$", "gim");
1639 mslText = mslText.replace(reg, "<span class='msl_actions'>$1</span>$2");
1640
1641 // Numbers/boolean group
1642 reg = /\b(\d+(\.\d+)?([eE][-+]?\d+)?)\b/g;
1643 mslText = mslText.replace(reg, '<span class="msl_number">$1</span>');
1644 reg = new RegExp("\\b(" + keywordGroups.bool.keywords.join("|") + ")\\b", "gi");
1645 //reg = /\b(true|false)\b/gi;
1646 mslText = mslText.replace(reg, '<span class="msl_bool">$1</span>');
1647
1648 // Break lines and handle one by one
1649 seqLines = mslText.split("\n");
1650
1651 // This is important for Firefox
1652 let emptyClass = "";
1653 if (isChrome === 1) emptyClass = "esline";
1654 // Loop and restore comment lines and empty lines
1655 for (let j = 0; j < seqLines_org.length ; j++) {
1656 let line = seqLines_org[j];
1657 let inlineComment = mslDefs.groups.comments.keywords[0];
1658 commentIndex = line.indexOf(inlineComment);
1659 if (line.trim().startsWith(inlineComment) || line.trim().toLowerCase().startsWith("comment")) {
1660 // Restore comment lines without highlighting
1661 seqLines[j] = `<span class='msl_comment'>${line}</span>`;
1662 } else if (commentIndex > 0) {
1663 // Restore comment section at end of line
1664 const comment = line.slice(commentIndex);
1665 seqLines[j] = seqLines[j].slice(0, seqLines[j].indexOf("#")) + `</span><span class='msl_comment'>${comment}</span>`;
1666 }
1667
1668 // empty class is needed for cursor movement in Firefox
1669 // for Chrome empty lines are skipped with arrow up??
1670 if (line === "") {
1671 if ((j === seqLines_org.length - 1) && (isChrome === 1)) {
1672 seqLines[j] = "<span class=' ' id='sline" + (j+1).toString() + "'></span>";
1673 } else if (isChrome === 1) {
1674 seqLines[j] = "<span class='" + emptyClass + "' id='sline" + (j+1).toString() + "'></span>";
1675 } else {
1676 seqLines[j] = "<span class='" + emptyClass + "' id='sline" + (j+1).toString() + "'></span>";
1677 }
1678 } else {
1679 seqLines[j] = "<span class='sline' id='sline" + (j+1).toString() + "'>" + seqLines[j] + "</span>";
1680 }
1681 }
1682 return seqLines;
1683}
1684
1685// Adjust indentation of a selection of lines
1686function indent_msl(lines,addTab) {
1687 /* Arguments:
1688 lines - an array of two elements, first and last line numbers
1689 addTab - (opt) +/-1 to add/subtract three spaces to selected lines
1690 */
1691 let indentLevel = 0;
1692 let singleLine = false;
1693 let editor = editorElements()[1];
1694 // Indentation keywords
1695 const defIndent = mslDefs.defIndent;
1696 const nIndent = defIndent.nIndent || 3;
1697
1698 // Avoid issues of begenning of single line
1699 if (lines[0] > lines[1] || lines[0] == lines[1]) {
1700 lines[0] = lines[0] > 0 ? lines[0] : 1;
1701 lines[1] = lines[0];
1702 singleLine = true;
1703 }
1704 for (let j = lines[0]; j <= lines[1] ; j++) {
1705 let lineId = "#sline" + j.toString();
1706 let prevLineId = "#sline" + (j-1).toString();
1707 let lineEl = editor.querySelector(lineId);
1708 let line = "";
1709 if (lineEl) line = lineEl.innerText;
1710 if (addTab === 1) {
1711 let indentString = " ".repeat(nIndent);
1712 lineEl.innerText = indentString + line;
1713 } else if (addTab === -1) {
1714 lineEl.innerText = line.replace(new RegExp(`^ {1,${nIndent}}`), '');
1715 } else if (singleLine && editor.querySelector(prevLineId)) {
1716 let prevLineEl = editor.querySelector(prevLineId);
1717 let prevLine = prevLineEl.innerText;
1718 const indentMinos = defIndent.indentminos.some(keyword => prevLine.trim().toLowerCase().startsWith(keyword.toLowerCase()));
1719 const indentPlus = defIndent.indentplus.some(keyword => prevLine.trim().toLowerCase().startsWith(keyword.toLowerCase()));
1720 if (indentMinos) {
1721 indentLevel = -1;
1722 } else if (indentPlus) {
1723 indentLevel = 1;
1724 }
1725 let preSpace = prevLine.search(/\S|$/) + (indentLevel * nIndent);
1726 if (preSpace < 0) preSpace = 0;
1727 let indentString = " ".repeat(preSpace);
1728 lineEl.innerText = indentString + line.trimStart();
1729 } else {
1730 const indentMinos = defIndent.indentminos.some(keyword => line.trim().toLowerCase().startsWith(keyword.toLowerCase()));
1731 if (indentMinos && indentLevel > 0) indentLevel--;
1732 let indentString = " ".repeat(indentLevel * nIndent);
1733 if (line !== "" || indentString !== "") {
1734 lineEl.innerText = indentString + line.trimStart();
1735 }
1736 const indentPlus = defIndent.indentplus.some(keyword => line.trim().toLowerCase().startsWith(keyword.toLowerCase()));
1737 if (indentPlus) indentLevel++;
1738 }
1739 }
1740}
1741
1742// Prepare the parameters/variables (if present) from the ODB as an html table
1743// Also return default ODB Paths and their respective values
1744function varTable(id,odbTreeVar) {
1745 /* Arguments:
1746 id - ID of div to fill with the table of variables
1747 odbTreeVar - values of ${SeqODB}/Variables
1748 */
1749
1750 let e = document.getElementById(id);
1751 if (e === null) {
1752 dlgAlert("Container ID was not give.");
1753 return;
1754 }
1755
1756 let old_var_names = [];
1757 let new_var_names = [];
1758 document.querySelectorAll(".varname").forEach(el => old_var_names.push(el.innerText));
1759
1760 // If ${SeqODB}/Variables are empty return empty
1761 if (!odbTreeVar || (odbTreeVar && Object.keys(odbTreeVar).length === 0)) {
1762 // Clear container row
1763 e.innerHTML = "";
1764 return;
1765 }
1766
1767 let html = "<table id='varTable' class='mtable infotable'>\n";
1768 html += "<tr><th style='min-width: 120px'>Variable&nbsp;&nbsp;</th><th>Current value&nbsp;&nbsp;</th></tr>\n";
1769
1770 // Go over all variables in ODB and count them
1771 for (let key in odbTreeVar) {
1772 const match = key.match(/([^/]+)\/name$/);
1773 if (match) {
1774 const name = match[1];
1775 const value = odbTreeVar[name];
1776 new_var_names.push(name);
1777 let isBool = (typeof(value) === "boolean");
1778 if (isBool) {
1779 html += `<tr><td class="varname">${name}</td><td><input type="checkbox" class="modbcheckbox" data-odb-path="${SeqODB}/Variables/${name}"></span></td></tr>\n`;
1780 } else {
1781 html += `<tr><td class="varname">${name}</td><td><span class="modbvalue" data-odb-path="${SeqODB}/Variables/${name}"></span></td></tr>\n`;
1782 }
1783 }
1784 }
1785 html += "</table>";
1786 let have_new_var = false;
1787 if (old_var_names.length !== new_var_names.length) {
1788 have_new_var = true;
1789 } else {
1790 for (let i = 0; i < old_var_names.length; i++) {
1791 if (old_var_names[i] != new_var_names[i]) {
1792 have_new_var = true;
1793 }
1794 }
1795 }
1796 if (have_new_var) {
1797 e.innerHTML = html;
1798 }
1799 return;
1800}
1801
1802// Prepare the parameters/variables (if present) from the ODB as an object with
1803// html table, default ODB Paths, their respective values and their number
1804function parTable(odbTreeV,odbTreeC,odbTreeD,odbTreeO,odbTreeVar,editOnSeq) {
1805 /* Arguments:
1806 odbTree... - Objects of ODB values
1807 */
1808 let html = "";
1809 let odbDefPaths = [];
1810 let odbDefValues = [];
1811 let NValues = 0;
1812
1813 html += "<table id='paramTable' class='mtable infotable'>";
1814 html += "<tr><th>Parameter&nbsp;&nbsp;</th><th>Initial value&nbsp;&nbsp;</th><th>Comment</th></tr>";
1815
1816 const processParam = (name, value, isBool, defValue, optValue, comment) => {
1817 let parLine = `<tr><td>${name}</td>`;
1818 let inParLine = "";
1819 if (optValue) {
1820 // if not given the default is the first option
1821 if (defValue === undefined || defValue === "") defValue = optValue[0];
1822 const optionsHtml = optValue.map(option => `<option value="${option}" ${option === defValue ? 'selected' : ''}>${option}</option>`).join('');
1823 inParLine += `<select class="modbselect" data-odb-path="${SeqODB}/Param/Value/${name}" data-odb-editable="1">${optionsHtml}</select>`;
1824 } else if (isBool) {
1825 inParLine += `<input type="checkbox" class="modbcheckbox" data-odb-path="${SeqODB}/Param/Value/${name}" data-odb-editable="1"></input>`;
1826 } else {
1827 inParLine += `<span class="modbvalue" data-odb-path="${SeqODB}/Param/Value/${name}" data-odb-editable="1" data-input="1"></span>`;
1828 }
1829 if (defValue !== undefined) {
1830 NValues++;
1831 odbDefPaths.push(`${SeqODB}/Param/Value/${name}`);
1832 odbDefValues.push(defValue);
1833 }
1834
1835 parLine += `<td>${inParLine}</td>`;
1836 parLine += `<td>${comment}</td></tr>`;
1837 html += parLine;
1838 };
1839
1840 // Go over all parameters in ODB
1841 for (let key in odbTreeV) {
1842 const match = key.match(/([^/]+)\/name$/);
1843 if (match) {
1844 const name = match[1];
1845 // if variable is found use its value
1846 let value = (odbTreeVar && odbTreeVar[name]) ? odbTreeVar[name] : odbTreeV[name];
1847 let isBool = (typeof(value) === "boolean");
1848 let defValue = (value !== null && value !== undefined && value !== '') ? value : (odbTreeD && odbTreeD[name]) || value;
1849 let optValue = odbTreeO ? odbTreeO[name] : undefined;
1850 let comment = odbTreeC[name] || '';
1851 if (typeof value !== "object") {
1852 processParam(name, value, isBool, defValue, optValue, comment);
1853 }
1854 }
1855 }
1856
1857 // Go over Edit on sequence links
1858 for (let key in editOnSeq) {
1859 const match = key.match(/([^/]+)\/name$/);
1860 if (match) {
1861 const name = match[1];
1862 const value = editOnSeq[name];
1863 let isBool = (typeof(value) === "boolean");
1864 if (isBool) {
1865 html += `<tr><td>${name}</td><td><input type="checkbox" class="modbcheckbox" data-odb-path="/Experiment/Edit on sequence/${name}" data-odb-editable="1"></input></td><td></td></tr>\n`;
1866 } else {
1867 html += `<tr><td>${name}</td><td><span class="modbvalue" data-odb-path="/Experiment/Edit on sequence/${name}" data-odb-editable="1" data-input="1"></span></td><td></td></tr>\n`;
1868 }
1869 }
1870 }
1871
1872 html += "</table>";
1873 return {
1874 html: html,
1875 paths: odbDefPaths,
1876 values: odbDefValues,
1877 NValues: NValues
1878 };
1879}
1880
1881function dlgParam(debugFlag) {
1882 /* Arguments:
1883 debugFlag - (opt) true/false run in debug/normal mode
1884 */
1885
1886 // Ensure we read the latest Params from the ODB, in case we just loaded a new file
1887 let paths = [`${SeqODB}/Param`, `${SeqODB}/Variables`];
1888
1889 mjsonrpc_db_get_values(paths).then(function (rpc) {
1890 // Try to use the very latest values from ODB when building
1891 // the parameters table.
1892 // `dlgParamMain()` reads from sessionStorage, so we need to write
1893 // the latest values there.
1894 // Normally sessionStorage is updated by `mhttpd_refresh()` (when it
1895 // updates "modbvalue" spans in the HTML), but we don't want to hang
1896 // around waiting for the next update to happen, so we just update it
1897 // ourselves.
1898 const status = rpc?.result?.status ?? [];
1899 const data = rpc?.result?.data ?? [];
1900 // parameters
1901 if (status[0] === 1) {
1902 sessionStorage.setItem("parameters", JSON.stringify(data[0]));
1903 }
1904 // variables
1905 if (status[1] === 1) {
1906 sessionStorage.setItem("variables", JSON.stringify(data[1]));
1907 }
1908 dlgParamMain(debugFlag);
1909 }).catch(function (error) {
1910 console.error(error);
1911 });
1912}
1913
1914// Prepare the parameters/variables (if present) from the ODB as a table
1915function dlgParamMain(debugFlag) {
1916 /* Arguments:
1917 debugFlag - (opt) true/false run in debug/normal mode
1918 */
1919
1920 let rawOdbTree = sessionStorage.getItem('parameters');
1921 let parsedOdbTree = (rawOdbTree !== null && rawOdbTree !== 'undefined') ? JSON.parse(rawOdbTree) : null;
1922 let odbTree = (parsedOdbTree && Object.keys(parsedOdbTree).length === 0) ? null : parsedOdbTree;
1923
1924 const rawEditOnSeq = sessionStorage.getItem('editonseq');
1925 const parsedEditOnSeq = (rawEditOnSeq !== null && rawEditOnSeq !== 'undefined') ? JSON.parse(rawEditOnSeq) : null;
1926 const editOnSeq = (parsedEditOnSeq && Object.keys(parsedEditOnSeq).length === 0) ? null : parsedEditOnSeq;
1927
1928 // If ${SeqODB}/Param are empty, start and return
1929 if ((odbTree === null || Object.keys(odbTree.value).length === 0) && editOnSeq === null ) {
1930 //if ((odbTree === null || Object.keys(odbTree).length) && (editOnSeq === null || Object.keys(editOnSeq).length)) {
1931 if (debugFlag) {
1932 modbset(`${SeqODB}/Command/Debug script`,true);
1933 } else {
1934 modbset(`${SeqODB}/Command/Start script`,true);
1935 }
1936 return;
1937 }
1938
1939 let odbTreeV = null;
1940 let odbTreeC = null;
1941 let odbTreeD = null;
1942 let odbTreeO = null;
1943 let odbTreeVar = null;
1944
1945 if (odbTree) {
1946 odbTreeV = odbTree.value;
1947 odbTreeC = odbTree.comment;
1948 odbTreeD = odbTree.defaults;
1949 odbTreeO = odbTree.options;
1950 odbTreeVar = JSON.parse(sessionStorage.getItem('variables'));
1951 }
1952
1953 // Go over all parameters in ODB
1954 let seqParTable = parTable(odbTreeV,odbTreeC,odbTreeD,odbTreeO,odbTreeVar,editOnSeq);
1955 let html = seqParTable.html;
1956 // set all default values and once finished produce dialog
1957 // Collect paths where values start with "/"
1958 let valuesLinkODB = seqParTable.values.map((valueLinkODB, indexLinkODB) => {
1959 if (valueLinkODB !== undefined && typeof(valueLinkODB) === "string") {
1960 if (valueLinkODB.startsWith("/")) {
1961 return seqParTable.values[indexLinkODB];
1962 }
1963 }
1964 return null;
1965 }).filter(path => path !== null);
1966 mjsonrpc_db_get_values(valuesLinkODB).then(function (rpc) {
1967 if (rpc.result.status.every(status => status === 1)) {
1968 // substitute values
1969 rpc.result.data.forEach((newData, index) => {
1970 let pathIndex = seqParTable.values.indexOf(valuesLinkODB[index]);
1971 seqParTable.values[pathIndex] = `${newData}`; // Update corresponding value
1972 });
1973 mjsonrpc_db_paste(seqParTable.paths,seqParTable.values).then(function (rpc) {
1974 if ((rpc.result.status.every(status => status === 1)) || seqParTable.values.length === 0) {
1975 // if parContainer not given produce a dialog
1976 let htmlDlg = `${html}<br><button class="dlgButtonDefault" id="dlgParamStart" type="button">Start</button><button class="dlgButton" id="dlgParamCancel" type="button">Cancel</button><br>`;
1977 let d = dlgGeneral({html: htmlDlg,iddiv: "Parameters",minWidth:500});
1978 let e = document.getElementById("parContainer");
1979 // Append the table to a container
1980 let startBtn = document.getElementById("dlgParamStart");
1981 let cancelBtn = document.getElementById("dlgParamCancel");
1982 cancelBtn.addEventListener("click", function () {d.remove();});
1983 startBtn.addEventListener("click", function () {
1984 // this is what we need to do after setting the values
1985 let doStart = function() {
1986 d.remove();
1987 if (debugFlag) {
1988 modbset(`${SeqODB}/Command/Debug script`,true);
1989 } else {
1990 modbset(`${SeqODB}/Command/Start script`,true);
1991 }
1992 };
1993 // but first collect values from inputs and set them in ODB
1994 let paramTable = document.getElementById("paramTable");
1995 let paths = [], values = [];
1996 if (paramTable) {
1997 paramTable.querySelectorAll(".modbvalue[data-input='1']").forEach(function(span) {
1998 let input = span.querySelector("input[type='text']");
1999 if (input && span.dataset.odbPath) {
2000 paths.push(evalPath(span.dataset.odbPath));
2001 values.push(input.value);
2002 }
2003 });
2004 }
2005 if (paths.length > 0) {
2006 mjsonrpc_db_paste(paths, values).then(function() { doStart(); })
2007 .catch(function(error) { console.error(error); doStart(); });
2008 } else {
2009 doStart();
2010 }
2011 });
2012
2013 // refresh immediately modbvalue elements
2014 mhttpd_refresh();
2015 } else {
2016 dlgAlert("Something went wrong. Please try again!");
2017 }
2018 }).catch(function (error) {
2019 console.error(error);
2020 });
2021 } else {
2022 let message = `ODB "${valuesLinkODB}" was not found.<br>Cannot start sequence!`;
2023 dlgAlert(message);
2024 }
2025 }).catch(function (error) {
2026 console.error(error);
2027 });
2028}
2029
2030
2031// helper debug function
2032function debugSeq(parContainer) {
2033 startSeq(parContainer,true);
2034}
2035
2036// helper start function
2037function startSeq(parContainer,debugFlag) {
2038 const [lineNumbers,editor,label] = editorElements();
2039 if (!debugFlag) debugFlag = false;
2040 if (editor.id !== "editorTab1" && parContainer === undefined) {
2041 let filename = label.title;
2042 if (!filename || filename.endsWith("Untitled")) {
2043 dlgAlert("Please give the file a name first (Save as).");
2044 return;
2045 }
2046 let seqChanged = seqIsChanged();
2047 let message = debugFlag ? `Start debugging ${filename}?` : `Start ${filename}?`;
2048 if (seqChanged) {
2049 message = debugFlag ? `Save and debug ${filename}?` : `Save and start ${filename}?`;
2050 }
2051 dlgConfirm(message,function(resp) {
2052 if (resp) {
2053 if (seqChanged) seqSave(filename);
2054 openETab(document.getElementById("etab1-btn"));
2055 seqOpen(filename);
2056 // Make sure to load file and reset parameters
2057 mjsonrpc_db_paste([`${SeqODB}/Command/Load new file`],[true]).then(function (rpc) {
2058 if (rpc.result.status[0] === 1) {
2059 // Wait for Load new file to turn false
2060 checkODBValue(`${SeqODB}/Command/Load new file`,false,dlgParam,debugFlag);
2061 }
2062 });
2063 }
2064 });
2065 } else {
2066 // make sure to load file first
2067 mjsonrpc_db_paste([`${SeqODB}/Command/Load new file`],[true]).then(function (rpc) {
2068 if (rpc.result.status[0] === 1) {
2069 // Wait for Load new file to turn false
2070 checkODBValue(`${SeqODB}/Command/Load new file`,false,dlgParam,debugFlag);
2071 }
2072 });
2073 }
2074}
2075
2076// Helper function to add the current file to next files queue
2077function setAsNext() {
2078 const [lineNumbers,editor,label] = editorElements();
2079 // If clicked in the console give a file browser
2080 if (editor.id === "editorTab1") {
2081 chngNextFilename();
2082 return;
2083 }
2084 // This is the addAsNext button cell
2085 const e = document.getElementById("addAsNext");
2086 let filename = label.title.split("\n")[0].replace(/^sequencer\//,'');
2087 let message = `Save and put ${filename} in the next file queue?`;
2088 dlgConfirm(message,function(resp) {
2089 if (resp) {
2090 let order = chngNextFilename(e,filename);
2091 if (order !== -1 && order !== undefined) {
2092 seqSave(filename);
2093 dlgAlert(`File saved and placed in position ${order} in the queue.`);
2094 }
2095 }
2096 });
2097}
2098
2099// helper stop function
2100function stopSeq() {
2101 const message = `Are you sure you want to stop the sequence?`;
2102 dlgConfirm(message,function(resp) {
2103 if (resp) {
2104 modbset(`${SeqODB}/Command/Stop immediately`,true);
2105 }
2106 });
2107}
2108
2109// Show or hide parameters table
2110function showParTable(varContainer) {
2111 let e = document.getElementById(varContainer);
2112 let varTable = document.getElementById("varTable");
2113 let vis = document.getElementById("showParTable").checked;
2114 let visNF = (document.getElementById("nextFNContainer").style.display === "none");
2115 if (vis) {
2116 e.style.display = "flex";
2117 e.parentElement.style.width = "285px";
2118 } else {
2119 e.style.display = "none";
2120 if (visNF) {
2121 e.parentElement.style.width = "0px";
2122 }
2123 }
2124 windowResize();
2125}
2126
2127// Show or hide next file list
2128function showFNTable(nextFNContainer) {
2129 let e = document.getElementById(nextFNContainer);
2130 let vis = document.getElementById("showNextFile").checked;
2131 let visVar = document.getElementById("varContainer") ? (document.getElementById("varContainer").style.display === "none") : false;
2132 let html = "";
2133 let addFileRow = "";
2134 let nFiles = 0;
2135 let odbpath = mslDefs.filename.next;
2136 mjsonrpc_db_get_values([odbpath]).then(function(rpc) {
2137 if (rpc.result.status[0] !== 1) return;
2138 let fList_org = rpc.result.data[0];
2139 // Move full slots to the begenning of the array
2140 fList = fList_org.filter(Boolean);
2141 fList = [...fList, ...new Array(10 - fList.length).fill("")];
2142 // If the cleaned up not equal to original update ODB
2143 if (fList !== fList_org) {
2144 mjsonrpc_db_paste([odbpath],[fList]).then(function (rpc2) {
2145 if (rpc2.result.status[0] !== 1) {
2146 dlgAlert(`${odbpath} needs manual cleanup!<br>Please check.`);
2147 }
2148 }).catch(function (error) {
2149 console.error(error);
2150 });
2151 }
2152 for (let i = 0; i < fList.length; i++) {
2153 if (fList[i] && fList[i].trim() !== "") {
2154 html += `<tr class="dragrow" draggable="true"><td style="cursor: all-scroll;"><img draggable="false" style="cursor: all-scroll;" src="icons/menu.svg" title="Drag and drop to reorder"></td><td><img draggable="false" src="icons/folder-open.svg" title="Change file" onclick="chngNextFilename(this.parentElement);"></td><td style="cursor: all-scroll;" title="Drag and drop to reorder" ondblclick="chngNextFilename(this);">${fList[i]}</td><td><img draggable="false" title="Remove file from list" onclick="remNextFilename(this.parentElement);" src="icons/trash-2.svg"></td></tr>`;
2155 nFiles++;
2156 }
2157 }
2158 if (vis && html !== "") {
2159 e.style.display = "flex";
2160 e.parentElement.style.width = "285px";
2161 if (nFiles < 10) {
2162 disableMenuItems("SetAsNext",false);
2163 addFileRow = `<tr><td id="addAsNext"><img src="icons/file-plus.svg" title="Add file" onclick="chngNextFilename(this.parentElement);"></td><td></td><td></td><td></td></tr>`;
2164 } else {
2165 disableMenuItems("SetAsNext",true);
2166 }
2167 e.innerHTML = `<table class="mtable infotable"><tr><th style="width:10px;"></th><th style="width:10px;"></th><th>Next files</th><th style="width:10px;"></th></tr>${html}${addFileRow}</table>`;
2168 windowResize();
2169 } else {
2170 e.style.display = "none";
2171 if (visVar) {
2172 e.parentElement.style.width = "0px";
2173 windowResize();
2174 }
2175 }
2176 activateDragDrop(e);
2177 }).catch (function (error) {
2178 console.error(error);
2179 });
2180}
2181
2182// Show extra rows for wait and loop
2183function extraRows(e) {
2184 /* Arguments:
2185 e - triggering element to identify wait or loop
2186 */
2187
2188 if (e === undefined) e=document.getElementById('waitTrig');
2189 if (!e) return;
2190
2191 // get current row, table and dialog
2192 let row = e.parentElement.parentElement;
2193 let rIndex = row.rowIndex;
2194 let table = row.parentElement;
2195
2196 // check if there is a wait or loop command (if non-zero)
2197 if (e.value) {
2198 showProgress();
2199 if (e.id === "waitTrig") {
2200 // Wait type. Make sure there is only one wait row.
2201 const waitTRs = document.querySelectorAll('.waitTR');
2202 if (waitTRs.length) waitTRs.forEach(element => element.remove());
2203 let waitType = String(e.value);
2204 let isOdbWait = /^ODB(==|!=|>=|<=|>|<)?$/i.test(waitType);
2205 let tr = table.insertRow(rIndex+1);
2206 tr.className = "waitTR";
2207
2208 if (isOdbWait) {
2209 // extract operator
2210 let op = waitType.replace(/^ODB/i, "");
2211
2212 // For timed WAIT ODBValue, Wait value/limit keep their ODB
2213 // condition meaning. Timeout value/limit provide the progress bar.
2214 tr.innerHTML = `<td style="position: relative;width:80%;height:36px;" colspan="2">
2215 <span class="modbhbar waitloopbar" style="position: absolute; top: 0; left: 0; color: lightgreen; height: 15px;" data-odb-path="${SeqODB}/State/Timeout value" id="mwaitProgress"></span>
2216 <span class="waitlooptxt">
2217 Timeout: [<span class="modbvalue" data-format="f0" data-odb-path="${SeqODB}/State/Timeout value"></span>/<span class="modbvalue" data-odb-path="${SeqODB}/State/Timeout limit" onchange="if (document.getElementById('mwaitProgress')) document.getElementById('mwaitProgress').dataset.maxValue = this.value;"></span>] Seconds
2218 </span><br>
2219 <span class="modbvalue" data-odb-path="${SeqODB}/State/Wait ODB"></span>?
2220 <span class="modbvalue" data-odb-path="${SeqODB}/State/Wait value"></span>
2221 ${op}
2222 <span class="modbvalue" data-odb-path="${SeqODB}/State/Wait limit"></span>
2223 <span>
2224 </td>`;
2225 } else if (e.value === "ClientsRunning" || e.value === "ODBValueStability") {
2226 // Timed WAIT, but without an ODB operator
2227 tr.innerHTML = `<td style="position: relative;width:80%;height:36px;" colspan="2">&nbsp;
2228 <span class="modbhbar waitloopbar" style="position: absolute; top: 0; left: 0; color: lightgreen; height: 15px;" data-format="f0" data-odb-path="${SeqODB}/State/Timeout value" id="mwaitProgress"></span>
2229 <span class="waitlooptxt">
2230 Timeout: [<span class="modbvalue" data-format="f0" data-odb-path="${SeqODB}/State/Timeout value"></span>/<span class="modbvalue" data-odb-path="${SeqODB}/State/Timeout limit" onchange="if (document.getElementById('mwaitProgress')) document.getElementById('mwaitProgress').dataset.maxValue = this.value;"></span>] Seconds
2231 </span><br>
2232 <span>
2233 Wait: [<span class="modbvalue" data-format="f0" data-odb-path="${SeqODB}/State/Wait value"></span>/<span class="modbvalue" data-odb-path="${SeqODB}/State/Wait limit"></span>] <span class="modbvalue" data-odb-path="${SeqODB}/State/Wait type"></span>
2234 </span>
2235 </td>`;
2236
2237 } else {
2238 const waitFormula = (e.value === "Seconds") ? 'data-formula="x/1000"' : '';
2239 // Insert the normal wait row
2240 tr.innerHTML = `<td style="position: relative;width:80%;" colspan="2">&nbsp;
2241 <span class="modbhbar waitloopbar" style="position: absolute; top: 0; left: 0; color: lightgreen; height: 15px;" data-format="f0" data-odb-path="${SeqODB}/State/Wait value" ${waitFormula} id="mwaitProgress"></span>
2242 <span class="waitlooptxt">
2243 Wait: [<span class="modbvalue" data-format="f0" data-odb-path="${SeqODB}/State/Wait value" ${waitFormula}></span>/<span class="modbvalue" data-odb-path="${SeqODB}/State/Wait limit" ${waitFormula} onchange="if (document.getElementById('mwaitProgress')) document.getElementById('mwaitProgress').dataset.maxValue = this.value;"></span>] <span class="modbvalue" data-odb-path="${SeqODB}/State/Wait type"></span>
2244 </span>
2245 </td>`;
2246 }
2247 windowResize();
2248 } else if (e.id === "loopTrig") {
2249 // Loop type
2250 mjsonrpc_db_get_values([`${SeqODB}/State/Loop n`]).then(function(rpc) {
2251 let loopArray = rpc.result.data[0];
2252 for (let i = 0; i < loopArray.length; i++) {
2253 if (loopArray[i] === 0) break;
2254 let tr = table.insertRow(rIndex+1);
2255 tr.className = "loopTR";
2256 tr.innerHTML = `<td style="position: relative;width:80%;" colspan="2">&nbsp;
2257 <span class="modbhbar waitloopbar" style="position: absolute; top: 0; left: 0; color: #CBC3E3; height: 15px;" data-odb-path="${SeqODB}/State/Loop counter[${i}]" id="mloopProgress${i}"></span>
2258 <span class="waitlooptxt">
2259 Loop ${i}: [<span class="modbvalue" data-odb-path="${SeqODB}/State/Loop counter[${i}]"></span>/<span class="modbvalue" data-odb-path="${SeqODB}/State/Loop n[${i}]" onchange="if (document.getElementById('mloopProgress${i}')) document.getElementById('mloopProgress${i}').dataset.maxValue = this.value;"></span>]
2260 </span>
2261 </td>`;
2262 windowResize();
2263 }
2264 }).catch (function (error) {
2265 console.error(error);
2266 });
2267 }
2268 } else {
2269 // remove rows
2270 if (e.id === "waitTrig") {
2271 extraRows.waitSerial++;
2272 document.querySelectorAll('.waitTR').forEach(element => element.remove());
2273 }
2274
2275 if (e.id === "loopTrig") document.querySelectorAll('.loopTR').forEach(element => element.remove());
2276 windowResize();
2277 }
2278}
2279
2280// Helper function to identify browser, 1 FF, 2 Chrome, 3, other
2281function browserType() {
2282 if (navigator.userAgent.indexOf("Chrome") !== -1) {
2283 return 1;
2284 } else if (navigator.userAgent.indexOf("Firefox") !== -1) {
2285 return 2;
2286 }
2287
2288 return 1;
2289}
2290
2291// make visual hint that file is changes
2292function seqIsChanged(flag) {
2293 // flag - true is changed, false is saved
2294 let fileChangedId = "filechanged" + editorElements()[0].id.replace("lineNumbers","");
2295 let filechanged = document.getElementById(fileChangedId);
2296 if (filechanged) {
2297 if (flag) {
2298 filechanged.innerHTML = "&nbsp;&#9998;";
2299 } else if (flag === undefined) {
2300 // true if text has changed false if not
2301 return (filechanged.innerHTML !== "");
2302 } else {
2303 filechanged.innerHTML = "";
2304 }
2305 }
2306 // also change validation icon to defaule
2307 let vldBtn = document.getElementById("vldBtn");
2308 vldBtn.src = "icons/validate-syntax.svg";
2309 document.querySelectorAll('.seqbtn').forEach(btn => {
2310 btn.style.backgroundColor = "";
2311 });
2312}
2313
2314// save history of edits in element editor
2315function saveState(mslText,editor) {
2316 editor = (editor) ? editor : editorElements()[1];
2317 const editorId = editor.id;
2318 if (saveRevision[editorId] === false) {
2319 return;
2320 } else if (saveRevision[editorId] === undefined){
2321 saveRevision[editorId] = true;
2322 }
2323
2324 if (!previousRevisions[editorId]) {
2325 previousRevisions[editorId] = [];
2326 revisionIndex[editorId] = -1;
2327 }
2328
2329 // Add one more revision, and trim array if we had some undos
2330 revisionIndex[editorId]++;
2331 if (revisionIndex[editorId] < previousRevisions[editorId].length - 1) {
2332 previousRevisions[editorId].splice(revisionIndex[editorId] + 1);
2333 }
2334 // Push new revision and keep only nRevisions revisions
2335 previousRevisions[editorId].push(mslText)
2336 if (previousRevisions[editorId].length > nRevisions) {
2337 previousRevisions[editorId].shift();
2338 }
2339 revisionIndex[editorId] = previousRevisions[editorId].length - 1;
2340}
2341
2342// undo last edit
2343function undoEdit(editor) {
2344 editor = (editor) ? editor : editorElements()[1];
2345 const editorId = editor.id;
2346 if (revisionIndex[editorId] === 0) {
2347 // disable menu item
2348 disableMenuItems("undoMenu",true);
2349 seqIsChanged(false);
2350 return;
2351 } else {
2352 // enable menu item
2353 disableMenuItems("undoMenu",false);
2354 revisionIndex[editorId]--;
2355 }
2356 // reset redo
2357 disableMenuItems("redoMenu",false);
2358
2359 let caretPos = getCurrentCursorPosition(editor);
2360 let currText = editor.innerText;
2361 saveRevision[editorId] = false;
2362 editor.innerHTML = syntax_msl(previousRevisions[editorId][revisionIndex[editorId]]).join(lc).slice(0,-1);
2363 updateLineNumbers(editor.previousElementSibling,editor);
2364 saveRevision[editorId] = true;
2365 // calculate change in caret position based on length
2366 caretPos = caretPos + previousRevisions[editorId][revisionIndex[editorId]].length - currText.length;
2367 setCurrentCursorPosition(editor, caretPos);
2368}
2369
2370// redo the undo
2371function redoEdit(editor) {
2372 editor = (editor) ? editor : editorElements()[1];
2373 const editorId = editor.id;
2374 if (revisionIndex[editorId] >= previousRevisions[editorId].length - 1) {
2375 // disable menu item
2376 disableMenuItems("redoMenu",true);
2377 return;
2378 } else {
2379 // enable menu item
2380 disableMenuItems("redoMenu",false);
2381 revisionIndex[editorId]++;
2382 }
2383 // reset undo
2384 disableMenuItems("undoMenu",false);
2385 seqIsChanged(true);
2386
2387 let caretPos = getCurrentCursorPosition(editor);
2388 let currText = editor.innerText;
2389 saveRevision[editorId] = false;
2390 editor.innerHTML = syntax_msl(previousRevisions[editorId][revisionIndex[editorId]]).join(lc).slice(0,-1);
2391 updateLineNumbers(editor.previousElementSibling,editor);
2392 saveRevision[editorId] = true;
2393 // calculate change in caret position based on length
2394 caretPos = caretPos + previousRevisions[editorId][revisionIndex[editorId]].length - currText.length;
2395 setCurrentCursorPosition(editor, caretPos);
2396}
2397
2398// Select slines from startLine to endLine
2399function selectLines([startLine, endLine],e) {
2400 const selection = window.getSelection();
2401 // Remove existing selections
2402 selection.removeAllRanges();
2403 let startElementId = '#sline' + startLine;
2404 let endElementId = '#sline' + endLine;
2405 let startElement = null, endElement = null;
2406 if (e.querySelector(startElementId)) startElement = e.querySelector(startElementId).firstChild;
2407 if (e.querySelector(endElementId)) endElement = e.querySelector(endElementId).lastChild;
2408 // we need startElement and endElement with first/lastChild
2409 // the following prevents loosing selection but not ideal
2410 while (startElement === null && startLine <= endLine) {
2411 startLine++;
2412 startElementId = '#sline' + startLine;
2413 startElement = e.querySelector(startElementId).firstChild;
2414 }
2415 while (endElement === null && endLine > 0) {
2416 endLine--;
2417 endElementId = '#sline' + endLine;
2418 endElement = e.querySelector(endElementId).lastChild;
2419 }
2420 if (startElement && endElement) {
2421 const range = document.createRange();
2422 // Set the start of the range to the startElement at offset 0
2423 range.setStart(startElement, 0);
2424 // Set the end of the range to the endElement at its length
2425 range.setEnd(endElement, endElement.childNodes.length);
2426 // Add the range to the selection
2427 selection.addRange(range);
2428 }
2429}
2430
2431// switch between dark and light modes on request
2432function lightToDark(lToDcheck) {
2433 if (!lToDcheck) return;
2434 const edt_areas = document.querySelectorAll('.edt_area');
2435 if (lToDcheck.checked) {
2436 localStorage.setItem("darkMode", true);
2437 edt_areas.forEach(area => {
2438 area.style.backgroundColor = "black";
2439 area.style.color = "white";
2440 });
2441 updateCSSRule(".etab button:hover","background-color","black");
2442 updateCSSRule(".etab button:hover","color","white");
2443 updateCSSRule(".etab button:hover","border-bottom","5px solid black");
2444 updateCSSRule(".etab button.edt_active","background-color","black");
2445 updateCSSRule(".etab button.edt_active","color","white");
2446 updateCSSRule(".etab button.edt_active","border-bottom","5px solid black");
2447 updateCSSRule(".etab button.edt_active:hover","background-color","black");
2448 updateCSSRule(".etab button.edt_active:hover","border-bottom","5px solid black");
2449 } else {
2450 localStorage.removeItem("darkMode");
2451 edt_areas.forEach(area => {
2452 area.style.backgroundColor = "white";
2453 area.style.color = "black";
2454 });
2455 updateCSSRule(".etab button:hover","background-color","white");
2456 updateCSSRule(".etab button:hover","color","black");
2457 updateCSSRule(".etab button:hover","border-bottom","5px solid white");
2458 updateCSSRule(".etab button.edt_active","background-color","white");
2459 updateCSSRule(".etab button.edt_active","color","black");
2460 updateCSSRule(".etab button.edt_active","border-bottom","5px solid white");
2461 updateCSSRule(".etab button.edt_active:hover","background-color","white");
2462 updateCSSRule(".etab button.edt_active:hover","border-bottom","5px solid white");
2463 }
2464}
2465
2466function updateCSSRule(selector, property, value) {
2467 for (let i = 0; i < document.styleSheets.length; i++) {
2468 let styleSheet = document.styleSheets[i];
2469 let rules = styleSheet.cssRules || styleSheet.rules;
2470 if (!rules) continue;
2471 for (let j = 0; j < rules.length; j++) {
2472 let rule = rules[j];
2473 if (rule.selectorText === selector) {
2474 rule.style[property] = value;
2475 return;
2476 }
2477 }
2478 }
2479}
2480
2481// show/hide wait and loop progress
2482function showProgress(e) {
2483 //const progressDiv = document.getElementById("progressDiv");
2484 const progressDiv = document.getElementById("Progress");
2485 if (e === undefined) e = document.getElementById("showProgressBars");
2486 if (e.checked) {
2487 localStorage.setItem("showProgress",true);
2488 progressDiv.style.display = "block";
2489 //dlgShow(progressDiv);
2490 } else {
2491 localStorage.removeItem("showProgress");
2492 progressDiv.style.display = "none";
2493 //dlgHide(progressDiv);
2494 }
2495}
2496
2497// Mark the current line number
2498function markCurrLineNum() {
2499 const [lineNumbers,editor] = editorElements();
2500 const currLines = lineNumbers.querySelectorAll(".edt_linenum_curr");
2501 const [startLine,endLine] = getLinesInSelection(editor);
2502 if (startLine === 0 && endLine === 0) return;
2503 currLines.forEach((line) => line.classList.remove("edt_linenum_curr"));
2504 for (let i = startLine; i <= endLine; i++) {
2505 let lineNumId = "#lNum" + i.toString();
2506 let lineNum = lineNumbers.querySelector(lineNumId);
2507 if (lineNum)
2508 lineNum.className = "edt_linenum_curr";
2509 }
2510}
2511
2512
2513// Check if program is progName is running.
2514// If not, try to get it going
2515function checkProgram(progName = "Sequencer") {
2516 mjsonrpc_call('cm_exist', {"name":progName,"unique":true}).then(function (rpc1) {
2517 mjsonrpc_db_get_values([`/Programs/${progName}/Start command`]).then(function(rpc2) {
2518 let isRunning = (rpc1.result.status === 1);
2519 let isDefined = ((rpc2.result.data[0] !== null) && (rpc2.result.data[0] !== ""));
2520 if (isRunning && isDefined) return;
2521 // progName is not running or not defined, stop it just in case and check the reason
2522 mjsonrpc_stop_program(progName);
2523 let message = "";
2524 if (isDefined) {
2525 message = `${progName} program is not running.<br>Should I start it?`
2526 } else {
2527 message = `${progName} program is not configured and not running.<br>Should I try to start it anyway?`
2528 }
2529 dlgConfirm(message,function(resp) {
2530 // Guess the name of the actual binary
2531 let binName = (progName === "Sequencer") ? "msequencer" : progName.toLowerCase();
2532 if (resp) {
2533 if (!isDefined) {
2534 // assume that progName is in path and create a start command, sleep 2s,
2535 // set value to "progName -D", sleep 2s, start program
2536 mjsonrpc_db_create([{"path" : `/Programs/${progName}/Start command`, "type" : TID_STRING}]).then(function (rpc3) {
2537 setTimeout(function(){
2538 mjsonrpc_db_paste([`/Programs/${progName}/Start command`],[`${binName} -D`]).then(function (rpc4) {
2539 if (rpc4.result.status[0] === 1) {
2540 mjsonrpc_start_program(progName);
2541 }
2542 }).catch(function (error) {
2543 console.error(error);
2544 });
2545 }, 2000);
2546 }).catch(function (error) {
2547 console.error(error);
2548 });
2549 } else {
2550 mjsonrpc_start_program(progName);
2551 }
2552 // take 3 seconds and check that it actually started
2553 setTimeout(function(){
2554 mjsonrpc_call('cm_exist', {"name":progName,"unique":true}).then(function (rpc5) {
2555 if (rpc5.result.status === 1) {
2556 dlgAlert(`${progName} started successfully.`);
2557 } else {
2558 dlgAlert(`Failed to start ${progName}!<br>Try to start it manually (${binName} -D)`);
2559 }
2560 });
2561 }, 3000);
2562 }
2563 });
2564 }).catch (function (error) {
2565 console.error(error);
2566 });
2567 }).catch(function (error) {
2568 console.error(error);
2569 });
2570}
2571
2572// Python programs seem to be different from standard midas - to be checked
2573function checkPyProgram(progName = "PySequencer") {
2574 mjsonrpc_call('cm_exist', {"name":progName,"unique":true}).then(function (rpc1) {
2575 mjsonrpc_db_get_values([`/Programs/${progName}/Start command`]).then(function(rpc2) {
2576 let isRunning = (rpc1.result.status === 1);
2577 let isDefined = ((rpc2.result.data[0] !== null) && (rpc2.result.data[0] !== ""));
2578 if (isRunning && isDefined) return;
2579 // progName is not running or not defined, stop it just in case and check the reason
2580 mjsonrpc_stop_program(progName);
2581 let message = "";
2582 if (isDefined) {
2583 message = `${progName} program is not running.<br>Should I start it?`
2584 } else {
2585 message = `${progName} program is not configured and not running.<br>Should I try to start it anyway?`
2586 }
2587 dlgConfirm(message,function(resp) {
2588 // Guess the name of the actual binary
2589 let binName = (progName === "PySequencer") ? "python3 $MIDASSYS/python/midas/sequencer.py" : "python3 $MIDASSYS/python/midas/" + progName.toLowerCase() + ".py";
2590 if (resp) {
2591 if (!isDefined) {
2592 // assume that progName is in path and create a start command, sleep 2s,
2593 // set value to "progName -D", sleep 2s, start program
2594 mjsonrpc_db_create([{"path" : `/Programs/${progName}/Start command`, "type" : TID_STRING}]).then(function (rpc3) {
2595 setTimeout(function(){
2596 mjsonrpc_db_paste([`/Programs/${progName}/Start command`],[`${binName} -D`]).then(function (rpc4) {
2597 if (rpc4.result.status[0] === 1) {
2598 mjsonrpc_start_program(progName);
2599 }
2600 }).catch(function (error) {
2601 console.error(error);
2602 });
2603 }, 2000);
2604 }).catch(function (error) {
2605 console.error(error);
2606 });
2607 } else {
2608 mjsonrpc_start_program(progName);
2609 }
2610 // take 3 seconds and check that it actually started
2611 setTimeout(function(){
2612 mjsonrpc_call('cm_exist', {"name":progName,"unique":true}).then(function (rpc5) {
2613 if (rpc5.result.status === 1) {
2614 dlgAlert(`${progName} started successfully.`);
2615 } else {
2616 dlgAlert(`Failed to start ${progName}!<br>Try to start it manually (${binName} -D)`);
2617 }
2618 });
2619 }, 3000);
2620 }
2621 });
2622 }).catch (function (error) {
2623 console.error(error);
2624 });
2625 }).catch(function (error) {
2626 console.error(error);
2627 });
2628}
2629
2630function captureSelected() {
2631 if (window.getSelection) {
2632 let selection = window.getSelection();
2633 let text = selection.toString();
2634 let range = selection.getRangeAt(0);
2635 if (text && range) {
2636 const rangeData = {
2637 startPath: range.startContainer,
2638 startOffset: range.startOffset,
2639 endPath: range.endContainer,
2640 endOffset: range.endOffset
2641 };
2642 sessionStorage.setItem("tempSelText", text);
2643 sessionStorage.setItem("tempSelRange", JSON.stringify(rangeData));
2644 }
2645 }
2646}
2647
2648function editMenu(action) {
2649 let text = sessionStorage.getItem("tempSelText") ?? "";
2650 let storedRange = sessionStorage.getItem("tempSelRange") ?? "";
2651 /*
2652 if (storedRange) {
2653 let rangeData = JSON.parse(storedRange);
2654 let startContainer = nodeFromPath(rangeData.startPath);
2655 let endContainer = nodeFromPath(rangeData.endPath);
2656
2657 // Create a new range
2658 let newRange = new Range();
2659 newRange.setStart(startContainer, rangeData.startOffset);
2660 newRange.setEnd(endContainer, rangeData.endOffset);
2661
2662 // Select the new range
2663 let selection = window.getSelection();
2664 selection.removeAllRanges();
2665 selection.addRange(newRange);
2666 }
2667 */
2668 if (action === "Copy") {
2669 if (text) {
2670 sessionStorage.setItem("copiedText",text);
2671 }
2672 } else if (action === "Paste") {
2673 const copiedText = sessionStorage.getItem("copiedText");
2674 if (copiedText) {
2675 newRange.deleteContents();
2676 newRange.insertNode(document.createTextNode(copiedText));
2677 }
2678 } else if (action === "Cut") {
2679 if (text) {
2680 sessionStorage.setItem("copyText",text);
2681 //document.execCommand("cut");
2682 newRange.deleteContents();
2683 }
2684 } else if (action === "Undo") {
2685 undoEdit();
2686 } else if (action === "Redo") {
2687 redoEdit();
2688 }
2689}
2690
2691// Switch to the clicked tab
2692function openETab(btn) {
2693 const tabcontent = document.querySelectorAll(".etabcontent");
2694 const tablinks = document.querySelectorAll(".etablinks");
2695 const tab = btn ? btn : document.querySelectorAll(".edt_active")[0];
2696 const tabID = tab.id.replace("-btn","")
2697 tabcontent.forEach(content => {
2698 content.style.display = "none";
2699 });
2700 tablinks.forEach(link => {
2701 link.classList.remove("edt_active");
2702 });
2703 tab.className += " edt_active";
2704 //document.getElementById(tabID).style.display = "inline-flex";
2705 document.getElementById(tabID).style.display = "flex";
2706 // For the main sequence tab disable Save and Save as...
2707 if (tabID === "etab1") {
2708 disableMenuItems("noteditor",true);
2709 } else {
2710 disableMenuItems("noteditor",false);
2711 }
2712 // Change validation icon to defaule
2713 let vldBtn = document.getElementById("vldBtn");
2714 vldBtn.src = "icons/validate-syntax.svg";
2715 document.querySelectorAll('.seqbtn').forEach(btn => {
2716 btn.style.backgroundColor = "";
2717 });
2718
2719 // Adjust height of active editor
2720 windowResize();
2721}
2722
2723// Close the clicked tab
2724function closeTab(tab,event) {
2725 const tablinks = document.querySelectorAll(".etablinks");
2726 if (tablinks.length < 3) return;
2727 const tabCount = parseInt(tab.parentElement.id.replace("-btn","").replace("etab",""));
2728 const tabBtn = document.getElementById(`etab${tabCount}-btn`);
2729 const tabContent = document.getElementById(`etab${tabCount}`);
2730 if (seqIsChanged()) {
2731 dlgConfirm("File was changed, close anyway?", function(resp) {
2732 if (resp) {
2733 tabBtn.remove();
2734 tabContent.remove();
2735 // switch to previous tab
2736 openETab(document.getElementById(`etab${(tabCount-1)}-btn`));
2737 }
2738 });
2739 } else {
2740 tabBtn.remove();
2741 tabContent.remove();
2742 // switch to previous tab
2743 openETab(document.getElementById(`etab${(tabCount-1)}-btn`));
2744 }
2745 // need this since the close button is inside the tab button
2746 event.stopPropagation();
2747}
2748
2749// Create and add a new editor tab. If tabNum is provided insert tab at corresponding order
2750function addETab(btn,tabNum) {
2751 // Create tab button
2752 const tabBtn = document.createElement("button");
2753 tabBtn.className = "etablinks";
2754 tabNum = tabNum || (btn.previousElementSibling ? parseInt(btn.previousElementSibling.id.replace("-btn","").replace("etab","")) + 1 : 1);
2755 // Make room for inserted tab if needed
2756 shiftETabsUpFrom(tabNum);
2757 tabBtn.id = `etab${tabNum}-btn`;
2758
2759 tabBtn.innerHTML = `<span id="etab${tabNum}-lbl">Untitled</span><span id="filechanged${tabNum}"></span><span onclick="closeTab(this,event);" class="closebtn">&times;</span>`;
2760 const refBtn = document.getElementById(`etab${tabNum + 1}-btn`) || btn;
2761 btn.parentNode.insertBefore(tabBtn, refBtn);
2762 tabBtn.onclick = function () { openETab(this);};
2763
2764 // Create editor area
2765 const tabContent = document.createElement("div");
2766 tabContent.id = `etab${tabNum}`;
2767 tabContent.className = "etabcontent";
2768
2769 let makeDark = "";
2770 if (localStorage.getItem("darkMode")) makeDark = "style='background-color: black; color: white;'";
2771
2772 tabContent.innerHTML =
2773 `<pre id="lineNumbers${tabNum}" class="edt_linenum"></pre><pre id="editorTab${tabNum}" ${makeDark} class="edt_area" spellcheck="false" contenteditable="false"></pre>`;
2774
2775 const refContent = document.getElementById(`etab${tabNum + 1}`) || document.getElementById("lastETab");
2776 refContent.parentNode.insertBefore(tabContent, refContent);
2777 tabBtn.click();
2778 windowResize();
2779 // Add event listeners
2780 editorEventListeners();
2781 return tabBtn;
2782}
2783
2784// Helper function to shift and renumber tabs
2785function shiftETabsUpFrom(startNum) {
2786 // Count existing tabs by content divs
2787 const contents = document.querySelectorAll('.etabcontent');
2788 const maxTab = contents.length;
2789
2790 // Rename backwards to avoid collisions
2791 for (let i = maxTab; i >= startNum; i--) {
2792 renameETab(i, i + 1);
2793 }
2794}
2795// Helper function to reorder tabs (change associated ids).
2796function renameETab(oldNum, newNum) {
2797 const mappings = [
2798 [`etab${oldNum}`, `etab${newNum}`],
2799 [`etab${oldNum}-btn`, `etab${newNum}-btn`],
2800 [`editorTab${oldNum}`, `editorTab${newNum}`],
2801 [`lineNumbers${oldNum}`, `lineNumbers${newNum}`],
2802 [`etab${oldNum}-lbl`, `etab${newNum}-lbl`],
2803 [`filechanged${oldNum}`, `filechanged${newNum}`]
2804 ];
2805
2806 mappings.forEach(([oldId, newId]) => {
2807 const el = document.getElementById(oldId);
2808 if (el) el.id = newId;
2809 });
2810}
2811
2812
2813// Return the pre of lineNumbers, editor, tab label and tab button element of the active tab
2814function editorElements() {
2815 const btn = (document.querySelectorAll(".edt_active")[0]) ? document.querySelectorAll(".edt_active")[0] : document.getElementById("etab1-btn");
2816 const tab = document.getElementById(btn.id.replace("-btn",""));
2817 const [lineNumbers,editor] = tab.children;
2818 //const btnLabel = (btn.id !== "etab1-btn") ? btn.children[0] : btn.children[1];
2819 const btnLabel = btn.children[0];
2820 return [lineNumbers,editor,btnLabel,btn];
2821}
2822
2823// disable and enable clicking on menu item
2824function disableMenuItems(className,flag) {
2825 /* Arguments:
2826 className - the class name of the item
2827 flag - true/false to enable/disable item
2828 */
2829 const els = document.querySelectorAll(`.${className}`);
2830 els.forEach(e => {
2831 if (flag) {
2832 e.style.opacity = 0.5;
2833 e.style.pointerEvents = "none";
2834 } else {
2835 e.style.opacity = "";
2836 e.style.pointerEvents = "";
2837 }
2838 });
2839}
2840
2841// Function to replace some special characters in the text
2842function escapeSpecialCharacters(text) {
2843 return text.replace(/&/g, "&amp;")
2844 .replace(/</g, "&lt;")
2845 .replace(/>/g, "&gt;")
2846 .replace(/\r\n|\n\r/g, '\n')
2847 //.replace(/"/g, "&quot;")
2848 //.replace(/'/g, "&#39;")
2849 .replace(/\t/g, " ")
2850 // and illegal ascii characters
2851 .replace(/[^\x00-\x7F]/g, " ");
2852}
2853
2854// Produce a help window
2855function dlgHelp() {
2856 let help = `
2857<span style='text-align: left;'>
2858<b>Hidden features of the sequencer editor</b>
2859<ul style='white-space: pre;font-family: monospace;'>
2860<li>Double click on the edit area of the first (main) tab to edit the currently loaded sequence.</li>
2861<li>Tab - Indent selected lines.</li>
2862<li>Shift+Tab - Unindent selected lines.</li>
2863<li>Escape - Autoindent selected lines according to syntax rules.</li>
2864<li>Ctrl+C - Copy selected text.</li>
2865<li>Ctrl+V - Paste selected text.</li>
2866<li>Ctrl+A - Select all text.</li>
2867<li>Ctrl+Z - Undo last change.</li>
2868<li>Ctrl+R - Redo last undo.</li>
2869</ul>
2870</span>
2871`;
2872 const d = dlgMessage("Editor help",help, false, false);
2873 const btn = d.querySelectorAll('.dlgButton')[0];
2874 btn.className = "dlgButtonDefault";
2875 btn.focus();
2876
2877}
2878
2879// Activate drag and drop events on next files table
2880function activateDragDrop(table) {
2881 /* Arguments:
2882 table - The table element containing the list of next files.
2883 */
2884 // collect all rows with class dragrow
2885 const rows = table.querySelectorAll('.dragrow');
2886 let dragStartIndex,dragEndIndex;
2887 let emptyRow;
2888 // add event listeners
2889 rows.forEach(row => {
2890 row.addEventListener('dragstart', dragStart);
2891 row.addEventListener('dragover', dragOver);
2892 row.addEventListener('dragend', dragEnd);
2893 });
2894
2895 function dragStart(e) {
2896 dragStartIndex = Array.from(rows).indexOf(this);
2897 rows.forEach(row => row.classList.remove('dragstart'));
2898 this.classList.add('dragstart');
2899 }
2900 function dragOver(e) {
2901 e.preventDefault();
2902 dragEndIndex = Array.from(rows).indexOf(this);
2903 // Create or update the empty row element
2904 if (!emptyRow) {
2905 emptyRow = document.createElement('tr');
2906 emptyRow.innerHTML = "<td colspan=4>&nbsp;</td>";
2907 emptyRow.classList.add('empty-dragrow');
2908 }
2909 // Insert the empty row element at the appropriate position
2910 if (dragEndIndex > dragStartIndex) {
2911 this.parentNode.insertBefore(emptyRow, this.nextSibling);
2912 } else if (dragEndIndex < dragStartIndex) {
2913 this.parentNode.insertBefore(emptyRow, this);
2914 }
2915 }
2916 function dragEnd(e) {
2917 if (emptyRow) {
2918 emptyRow.remove();
2919 emptyRow = null;
2920 }
2921 reorderNextFilenames(dragStartIndex,dragEndIndex);
2922 rows.forEach(row => {
2923 row.classList.remove('dragstart');
2924 });
2925 }
2926}
2927
2928// Move next filename from dragStarIndex to dragEndIndex position
2929function reorderNextFilenames(dragStarIndex,dragEndIndex) {
2930 /* Arguments:
2931 dragStarIndex - move file from this indext.
2932 dragEndIndex - destination index of the file.
2933 */
2934 if (dragStarIndex === dragEndIndex) return;
2935 let odbpath = mslDefs.filename.next;
2936 mjsonrpc_db_get_values([odbpath]).then(function(rpc) {
2937 if (rpc.result.status[0] !== 1) return;
2938 let fList = rpc.result.data[0];
2939
2940 // Remove the draggedFile from the starting index
2941 const draggedFile = fList.splice(dragStarIndex, 1)[0];
2942 // Insert the draggedFile at the new index
2943 fList.splice(dragEndIndex,0,draggedFile);
2944 // Check that the list does not contain more than 10 files
2945 if (fList.length > 10) {
2946 dlgAlert("There are more than 10 files. Aborting!.");
2947 return;
2948 }
2949 // Move full slots to the begenning of the array
2950 fList = fList.filter(Boolean);
2951 fList = [...fList, ...new Array(10 - fList.length).fill("")];
2952 // Update values in ODB
2953 mjsonrpc_db_paste([odbpath],[fList]).then(function (rpc2) {
2954 if (rpc2.result.status[0] !== 1) {
2955 dlgAlert("Failed to move the file!<br>Please check.");
2956 }
2957 }).catch(function (error) {
2958 console.error(error);
2959 });
2960 }).catch (function (error) {
2961 console.error(error);
2962 });
2963}
2964
2965// Change the next file name in the clicked row
2966function chngNextFilename(e,filename) {
2967 /* Arguments:
2968 e - (optional) cell element on the same row of "next file" to be changed.
2969 If last row (or undefined) add a file to the end of the queue.
2970 filename - (optional) The file name to add to the end of the queue.
2971 */
2972 e = (e) ? e : document.getElementById("addAsNext");
2973 //if (e === undefined) e = document.getElementById("addAsNext");
2974 // file index from table row index
2975 let index = e ? e.parentElement.rowIndex - 1 : 0;
2976 // Only 10 files are allowed
2977 if (index > 9) {
2978 dlgAlert("Maximum number (10) of next files reached!");
2979 return -1;
2980 }
2981 index++;
2982 let odbpath = mslDefs.filename.next;
2983 mjsonrpc_db_get_values([`${SeqODB}/State/Path`]).then(function(rpc) {
2984 let path = mslDefs.filename.path + "/" + rpc.result.data[0].replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
2985 sessionStorage.setItem("pathName",path);
2986 if (filename) {
2987 odbpath = odbpath + "[" + index.toString() + "]";
2988 modbset(odbpath,filename);
2989 } else {
2990 file_picker(path,mslDefs.filename.ext,function(filename) {
2991 filename = filename.replace(/^sequencer\//,'').replace(/^\//,'');
2992 odbpath = odbpath + "[" + index.toString() + "]";
2993 if (filename) {
2994 modbset(odbpath,filename);
2995 }
2996 });
2997 }
2998 return index+1;
2999 }).catch(function (error) {
3000 mjsonrpc_error_alert(error);
3001 });
3002}
3003
3004// Reomve the next file name from the queue
3005function remNextFilename(e) {
3006 /* Arguments:
3007 e - cell element on the same row of "next file" to be removed
3008 */
3009 // file index from table row index
3010 let index = e.parentElement.rowIndex - 1;
3011 let odbpath = mslDefs.filename.next;
3012 mjsonrpc_db_get_values([odbpath]).then(function(rpc) {
3013 if (rpc.result.status[0] !== 1) return;
3014 let fList = rpc.result.data[0];
3015 // Empty element
3016 fList[index] = "";
3017 // Move full slots to the begenning of the array
3018 fList = fList.filter(Boolean);
3019 fList = [...fList, ...new Array(10 - fList.length).fill("")];
3020 mjsonrpc_db_paste([odbpath],[fList]).then(function (rpc2) {
3021 if (rpc2.result.status[0] !== 1) {
3022 dlgAlert("Failed to remove file from list!<br>Please check.");
3023 }
3024 }).catch(function (error) {
3025 console.error(error);
3026 });
3027 }).catch (function (error) {
3028 console.error(error);
3029 });
3030}
3031
3032var NcheckValue = 0;
3033// Wait for ODB in path to have value
3034// If value is not reached, give up after 10s
3035function checkODBValue(path,value,funcCall,args) {
3036 /* Arguments:
3037 path - ODB path to monitor for value
3038 value - the value to be reached and return success
3039 funcCall - function name to call when value is reached
3040 args - argument to pass to funcCall
3041 */
3042 // Call the mjsonrpc_db_get_values function
3043 mjsonrpc_db_get_values([path]).then(function(rpc) {
3044 if (rpc.result.status[0] === 1 && rpc.result.data[0] !== value) {
3045 NcheckValue++;
3046 if (NcheckValue < 100) {
3047 // Wait 0.1 second and then call checkODBValue again
3048 // Time out after 10 s
3049 setTimeout(() => {
3050 checkODBValue(path,value,funcCall,args);
3051 }, 100);
3052 }
3053 } else {
3054 if (funcCall) funcCall(args);
3055 // reset counter
3056 NcheckValue = 0;
3057 }
3058 }).catch(function(error) {
3059 console.error(error);
3060 });
3061}
3062
3063function isTextSelected(element) {
3064 const selection = window.getSelection();
3065 if (!selection.rangeCount) return false;
3066
3067 const range = selection.getRangeAt(0);
3068 const selectedText = selection.toString();
3069
3070 // Check if the selection is within the specified element
3071 if (!element.contains(range.commonAncestorContainer)) return false;
3072
3073 // Check if there is any selected text
3074 return selectedText.length > 0;
3075}
3076
3077function deleteSelectedText(element) {
3078 const selection = window.getSelection();
3079 if (!selection.rangeCount) return; // No selection exists
3080 const range = selection.getRangeAt(0);
3081 // Ensure the selection is within the target element
3082 if (!element.contains(range.commonAncestorContainer)) return;
3083 // Delete the selected text
3084 range.deleteContents();
3085}
3086
3087// Make sure that the caret stays visible
3088function keepCaretVisible(editor) {
3089 const selection = window.getSelection();
3090 const caretPosition = selection.getRangeAt(0).getBoundingClientRect();
3091 const containerRect = editor.getBoundingClientRect();
3092 const caretTop = caretPosition.top - containerRect.top;
3093 const caretBottom = caretPosition.bottom - containerRect.top;
3094 const containerHeight = containerRect.height;
3095 // Caclulate font size
3096 const emInPixels = parseFloat(window.getComputedStyle(editor).fontSize) * 1.5;
3097
3098 // If the caret is near the bottom, scroll the container down with 1.5 fontSize
3099 if (caretBottom > containerHeight) {
3100 editor.scrollTop += caretBottom - containerHeight + emInPixels;
3101 }
3102
3103 // If the caret is near the top, scroll the container up
3104 if (caretTop < 0) {
3105 editor.scrollTop -= Math.abs(caretTop);
3106 }
3107}
3108
3109
3110// commands object for syntax validation
3111const mslCommands = {
3112 INCLUDE: {
3113 patterns: [/^INCLUDE\s+.+(\.msl)?$/i],
3114 description: "Include another MSL file.",
3115 args: 1,
3116 },
3117 BREAK: {
3118 patterns: [/^BREAK$/i],
3119 description: "Break (finish prematurely) a loop.",
3120 args: 0,
3121 },
3122 CALL: {
3123 patterns: [/^CALL\s+\w+(\s*,\s*.+)*$/i],
3124 description: "Call a subroutine with optional parameters.",
3125 args: ">=1",
3126 },
3127 CAT: {
3128 patterns: [/^CAT\s+\w+(\s*,\s*.+)*$/i],
3129 description: "Concatenate strings into a single variable.",
3130 args: ">=1",
3131 },
3132 COMMENT: {
3133 patterns: [/^(?:COMMENT\s+.*|#.*)$/i],
3134 description: "A comment line starting with COMMENT or #.",
3135 args: ">=1",
3136 },
3137 EXIT: {
3138 patterns: [/^EXIT$/i],
3139 description: "Exit the script immediately.",
3140 args: 0,
3141 },
3142 GOTO: {
3143 patterns: [/^GOTO\s+\d+$/i],
3144 description: "Jump to a specific line in the script.",
3145 args: 1,
3146 },
3147 IF: {
3148 patterns: [/^IF\s*\‍(.+\‍)$/i, /^IF\s+.+$/i],
3149 description: "Conditional execution of code blocks.",
3150 blockStart: true, // Marks this as a block start command
3151 args: ">=1",
3152 },
3153 ELSE: {
3154 patterns: [/^ELSE$/i],
3155 description: "Alternative block for an IF statement.",
3156 args: 0,
3157 },
3158 ENDIF: {
3159 patterns: [/^ENDIF$/i],
3160 description: "Ends an IF block.",
3161 blockEnd: "IF", // Marks this as a block end command for IF
3162 args: 0,
3163 },
3164 LIBRARY: {
3165 patterns: [/^LIBRARY\s+.+$/i],
3166 description: "Declare the file as a library.",
3167 args: 1,
3168 },
3169 LOOP: {
3170 patterns: [
3171 /^LOOP\s+(\d+|INFINITE|\$\w+)$/i, // LOOP 5, LOOP INFINITE, or LOOP $varName
3172 /^LOOP\s+\w+\s*,\s*.+$/i, // LOOP i, 1:10
3173 // /^LOOP\s+\w+\s*,\s*(?:\d+:\d+|\S+)$/i, // LOOP i, 1:10
3174 ],
3175 description: "Execute a loop a fixed number of times or with values.",
3176 blockStart: true, // Marks this as a block start command
3177 args: ">=1",
3178 //argcond1: /^(\d+|INFINITE|\$\w+)$/, // First argument can be a number, INFINITE, or a variable
3179 },
3180 ENDLOOP: {
3181 patterns: [/^ENDLOOP$/i],
3182 description: "Ends a LOOP block.",
3183 blockEnd: "LOOP", // Marks this as a block end command for LOOP
3184 args: 0,
3185 },
3186 MESSAGE: {
3187 patterns: [/^MESSAGE\s+.+(\s*,\s*1)?$/i],
3188 description: "Display a message in the browser.",
3189 args: ">=1",
3190 },
3191 MSG: {
3192 patterns: [/^MSG\s+.+(\s*,\s*(ERROR|INFO|DEBUG|LOG|TALK))?$/i],
3193 description: "Send a message to the MIDAS buffer.",
3194 args: ">=1",
3195 },
3196 ODBCREATE: {
3197 patterns: [/^ODBCREATE\s+.+,\s+\w+(\s*,\s*\d+)?$/i],
3198 description: "Create an ODB key.",
3199 args: ">=2",
3200 },
3201 ODBDELETE: {
3202 patterns: [/^ODBDELETE\s+.+$/i],
3203 description: "Delete an ODB key or subdirectory.",
3204 args: 1,
3205 },
3206 ODBGET: {
3207 patterns: [/^ODBGET\s+(?:"[^"]+"|\/[^,]+|\$\w+(?:\[[^\]]+\])?),\s*[A-Za-z_]\w*(?:\[[^\]]+\])?$/i],
3208 description: "Retrieve a value from the ODB.",
3209 args: 2,
3210 argcond: [/^(?:\/.+|\$\w+(?:\[[^\]]+\])?)$/, // First argument must be an ODB path or a variable containing one
3211 /^[A-Za-z_]\w*(?:\[[^\]]+\])?$/], // Second argument is the destination variable name
3212 },
3213 ODBINC: {
3214 patterns: [/^ODBINC\s+.+(\s*,\s*[+-]?\d+)?$/i],
3215 description: "Increment an ODB value.",
3216 args: ">=1",
3217 },
3218 ODBLOOKUP: {
3219 patterns: [/^ODBLOOKUP\s+".+",\s+".+",\s+\w+$/i],
3220 description: "Lookup a string in the ODB array.",
3221 args: 3,
3222 },
3223 ODBSET: {
3224 //patterns: [/^ODBSET\s+("[^"]*"|\/[^,]+),\s*("[^"]*"|[^,]+)(\s*,\s*[01])?$/i,],
3225 patterns: [/^ODBSET\s+.+,\s+.+(\s*,\s*[01])?$/i],
3226 description: "Set a value in the ODB.",
3227 args: ">=2",
3228 argcond: [
3229 /^.+$/, // First argument must start with / or be a quoted string
3230 /^.+$/, // Second argument can be a quoted string or unquoted value
3231 /^[01]$/, // Third argument (optional) must be 0 or 1
3232 ],
3233 },
3234 ODBLOAD: {
3235 patterns: [/^ODBLOAD\s+.+(\s*,\s*.+)?$/i],
3236 description: "Load an external file into the ODB.",
3237 args: ">=1",
3238 },
3239 ODBSAVE: {
3240 patterns: [/^ODBSAVE\s+.+,\s+.+$/i],
3241 description: "Save part of the ODB to a file.",
3242 args: 2,
3243 },
3244 ODBSUBDIR: {
3245 patterns: [/^ODBSUBDIR\s+("\/[^"]*"|\/[^,]+)$/i],
3246 description: "Set a base path for ODB commands.",
3247 blockStart: true, // Marks this as a block start command
3248 args: 1,
3249 },
3250 ENDODBSUBDIR: {
3251 patterns: [/^ENDODBSUBDIR$/i],
3252 description: "Ends an ODBSUBDIR block.",
3253 blockEnd: "ODBSUBDIR", // Marks this as a block end command for ODBSUBDIR
3254 args: 1,
3255 },
3256 PARAM: {
3257 patterns: [/^PARAM\s+\w+(\s*,\s*.+)*$/i],
3258 description: "Define script parameters.",
3259 args: ">=1",
3260 },
3261 RUNDESCRIPTION: {
3262 patterns: [/^RUNDESCRIPTION\s+.+$/i],
3263 description: "Set a run description.",
3264 args: 1,
3265 },
3266 SCRIPT: {
3267 patterns: [/^SCRIPT\s+.+(\s*,\s*.+)*$/i],
3268 description: "Call a server-side script with optional parameters.",
3269 args: ">=1",
3270 },
3271 SET: {
3272 patterns: [/^SET\s+\w+\s*,\s*.+$/i],
3273 description: "Set a variable to a value.",
3274 args: 2,
3275 },
3276 SUBROUTINE: {
3277 patterns: [/^SUBROUTINE\s+\w+$/i],
3278 description: "Start a subroutine block.",
3279 blockStart: true, // Marks this as a block start command
3280 args: 1,
3281 },
3282 ENDSUBROUTINE: {
3283 patterns: [/^ENDSUBROUTINE$/i],
3284 description: "Ends a subroutine block.",
3285 blockEnd: "SUBROUTINE", // Marks this as a block end command for SUBROUTINE
3286 args: 0,
3287 },
3288 TRANSITION: {
3289 patterns: [/^TRANSITION\s+(start|stop|pause|resume)$/i],
3290 description: "Trigger a run state transition.",
3291 args: 1,
3292 },
3293 WAIT: {
3294 patterns: [
3295 /^WAIT\s+seconds,\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)|\$\w+)$/i, // WAIT seconds, 10
3296 /^WAIT\s+events,\s*(\d+|\$\w+)$/i, // WAIT events,10
3297
3298 // WAIT ODBValue, /path, op, value
3299 // WAIT ODBValue, /path, op, value, timeout, seconds
3300 // WAIT ODBValue, /path, op, value, timeout, seconds, status
3301 //
3302 // The timeout is optional and may be a number or a sequencer variable.
3303 // If present, the optional status argument is the variable name that receives
3304 // the timeout status from the sequencer runtime.
3305 // /^WAIT\s+ODBvalue,\s*(?:"?\/?[^,"]+"?|\$\w+)\s*,\s*(?:>=|<=|==|!=|>|<)\s*,\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)|\$\w+)(?:\s*,\s*timeout\s*,\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)|\$\w+))?$/i,
3306 /^WAIT\s+ODBvalue,\s*(?:"?\/?[^,"]+"?|\$\w+(?:\[[^\]]+\])?)\s*,\s*(?:>=|<=|==|!=|>|<)\s*,\s*([+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)|\$\w+(?:\[[^\]]+\])?)(?:\s*,\s*timeout\s*,\s*([+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)|\$\w+(?:\[[^\]]+\])?)(?:\s*,\s*([A-Za-z_]\w*(?:\[[^\]]+\])?|\$\w+(?:\[[^\]]+\])?))?)?$/i,
3307 ],
3308 description: "Wait for a condition, time, or event.",
3309 args: ">=1",
3310 },
3311 EQUATION: {
3312 patterns: [/^[a-zA-Z_]\w*\s*=\s*([0-9]+(\.\d+)?|\$[a-zA-Z_]\w*|[\d\w\$\s\+\-\*\/\^\‍(\‍)]+|[\w\$]+\‍(.*\‍)|".*"|'.*')$/i],
3313 description: "Variable assignment or equation.",
3314 args: ">=1",
3315 },
3316};
3317
3318const pythonCommands = {
3319 // Comment lines starting with "#" are ignored.
3320 COMMENT: {
3321 patterns: [/#.*/g],
3322 description: "Comment line. Lines starting with '#' are ignored.",
3323 args: 0,
3324 },
3325 // Multi line comments between triple brackets are ignored
3326 MULTICOMM: {
3327 patterns: [/("""[\s\S]*?""")|('''[\s\S]*?''')/g],
3328 description: "Multi line comments between tripple quotes.",
3329 args: 0,
3330 },
3331 // Function definition
3332 DEF: {
3333 patterns: [/^\s*def\s+[A-Za-z_]\w*\s*\‍([^)]*\‍)\s*:$/],
3334 description: "Function definition. Syntax: def name(params):",
3335 args: ">=0",
3336 },
3337 // Class definition
3338 CLASS: {
3339 patterns: [/^\s*class\s+[A-Za-z_]\w*(?:\s*\‍([^)]*\‍))?\s*:$/],
3340 description: "Class definition. Syntax: class Name(Base1, Base2):",
3341 args: ">=0",
3342 },
3343 // Import statements
3344 IMPORT: {
3345 patterns: [
3346 /^\s*import\s+[A-Za-z_]\w*(?:\.\w+)*(?:\s*,\s*[A-Za-z_]\w*(?:\.\w+)*)*\s*$/,
3347 /^\s*from\s+[A-Za-z_]\w*(?:\.\w+)*\s+import\s+(?:\*\s*|[A-Za-z_]\w*(?:\s+as\s+[A-Za-z_]\w*)?(?:\s*,\s*[A-Za-z_]\w*(?:\s+as\s+[A-Za-z_]\w*)?)*)\s*$/
3348 ],
3349 description: "Import statements. Syntax: import module[, module2]\n or: from pkg.mod import name[, name2]",
3350 args: ">=1",
3351 },
3352 // Conditional blocks
3353 IF: {
3354 patterns: [/^\s*if\s+.+\s*:$/],
3355 description: "If statement. Syntax: if condition:",
3356 args: ">=1",
3357 blockStart: true,
3358 },
3359 ELIF: {
3360 patterns: [/^\s*elif\s+.+\s*:$/],
3361 description: "Elif statement. Syntax: elif condition:",
3362 args: ">=1",
3363 },
3364 ELSE: {
3365 patterns: [/^\s*else\s*:$/],
3366 description: "Else statement. Syntax: else:",
3367 args: 0,
3368 },
3369 // Loop blocks
3370 FOR: {
3371 patterns: [/^\s*for\s+[A-Za-z_]\w*\s+in\s+.+\s*:$/],
3372 description: "For loop. Syntax: for var in iterable:",
3373 args: ">=1",
3374 //blockStart: true,
3375 },
3376 WHILE: {
3377 patterns: [/^\s*while\s+.+\s*:$/],
3378 description: "While loop. Syntax: while condition:",
3379 args: ">=1",
3380 //blockStart: true,
3381 },
3382 // Try/Except/Finally
3383 TRY: {
3384 patterns: [/^\s*try\s*:$/],
3385 description: "Try block. Syntax: try:",
3386 args: 0,
3387 pythonBlockStart: "TRY",
3388 },
3389 EXCEPT: {
3390 patterns: [/^\s*except(?:\s+.+?)?(?:\s+as\s+[A-Za-z_]\w*)?\s*:$/],
3391 description: "Except block. Syntax: except ExceptionType as name:",
3392 args: ">=0",
3393 pythonBlockContinuation: "TRY",
3394 },
3395 FINALLY: {
3396 patterns: [/^\s*finally\s*:$/],
3397 description: "Finally block. Syntax: finally:",
3398 args: 0,
3399 pythonBlockContinuation: "TRY",
3400 },
3401 // With context manager
3402 WITH: {
3403 patterns: [/^\s*with\s+.+\s+as\s+[A-Za-z_]\w*\s*:$/],
3404 description: "With statement. Syntax: with expr as var:",
3405 args: ">=1",
3406 //blockStart: true,
3407 },
3408 // Simple statements
3409 RETURN: {
3410 patterns: [
3411 /^\s*return(?:\s+.+)?$/
3412 ],
3413 description: "Return statement. Syntax: return [expr]",
3414 args: ">=0",
3415 },
3416 PASS: {
3417 patterns: [/^\s*pass\s*$/],
3418 description: "Pass statement. Does nothing.",
3419 args: 0,
3420 },
3421 BREAK: {
3422 patterns: [/^\s*break\s*$/],
3423 description: "Break out of the nearest loop.",
3424 args: 0,
3425 },
3426 CONTINUE: {
3427 patterns: [/^\s*continue\s*$/],
3428 description: "Continue to next iteration of the nearest loop.",
3429 args: 0,
3430 },
3431 RAISE: {
3432 patterns: [/^\s*raise(?:\s+[A-Za-z_]\w*(?:\s*\‍(.*\‍))?)?(?:\s+from\s+[A-Za-z_]\w*(?:\s*\‍(.*\‍))?)?\s*$/],
3433 description: "Raise an exception. Syntax: raise ExceptionType(args) [from other]",
3434 args: ">=0",
3435 },
3436 ASSERT: {
3437 patterns: [/^\s*assert\s+.+$/],
3438 description: "Assert statement. Syntax: assert condition[, message]",
3439 args: ">=1",
3440 },
3441 // Decorators
3442 DECORATOR: {
3443 patterns: [/^\s*@\w+(?:\.\w+)*(?:\s*\‍(.*\‍))?$/],
3444 description: "Decorator. Syntax: @decorator or @decorator(args)",
3445 args: ">=0",
3446 },
3447 // Assignment (simple)
3448 ASSIGNMENT: {
3449 patterns: [/^\s*[A-Za-z_]\w*\s*=.*$/],
3450 description: "Variable assignment. Syntax: name = expression",
3451 args: ">=1",
3452 },
3453 // Bare function or method call, possibly with args
3454 CALL_EXPR: {
3455 patterns: [/^\s*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*\‍(.*\‍)\s*$/],
3456 description: "Expression statement (e.g. function or method call).",
3457 args: ">=1"
3458 },
3459};
3460
3461// Validation function to validate script content
3462function checkEditorContent(e, commands) {
3463 // If not provided take appropriate default values
3464 if (commands === undefined) {
3465 commands = (SeqODB === "/PySequencer") ? pythonCommands : mslCommands;
3466 }
3467 e = e ? e : event.target;
3468 // Return immediately with true if auto validation is disabled
3469 if ((e.id !== "vldBtn") && (document.getElementById("autoValidate").checked === false)) return true;
3470
3471 // Get the content from the current editor
3472 let [lineNumbers,editor] = editorElements();
3473 const editorContent = editor.innerText;
3474
3475 // Validate the script content
3476 const errors = validateScript(editorContent,commands);
3477 if (errors.length > 0) {
3478 // Format and display errors
3479 let vldErrors = errors.map(err => {
3480 if (typeof err === "string") {
3481 return err; // Handle string errors directly
3482 } else if (err.line && err.message) {
3483 return `Line ${err.line}: ${err.message}`; // Format object errors
3484 } else {
3485 return "Unknown error"; // Fallback for unexpected error formats
3486 }
3487 }).join("\n");
3488 // highlight error lines and scroll to first one
3489 hlLine(errors.map(error => error.line),"var(--mred)",editor,errors.map(error => error.message));
3490 setCurrentCursorPosition(editor, errors[0].line - 1 + editorContent.split("\n").slice(0,errors[0].line).reduce((sum, line) => sum + line.length, 0));
3491 editor.focus();
3492
3493 let errorHTML = "<div style='text-align: left;'>" + vldErrors.replaceAll("\n","<br>") + "</div>";
3494 // Show the error message for 5 seconds
3495 //let d = dlgWait(5,errorHTML);
3496 // Show the error message
3497 let d = dlgAlert(errorHTML);
3498 d.querySelector('.dlgTitlebar').innerText = "Validation Errors";
3499 console.error("Validation errors:\n",vldErrors);
3500 if (e.id === "vldBtn") e.src = "icons/validate-syntax-red.svg";
3501 if (e.classList.contains("seqbtn")) e.style.backgroundColor = "var(--mred)";
3502 return false;
3503 } else {
3504 //dlgAlert("The syntax of the sequence is valid.");
3505 if (e.id === "vldBtn") {
3506 e.src = "icons/validate-syntax-green.svg";
3507 e.style.backgroundColor = "var(--mgreen)";
3508 } else {
3509 document.querySelectorAll('.seqbtn').forEach(btn => {
3510 btn.style.backgroundColor = "";
3511 });
3512 }
3513 return true;
3514 }
3515}
3516
3517// Function to validate the script against commands object
3518function validateScript(script, commands) {
3519 const errors = [];
3520 const originalScript = script;
3521
3522 // Stack to track nested blocks
3523 const blockStack = [];
3524 const lineNums = [];
3525 // Stack to track delimiters
3526 const delimiterStack = [];
3527
3528 // Delimiters and their matching pairs
3529 const delimiters = {
3530 '{': '}',
3531 '[': ']',
3532 '(': ')',
3533 '"': '"',
3534 "'": "'",
3535 };
3536
3537 // Remove multiline comments when the active language defines them.
3538 const multiCommRegex = commands.MULTICOMM?.patterns?.[0];
3539 if (multiCommRegex) {
3540
3541 // replace each triple-quote block with the same count of blank lines
3542 script = script.replace(
3543 new RegExp(multiCommRegex.source, "g"),
3544 (match) => {
3545 // count the lines in the matched block
3546 const lines = match.split("\n");
3547 // return that many lines, each a single space
3548 return lines.map(() => " ").join("\n");
3549 }
3550 );
3551 }
3552
3553 // Regex to match and replace all comments by space
3554 const commentPatterns = commands.COMMENT?.patterns || [];
3555 if (commentPatterns.length > 0) {
3556 const commentRegex = new RegExp(commentPatterns.map(pattern => pattern.source).join("|"), "gim");
3557 script = script.replace(commentRegex, "");
3558 }
3559 const lines = script.split("\n");
3560
3561 lines.forEach((line, index) => {
3562 const trimmedLine = line.trim();
3563 if (!trimmedLine) return; // Skip empty lines
3564
3565 // Check if the line matches any command pattern.
3566 const cmdKey = Object.keys(commands).find(cmd => {
3567 const cmdPattern = commands[cmd].patterns || null;
3568 return Array.isArray(cmdPattern) && cmdPattern.some(pattern => regexTest(pattern, trimmedLine));
3569 });
3570
3571 if (cmdKey) {
3572 const cmdData = commands[cmdKey];
3573 // Handle block start commands
3574 if (commands !== pythonCommands && cmdData.blockStart) {
3575 blockStack.push(cmdKey); // Push the block type to the stack
3576 lineNums.push(index + 1);
3577 }
3578
3579 // Handle block end commands
3580 if (commands !== pythonCommands && cmdData.blockEnd) {
3581 const expectedBlock = cmdData.blockEnd;
3582 const lastBlock = blockStack.pop();
3583 lineNums.pop();
3584
3585 if (lastBlock !== expectedBlock) {
3586 errors.push({ line: index + 1, message: `"${cmdKey}" without matching "${expectedBlock}".` });
3587 }
3588 }
3589
3590 // Validate arguments if argcond is defined
3591 if (cmdData.argcond) {
3592 // Remove the command keyword from the line and split remaining arguments by comma
3593 const args = splitArguments(trimmedLine.replace(new RegExp(`^${cmdKey}`, "i"), "").trim());
3594 for (let i = 0; i < args.length; i++) {
3595 const arg = args[i].trim().replace(/^"(.*)"$/, "$1"); // Remove surrounding quotes
3596 const argCond = cmdData.argcond[i]; // Get the condition for this argument
3597 if (argCond && !regexTest(argCond, arg)) {
3598 errors.push({ line: index + 1, message: `Argument ${i + 1} of "${cmdKey}" is invalid.` });
3599 }
3600 }
3601 }
3602
3603 return;
3604 }
3605
3606 const likelyCmd = findLikelyCommand(trimmedLine, commands);
3607 if (likelyCmd) {
3608 const description = commands[likelyCmd].description || "Check the command syntax.";
3609 errors.push({ line: index + 1, message: `Invalid syntax for "${likelyCmd}". ${description}` });
3610 } else {
3611 const unknownCmd = extractCommandName(trimmedLine);
3612 const suggestedCmd = suggestCommandName(unknownCmd, commands);
3613 const suggestion = suggestedCmd ? ` Did you mean "${suggestedCmd}"?` : "";
3614 errors.push({ line: index + 1, message: `Unknown command "${unknownCmd}".${suggestion}` });
3615 }
3616
3617 // Delimiter validation
3618 for (let i = 0; i < trimmedLine.length; i++) {
3619 const char = trimmedLine[i];
3620 if (delimiters[char]) {
3621 // Opening delimiter
3622 delimiterStack.push({ char, line: index + 1, pos: i + 1 });
3623 } else if (Object.values(delimiters).includes(char)) {
3624 // Closing delimiter
3625 if (delimiterStack.length === 0) {
3626 errors.push({ line: index + 1, message: `Mismatched "${char}" at line ${index + 1}, column ${i + 1}.` });
3627 } else {
3628 const lastDelimiter = delimiterStack.pop();
3629 if (delimiters[lastDelimiter.char] !== char) {
3630 errors.push({ line: index + 1, message: `Mismatched "${char}" at line ${index + 1}, column ${i + 1}. Expected "${delimiters[lastDelimiter.char]}".` });
3631 }
3632 }
3633 }
3634 }
3635 });
3636
3637 // Check for unclosed blocks at the end of the script
3638 if (blockStack.length > 0) {
3639 errors.push({ line: lineNums[0], message: `Unclosed "${blockStack[0]}" block starting at line ${lineNums[0]}.` });
3640 }
3641
3642 // Check for unclosed delimiters at the end of the script
3643 if (delimiterStack.length > 0) {
3644 errors.push({ line: delimiterStack[0].line, message: `Unclosed "${delimiterStack[0].char}" delimiter starting at line ${delimiterStack[0].line}, column ${delimiterStack[0].pos}.` });
3645 }
3646
3647 errors.push(...validateIndentation(originalScript, commands));
3648
3649 return errors;
3650}
3651
3652function regexTest(regex, text) {
3653 regex.lastIndex = 0;
3654 return regex.test(text);
3655}
3656
3657// Return only real syntax entries, excluding comment metadata entries.
3658function commandKeys(commands) {
3659 return Object.keys(commands).filter(cmd => cmd !== "COMMENT" && cmd !== "MULTICOMM");
3660}
3661
3662// Extract the first command-like token from a line for diagnostics.
3663function extractCommandName(line) {
3664 const commandMatch = line.match(/^\s*([A-Za-z_]\w*)/);
3665 if (commandMatch) {
3666 return commandMatch[1];
3667 }
3668 return line.trim().split(/\s+/)[0] || line.trim().slice(0, 20) || "<empty>";
3669}
3670
3671// Find the command-table entry that probably owns this invalid line.
3672function findLikelyCommand(line, commands) {
3673 const firstToken = extractCommandName(line);
3674 const exactCmd = commandKeys(commands).find(cmd => cmd.toLowerCase() === firstToken.toLowerCase());
3675 if (exactCmd) {
3676 return exactCmd;
3677 }
3678
3679 // Some language constructs do not use the command object key as their first
3680 // token, but they still have a clear command-table entry.
3681 if (/^\s*from\b/i.test(line) && commands.IMPORT) {
3682 return "IMPORT";
3683 }
3684 if (/^\s*@/.test(line) && commands.DECORATOR) {
3685 return "DECORATOR";
3686 }
3687 if (/^\s*[A-Za-z_]\w*\s*=/.test(line)) {
3688 if (commands.ASSIGNMENT) {
3689 return "ASSIGNMENT";
3690 }
3691 if (commands.EQUATION) {
3692 return "EQUATION";
3693 }
3694 }
3695 if (/^\s*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*\‍(/.test(line) && commands.CALL_EXPR) {
3696 return "CALL_EXPR";
3697 }
3698
3699 return null;
3700}
3701
3702// Suggest a valid command name for likely typos in unknown commands.
3703function suggestCommandName(commandName, commands) {
3704 if (!commandName || commandName === "<empty>") {
3705 return "";
3706 }
3707
3708 let bestCmd = "";
3709 let bestDistance = Infinity;
3710 commandKeys(commands).forEach(cmd => {
3711 const distance = levenshteinDistance(commandName.toLowerCase(), cmd.toLowerCase());
3712 if (distance < bestDistance) {
3713 bestDistance = distance;
3714 bestCmd = cmd;
3715 }
3716 });
3717
3718 const maxDistance = Math.max(2, Math.floor(Math.max(commandName.length, bestCmd.length) * 0.35));
3719 return bestDistance <= maxDistance ? bestCmd : "";
3720}
3721
3722// Compute edit distance so diagnostics can suggest nearby command names.
3723function levenshteinDistance(a, b) {
3724 const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
3725 for (let i = 0; i <= a.length; i++) {
3726 dp[i][0] = i;
3727 }
3728 for (let j = 0; j <= b.length; j++) {
3729 dp[0][j] = j;
3730 }
3731
3732 for (let i = 1; i <= a.length; i++) {
3733 for (let j = 1; j <= b.length; j++) {
3734 const cost = a[i - 1] === b[j - 1] ? 0 : 1;
3735 dp[i][j] = Math.min(
3736 dp[i - 1][j] + 1,
3737 dp[i][j - 1] + 1,
3738 dp[i - 1][j - 1] + cost
3739 );
3740 }
3741 }
3742
3743 return dp[a.length][b.length];
3744}
3745
3746// Run language-specific indentation checks after regex syntax validation.
3747function validateIndentation(script, commands) {
3748 if (commands !== pythonCommands) {
3749 return [];
3750 }
3751
3752 const errors = [];
3753 const indentStack = [0];
3754 const pythonBlockStack = [];
3755 const lines = stripMultilineCommentsPreserveLines(script, commands).split("\n");
3756 let previousCodeLine = null;
3757 let continuationDepth = 0;
3758 let previousLineContinues = false;
3759
3760 lines.forEach((line, index) => {
3761 const lineNo = index + 1;
3762 const trimmedLine = line.trim();
3763 const inContinuation = continuationDepth > 0 || previousLineContinues;
3764
3765 if (!trimmedLine || trimmedLine.startsWith("#")) {
3766 return;
3767 }
3768
3769 const leadingWhitespace = line.match(/^\s*/)[0];
3770 if (leadingWhitespace.includes("\t")) {
3771 errors.push({ line: lineNo, message: "Python indentation uses tabs. Use spaces instead." });
3772 }
3773
3774 const indent = leadingWhitespace.replace(/\t/g, " ").length;
3775 const cmdKey = Object.keys(commands).find(cmd => {
3776 const cmdPattern = commands[cmd].patterns || null;
3777 return Array.isArray(cmdPattern) && cmdPattern.some(pattern => regexTest(pattern, trimmedLine));
3778 });
3779 const cmdData = cmdKey ? commands[cmdKey] : null;
3780
3781 if (!inContinuation) {
3782 if (indent % 4 !== 0) {
3783 errors.push({ line: lineNo, message: "Python indentation should use multiples of 4 spaces." });
3784 }
3785
3786 if (previousCodeLine?.opensBlock && indent <= previousCodeLine.indent) {
3787 errors.push({ line: lineNo, message: `Expected an indented block after line ${previousCodeLine.line}.` });
3788 }
3789
3790 while (indent < indentStack[indentStack.length - 1]) {
3791 indentStack.pop();
3792 }
3793
3794 const tryBlockAtThisIndent = pythonBlockStack
3795 .slice()
3796 .reverse()
3797 .find(block => block.type === "TRY" && block.indent === indent);
3798 const isTryContinuation = cmdData?.pythonBlockContinuation === "TRY" ||
3799 (cmdKey === "ELSE" && tryBlockAtThisIndent);
3800
3801 while (pythonBlockStack.length > 0) {
3802 const block = pythonBlockStack[pythonBlockStack.length - 1];
3803
3804 if (indent < block.indent) {
3805 pythonBlockStack.pop();
3806 if (block.type === "TRY" && !block.hasContinuation) {
3807 errors.push({ line: block.line, message: '"try" without matching "except" or "finally".' });
3808 }
3809 continue;
3810 }
3811
3812 if (indent === block.indent && !isTryContinuation) {
3813 pythonBlockStack.pop();
3814 if (block.type === "TRY" && !block.hasContinuation) {
3815 errors.push({ line: block.line, message: '"try" without matching "except" or "finally".' });
3816 }
3817 continue;
3818 }
3819
3820 break;
3821 }
3822
3823 if (indent > indentStack[indentStack.length - 1]) {
3824 if (!previousCodeLine?.opensBlock) {
3825 errors.push({ line: lineNo, message: "Unexpected indent." });
3826 }
3827 indentStack.push(indent);
3828 }
3829
3830 if (!indentStack.includes(indent)) {
3831 errors.push({ line: lineNo, message: "Indentation does not match any previous block level." });
3832 }
3833
3834 if (/^(elif|else|except|finally)\b/.test(trimmedLine) && previousCodeLine?.opensBlock) {
3835 errors.push({ line: lineNo, message: `"${trimmedLine.split(/\s+/)[0]}" cannot directly follow an unclosed block header.` });
3836 }
3837
3838 if (isTryContinuation) {
3839 const matchingBlock = pythonBlockStack
3840 .slice()
3841 .reverse()
3842 .find(block => block.type === "TRY" && block.indent === indent);
3843
3844 if (!matchingBlock) {
3845 errors.push({ line: lineNo, message: `"${cmdKey.toLowerCase()}" without matching "try" at the same indentation level.` });
3846 } else if (cmdKey === "EXCEPT") {
3847 if (matchingBlock.seenFinally) {
3848 errors.push({ line: lineNo, message: '"except" cannot follow "finally" for the same "try" block.' });
3849 } else if (matchingBlock.seenElse) {
3850 errors.push({ line: lineNo, message: '"except" cannot follow "else" for the same "try" block.' });
3851 }
3852 matchingBlock.hasContinuation = true;
3853 matchingBlock.seenExcept = true;
3854 } else if (cmdKey === "FINALLY") {
3855 if (matchingBlock.seenFinally) {
3856 errors.push({ line: lineNo, message: 'Duplicate "finally" for the same "try" block.' });
3857 }
3858 matchingBlock.hasContinuation = true;
3859 matchingBlock.seenFinally = true;
3860 } else if (cmdKey === "ELSE") {
3861 if (!matchingBlock.seenExcept) {
3862 errors.push({ line: lineNo, message: '"else" in a "try" block requires at least one "except" before it.' });
3863 } else if (matchingBlock.seenElse) {
3864 errors.push({ line: lineNo, message: 'Duplicate "else" for the same "try" block.' });
3865 } else if (matchingBlock.seenFinally) {
3866 errors.push({ line: lineNo, message: '"else" cannot follow "finally" for the same "try" block.' });
3867 } else {
3868 matchingBlock.seenElse = true;
3869 }
3870 }
3871 }
3872
3873 if (cmdData?.pythonBlockStart) {
3874 pythonBlockStack.push({
3875 type: cmdData.pythonBlockStart,
3876 indent: indent,
3877 line: lineNo,
3878 hasContinuation: false,
3879 seenExcept: false,
3880 seenElse: false,
3881 seenFinally: false,
3882 });
3883 }
3884
3885 previousCodeLine = {
3886 line: lineNo,
3887 indent: indent,
3888 opensBlock: /:\s*(#.*)?$/.test(trimmedLine),
3889 };
3890 }
3891
3892 continuationDepth = Math.max(0, continuationDepth + pythonBracketDelta(line));
3893 previousLineContinues = /\\\s*(#.*)?$/.test(line);
3894 });
3895
3896 pythonBlockStack.forEach(block => {
3897 if (block.type === "TRY" && !block.hasContinuation) {
3898 errors.push({ line: block.line, message: '"try" without matching "except" or "finally".' });
3899 }
3900 });
3901
3902 return errors;
3903}
3904
3905// Preserve line numbers while removing Python triple-quoted comment blocks.
3906function stripMultilineCommentsPreserveLines(script, commands) {
3907 const multiCommRegex = commands.MULTICOMM?.patterns?.[0];
3908 if (!multiCommRegex) {
3909 return script;
3910 }
3911
3912 return script.replace(new RegExp(multiCommRegex.source, "g"), (match) => {
3913 return match.split("\n").map(() => " ").join("\n");
3914 });
3915}
3916
3917// Count open/closed grouping delimiters outside strings and inline comments.
3918function pythonBracketDelta(line) {
3919 let delta = 0;
3920 let quote = "";
3921 let escaped = false;
3922
3923 for (let i = 0; i < line.length; i++) {
3924 const char = line[i];
3925
3926 if (escaped) {
3927 escaped = false;
3928 continue;
3929 }
3930 if (char === "\\") {
3931 escaped = true;
3932 continue;
3933 }
3934 if (quote) {
3935 if (char === quote) {
3936 quote = "";
3937 }
3938 continue;
3939 }
3940 if (char === "#" ) {
3941 break;
3942 }
3943 if (char === '"' || char === "'") {
3944 quote = char;
3945 continue;
3946 }
3947 if (char === "(" || char === "[" || char === "{") {
3948 delta++;
3949 } else if (char === ")" || char === "]" || char === "}") {
3950 delta--;
3951 }
3952 }
3953
3954 return delta;
3955}
3956
3957// Helper function to split arguments with quoted strings by commas
3958function splitArguments(input) {
3959 // Regex to match quoted strings
3960 const regex = /(["'])(.*?)\1/g;
3961 const args = [];
3962 let lastIndex = 0;
3963
3964 // Step 1: Find all quoted strings and split the input around them
3965 let match;
3966 while ((match = regex.exec(input)) !== null) {
3967 // Add unquoted arguments before the current quoted string
3968 const beforeQuoted = input.slice(lastIndex, match.index).trim();
3969 if (beforeQuoted) {
3970 args.push(...beforeQuoted.split(",").map(arg => arg.trim()).filter(arg => arg.length > 0));
3971 }
3972
3973 // Add the quoted argument
3974 args.push(match[2]); // Capture the content inside the quotes
3975
3976 // Update lastIndex to the end of the current match
3977 lastIndex = regex.lastIndex;
3978 }
3979
3980 // Step 2: Add any remaining unquoted arguments after the last quoted string
3981 const remaining = input.slice(lastIndex).trim();
3982 if (remaining) {
3983 args.push(...remaining.split(",").map(arg => arg.trim()).filter(arg => arg.length > 0));
3984 }
3985
3986 return args;
3987}