mirror of
https://github.com/IoTManagerProject/IoTManagerWeb.git
synced 2026-03-26 15:02:21 +03:00
Integrate scenario sanitization in WebSocketManager and replace textarea with ScenarioEditor component in Config.svelte for improved scenario handling.
This commit is contained in:
325
src/components/ScenarioEditor.svelte
Normal file
325
src/components/ScenarioEditor.svelte
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -8,6 +8,7 @@ import * as deviceConnection from "./deviceConnection.js";
|
|||||||
import * as deviceListManager from "./deviceListManager.js";
|
import * as deviceListManager from "./deviceListManager.js";
|
||||||
import * as blobProtocol from "./blobProtocol.js";
|
import * as blobProtocol from "./blobProtocol.js";
|
||||||
import * as wsReconnect from "./wsReconnect.js";
|
import * as wsReconnect from "./wsReconnect.js";
|
||||||
|
import { sanitizeScenario } from "./scenarioUtils.js";
|
||||||
import { eventEmitter } from "../eventEmitter.js";
|
import { eventEmitter } from "../eventEmitter.js";
|
||||||
|
|
||||||
const LOG_MAX_MESSAGES = 100;
|
const LOG_MAX_MESSAGES = 100;
|
||||||
@@ -536,7 +537,7 @@ export default class WebSocketManager {
|
|||||||
this.wsSendMsg(this.selectedWs, "/tuoyal|" + JSON.stringify(this.generateLayout()));
|
this.wsSendMsg(this.selectedWs, "/tuoyal|" + JSON.stringify(this.generateLayout()));
|
||||||
this._modify();
|
this._modify();
|
||||||
this.wsSendMsg(this.selectedWs, "/gifnoc|" + JSON.stringify(this.configJson));
|
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.clearData();
|
||||||
this.sendCurrentPageNameToSelectedWs();
|
this.sendCurrentPageNameToSelectedWs();
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/lib/scenarioUtils.js
Normal file
23
src/lib/scenarioUtils.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import Card from "../components/Card.svelte";
|
import Card from "../components/Card.svelte";
|
||||||
|
import ScenarioEditor from "../components/ScenarioEditor.svelte";
|
||||||
import CrossIcon from "../svg/Cross.svelte";
|
import CrossIcon from "../svg/Cross.svelte";
|
||||||
import OpenIcon from "../svg/Open.svelte";
|
import OpenIcon from "../svg/Open.svelte";
|
||||||
import Alarm from "../components/Alarm.svelte";
|
import Alarm from "../components/Alarm.svelte";
|
||||||
@@ -337,7 +338,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Сценарии">
|
<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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user