Integrate scenario sanitization in WebSocketManager and replace textarea with ScenarioEditor component in Config.svelte for improved scenario handling.

This commit is contained in:
DmitryBorisenko33
2026-02-09 00:38:54 +01:00
parent 886c15f28a
commit 64496cf038
4 changed files with 352 additions and 2 deletions

View File

@@ -0,0 +1,325 @@
<script>
import { sanitizeScenario } from "../lib/scenarioUtils.js";
/**
* Scenario editor with syntax highlighting for IoTManager scenario language.
* Uses a mirror div behind a transparent textarea so we don't need CodeMirror.
* Paste is sanitized so control chars and wrong line endings don't enter (IoTScenario compatibility).
*/
export let value = "";
export let rows = 10;
export let placeholder = "";
export let disabled = false;
/** Optional CSS class for the wrapper */
export let className = "";
const baseClass =
"px-2 py-1.5 bg-gray-50 border-2 border-gray-200 rounded text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-indigo-500 w-full font-mono text-sm resize-none";
// Keywords and built-ins for IoTManager scenario language
const KEYWORDS = new Set([
"if",
"then",
"else",
"and",
"or",
"not",
]);
const FUNCTIONS = new Set([
"getIP",
"getUptime",
"getHours",
"getMinutes",
"getSeconds",
"sendMsg",
"createItemFromNet",
]);
function escapeHtml(s) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function tokenizeLine(line) {
const tokens = [];
let i = 0;
const n = line.length;
while (i < n) {
// Comment from # to EOL
if (line[i] === "#") {
const start = i;
while (i < n) i++;
tokens.push({ type: "comment", text: line.slice(start, i) });
continue;
}
// Double-quoted string
if (line[i] === '"') {
const start = i;
i++;
while (i < n && line[i] !== '"') {
if (line[i] === "\\") i++;
i++;
}
if (i < n) i++;
tokens.push({ type: "string", text: line.slice(start, i) });
continue;
}
// Number
if (/\d/.test(line[i])) {
const start = i;
while (i < n && /[\d.]/.test(line[i])) i++;
tokens.push({ type: "number", text: line.slice(start, i) });
continue;
}
// Word (identifier / keyword / function)
if (/[a-zA-Z_]\w*/.test(line[i]) || (line[i] === "_" && line[i + 1]?.match(/\w/))) {
const start = i;
while (i < n && /\w/.test(line[i])) i++;
const word = line.slice(start, i);
let type = "identifier";
if (KEYWORDS.has(word)) type = "keyword";
else if (FUNCTIONS.has(word)) type = "function";
tokens.push({ type, text: word });
continue;
}
// Single character (operator, brace, etc.)
tokens.push({ type: "default", text: line[i] });
i++;
}
return tokens;
}
function highlight(text) {
if (text == null) text = "";
const lines = text.split("\n");
const out = [];
for (const line of lines) {
const tokens = tokenizeLine(line);
let lineHtml = "";
for (const t of tokens) {
const esc = escapeHtml(t.text);
lineHtml += `<span class="scn-${t.type}">${esc}</span>`;
}
out.push(lineHtml);
}
return out.join("\n");
}
$: highlightedHtml = highlight(value);
$: containerStyle = `min-height: calc(1.5em * ${rows} + 1rem);`;
$: validation = validateScenario(value);
const INDENT = " "; // 2 spaces per level
/**
* Validate scenario: balanced braces { }, no unclosed strings.
* Returns { valid: boolean, errors: { line: number, message: string }[] }
*/
function validateScenario(text) {
const errors = [];
if (!text || typeof text !== "string") return { valid: true, errors };
const lines = text.split("\n");
const stack = []; // { lineNum } for each unmatched {
for (let lineNum = 1; lineNum <= lines.length; lineNum++) {
const line = lines[lineNum - 1];
let i = 0;
const n = line.length;
while (i < n) {
// Comment: skip to EOL
if (line[i] === "#") break;
// String: skip to closing "
if (line[i] === '"') {
i++;
while (i < n && line[i] !== '"') {
if (line[i] === "\\") i++;
i++;
}
if (i < n) i++;
continue;
}
if (line[i] === "{") {
stack.push(lineNum);
i++;
continue;
}
if (line[i] === "}") {
if (stack.length === 0) {
errors.push({ line: lineNum, message: "Лишняя закрывающая скобка }" });
} else {
stack.pop();
}
i++;
continue;
}
i++;
}
}
while (stack.length > 0) {
const openLine = stack.pop();
errors.push({ line: openLine, message: "Нет закрывающей скобки } для открывающей {" });
}
// Unclosed string: odd number of " outside comments
let quoteCount = 0;
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
const line = lines[lineNum];
for (let i = 0; i < line.length; i++) {
if (line[i] === "#") break;
if (line[i] === '"') quoteCount++;
}
}
if (quoteCount % 2 !== 0) {
errors.push({ line: 1, message: "Незакрытая строка (нечётное число кавычек)" });
}
return { valid: errors.length === 0, errors };
}
/**
* Format scenario: indent by block level (then {, else {, }), trim lines, blank line between logical blocks.
*/
function formatScenario(text) {
if (!text || typeof text !== "string") return text;
const lines = text.split("\n");
const out = [];
let level = 0;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const trimmed = line.trim();
if (trimmed === "") {
out.push("");
continue;
}
// Comment-only: keep as-is with current indent
if (trimmed.startsWith("#")) {
out.push(INDENT.repeat(level) + trimmed);
continue;
}
// Line that starts with }: decrease level then output
if (trimmed.startsWith("}")) {
level = Math.max(0, level - 1);
}
out.push(INDENT.repeat(level) + trimmed);
// Line ends with { (e.g. then {, else {): increase level for next
if (trimmed.endsWith("{") && !trimmed.startsWith("#")) {
level++;
}
}
return out.join("\n");
}
function handleFormat() {
value = formatScenario(value);
}
function handleInput(e) {
value = e.target.value;
}
function handlePaste(e) {
const pasted = (e.clipboardData || window.clipboardData)?.getData("text");
if (pasted == null) return;
const sanitized = sanitizeScenario(pasted);
if (sanitized === pasted) return; // let default paste happen
e.preventDefault();
const ta = e.target;
const start = ta.selectionStart;
const end = ta.selectionEnd;
value = value.slice(0, start) + sanitized + value.slice(end);
const newPos = start + sanitized.length;
setTimeout(() => ta.setSelectionRange(newPos, newPos), 0);
}
</script>
<div
class="scenario-editor relative {className}"
style={containerStyle}
>
<div class="scenario-toolbar flex justify-end mb-1">
<button
type="button"
class="text-sm px-2 py-1 rounded border border-gray-300 bg-gray-100 hover:bg-gray-200 text-gray-700"
on:click={handleFormat}
title="Format scenario (indent blocks)"
>Форматировать</button>
</div>
<div class="scenario-editor-area relative" style={containerStyle}>
<!-- Mirror: highlighted content behind -->
<pre
class="scenario-mirror {baseClass}"
aria-hidden="true"
style="margin:0; overflow:auto; white-space: pre; position:absolute; inset:0;"
>{@html highlightedHtml}</pre>
<!-- Textarea on top: transparent text, caret visible -->
<textarea
class="scenario-input {baseClass}"
style="position:absolute; inset:0; color:transparent; caret-color: #374151; background:transparent; white-space: pre;"
wrap="off"
{rows}
{placeholder}
{disabled}
value={value}
on:input={handleInput}
on:scroll={(e) => {
const mirror = e.target.previousElementSibling;
if (mirror) {
mirror.scrollTop = e.target.scrollTop;
mirror.scrollLeft = e.target.scrollLeft;
}
}}
></textarea>
</div>
{#if validation.errors.length > 0}
<div class="scenario-errors mt-2 px-2 py-1.5 rounded border border-red-300 bg-red-50 text-red-800 text-sm">
<div class="font-semibold mb-1">Ошибки синтаксиса:</div>
<ul class="list-disc list-inside space-y-0.5">
{#each validation.errors as err}
<li>Строка {err.line}: {err.message}</li>
{/each}
</ul>
</div>
{/if}
</div>
<style>
.scenario-editor .scenario-mirror {
pointer-events: none;
min-height: 100%;
}
.scenario-editor .scenario-input {
resize: none;
}
/* IoTManager scenario syntax colors */
:global(.scn-comment) {
color: #a1a1aa;
font-style: italic;
}
:global(.scn-string) {
color: #b45309;
}
:global(.scn-keyword) {
color: #7c3aed;
font-weight: 600;
}
:global(.scn-function) {
color: #0d9488;
}
:global(.scn-number) {
color: #059669;
}
:global(.scn-identifier) {
color: #1e40af;
}
:global(.scn-default) {
color: #374151;
}
</style>

View File

@@ -8,6 +8,7 @@ import * as deviceConnection from "./deviceConnection.js";
import * as deviceListManager from "./deviceListManager.js";
import * as blobProtocol from "./blobProtocol.js";
import * as wsReconnect from "./wsReconnect.js";
import { sanitizeScenario } from "./scenarioUtils.js";
import { eventEmitter } from "../eventEmitter.js";
const LOG_MAX_MESSAGES = 100;
@@ -536,7 +537,7 @@ export default class WebSocketManager {
this.wsSendMsg(this.selectedWs, "/tuoyal|" + JSON.stringify(this.generateLayout()));
this._modify();
this.wsSendMsg(this.selectedWs, "/gifnoc|" + JSON.stringify(this.configJson));
this.wsSendMsg(this.selectedWs, "/oiranecs|" + this.scenarioTxt);
this.wsSendMsg(this.selectedWs, "/oiranecs|" + sanitizeScenario(this.scenarioTxt));
this.clearData();
this.sendCurrentPageNameToSelectedWs();
}

23
src/lib/scenarioUtils.js Normal file
View File

@@ -0,0 +1,23 @@
/**
* Scenario text sanitization for IoTManager backend compatibility.
* Matches IoTScenario.cpp lexer: file is read byte-by-byte; isspace() and ';' are skipped;
* comments # to EOL; strings "..."; identifiers [a-zA-Z_][a-zA-Z0-9_]*; other ASCII as tokens.
* We normalize line endings and remove control chars that would break the parser.
*/
/**
* Sanitize scenario text before sending to device (/oiranecs|).
* - Normalize line endings to LF (\n): \r\n and \r -> \n
* - Remove control characters (0x00-0x1F) except \n (10) and \t (9)
* So no NUL, no other control chars; backend expects printable ASCII + \n \t in code.
* @param {string} text - raw scenario text
* @returns {string} sanitized text safe for IoTScenario parser
*/
export function sanitizeScenario(text) {
if (text == null || typeof text !== "string") return "";
// Normalize line endings to \n only (backend counts curLine on LastChar == 10)
let out = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Remove control chars except \t (9) and \n (10)
out = out.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
return out;
}

View File

@@ -1,5 +1,6 @@
<script>
import Card from "../components/Card.svelte";
import ScenarioEditor from "../components/ScenarioEditor.svelte";
import CrossIcon from "../svg/Cross.svelte";
import OpenIcon from "../svg/Open.svelte";
import Alarm from "../components/Alarm.svelte";
@@ -337,7 +338,7 @@
</Card>
<Card title="Сценарии">
<textarea bind:value={scenarioTxt} rows={height} class="px-2 bg-gray-50 border-2 border-gray-200 rounded text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-indigo-500 w-full" />
<ScenarioEditor bind:value={scenarioTxt} rows={height} />
</Card>
</div>
</div>