All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
309 lines
9.1 KiB
JavaScript
309 lines
9.1 KiB
JavaScript
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
|
|
export function valueAt(context, path) {
|
|
return String(path)
|
|
.split(".")
|
|
.reduce((value, key) => {
|
|
if (forbidden.has(key) || value == null) return undefined;
|
|
if (typeof value === "string" && key) {
|
|
try {
|
|
value = JSON.parse(value);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
return Object.hasOwn(value, key) ? value[key] : undefined;
|
|
}, context);
|
|
}
|
|
const number = (value) => {
|
|
if (
|
|
!["number", "string"].includes(typeof value) ||
|
|
String(value).trim() === "" ||
|
|
!Number.isFinite(Number(value))
|
|
)
|
|
throw new Error("O cálculo precisa de um valor numérico.");
|
|
return Number(value);
|
|
};
|
|
const functions = {
|
|
round: (v) => Math.round(number(v)),
|
|
floor: (v) => Math.floor(number(v)),
|
|
ceil: (v) => Math.ceil(number(v)),
|
|
abs: (v) => Math.abs(number(v)),
|
|
min: (...v) => Math.min(...v.map(number)),
|
|
max: (...v) => Math.max(...v.map(number)),
|
|
lower: (v) => String(v ?? "").toLowerCase(),
|
|
upper: (v) => String(v ?? "").toUpperCase(),
|
|
length: (v) => v?.length ?? 0,
|
|
contains: (a, b) => String(a ?? "").includes(String(b ?? "")),
|
|
startsWith: (a, b) => String(a ?? "").startsWith(String(b ?? "")),
|
|
endsWith: (a, b) => String(a ?? "").endsWith(String(b ?? "")),
|
|
};
|
|
// A small expression parser: no eval, property calls, assignments or JavaScript execution.
|
|
export function evaluate(expression, context = {}) {
|
|
const source = String(expression).trim();
|
|
if (source.length > 2000) throw new Error("Expressão demasiado longa.");
|
|
const tokens = [];
|
|
let offset = 0;
|
|
while (offset < source.length) {
|
|
const match =
|
|
/^(\s+|(?:\d+(?:\.\d*)?|\.\d+)|[A-Za-z_][\w]*(?:\.[\w]+)*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|===|!==|==|!=|>=|<=|&&|\|\||[+*/%(),!<>-])/.exec(
|
|
source.slice(offset),
|
|
);
|
|
if (!match) throw new Error("Expressão inválida.");
|
|
offset += match[0].length;
|
|
if (!/^\s+$/.test(match[0])) tokens.push(match[0]);
|
|
}
|
|
if (tokens.length > 250) throw new Error("Expressão demasiado complexa.");
|
|
let i = 0,
|
|
depth = 0;
|
|
const precedence = {
|
|
"||": 1,
|
|
"&&": 2,
|
|
"==": 3,
|
|
"!=": 3,
|
|
"===": 3,
|
|
"!==": 3,
|
|
">": 4,
|
|
"<": 4,
|
|
">=": 4,
|
|
"<=": 4,
|
|
"+": 5,
|
|
"-": 5,
|
|
"*": 6,
|
|
"/": 6,
|
|
"%": 6,
|
|
};
|
|
const binary = (a, op, b) => {
|
|
switch (op) {
|
|
case "+":
|
|
return number(a) + number(b);
|
|
case "-":
|
|
return number(a) - number(b);
|
|
case "*":
|
|
return number(a) * number(b);
|
|
case "/":
|
|
if (number(b) === 0) throw new Error("Divisão por zero.");
|
|
return number(a) / number(b);
|
|
case "%":
|
|
if (number(b) === 0) throw new Error("Divisão por zero.");
|
|
return number(a) % number(b);
|
|
case "==":
|
|
case "===":
|
|
return String(a) === String(b);
|
|
case "!=":
|
|
case "!==":
|
|
return String(a) !== String(b);
|
|
case ">":
|
|
return number(a) > number(b);
|
|
case "<":
|
|
return number(a) < number(b);
|
|
case ">=":
|
|
return number(a) >= number(b);
|
|
case "<=":
|
|
return number(a) <= number(b);
|
|
case "&&":
|
|
return Boolean(a) && Boolean(b);
|
|
case "||":
|
|
return Boolean(a) || Boolean(b);
|
|
}
|
|
};
|
|
function primary() {
|
|
if (++depth > 40) throw new Error("Expressão demasiado complexa.");
|
|
try {
|
|
const t = tokens[i++];
|
|
if (!t) throw new Error("Expressão incompleta.");
|
|
if (t === "!" || t === "-" || t === "+") {
|
|
const get = primary();
|
|
return () =>
|
|
t === "!" ? !get() : t === "-" ? -number(get()) : number(get());
|
|
}
|
|
if (t === "(") {
|
|
const get = parse(1);
|
|
if (tokens[i++] !== ")") throw new Error("Falta fechar parênteses.");
|
|
return get;
|
|
}
|
|
if (/^\d|^\.\d/.test(t)) return () => Number(t);
|
|
if (t[0] === '"') {
|
|
const value = JSON.parse(t);
|
|
return () => value;
|
|
}
|
|
if (t[0] === "'") {
|
|
const value = t.slice(1, -1).replace(/\\(['\\])/g, "$1");
|
|
return () => value;
|
|
}
|
|
if (t === "true") return () => true;
|
|
if (t === "false") return () => false;
|
|
if (tokens[i] === "(") {
|
|
if (!Object.hasOwn(functions, t))
|
|
throw new Error("Função não suportada.");
|
|
i++;
|
|
const args = [];
|
|
if (tokens[i] !== ")") {
|
|
do {
|
|
args.push(parse(1));
|
|
if (tokens[i] !== ",") break;
|
|
i++;
|
|
} while (true);
|
|
}
|
|
if (tokens[i++] !== ")") throw new Error("Falta fechar função.");
|
|
return () => functions[t](...args.map((get) => get()));
|
|
}
|
|
return () => valueAt(context, t);
|
|
} finally {
|
|
depth--;
|
|
}
|
|
}
|
|
function parse(min) {
|
|
let left = primary();
|
|
while ((precedence[tokens[i]] || 0) >= min) {
|
|
const before = left,
|
|
op = tokens[i++],
|
|
right = parse(precedence[op] + 1);
|
|
left =
|
|
op === "&&"
|
|
? () => Boolean(before()) && Boolean(right())
|
|
: op === "||"
|
|
? () => Boolean(before()) || Boolean(right())
|
|
: () => binary(before(), op, right());
|
|
}
|
|
return left;
|
|
}
|
|
if (!tokens.length) return "";
|
|
const result = parse(1);
|
|
if (i !== tokens.length) throw new Error("Expressão inválida.");
|
|
return result();
|
|
}
|
|
export function timeValues(now = new Date(), timezone = "Europe/Lisbon") {
|
|
const parts = Object.fromEntries(
|
|
new Intl.DateTimeFormat("en-GB", {
|
|
timeZone: timezone,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hourCycle: "h23",
|
|
weekday: "short",
|
|
})
|
|
.formatToParts(now)
|
|
.map((p) => [p.type, p.value]),
|
|
);
|
|
return {
|
|
time: `${parts.hour}:${parts.minute}`,
|
|
seconds: parts.second,
|
|
hours: parts.hour,
|
|
minutes: parts.minute,
|
|
date: `${parts.day}/${parts.month}/${parts.year}`,
|
|
timestamp: Math.floor(now.getTime() / 1000),
|
|
day: parts.day,
|
|
month: parts.month,
|
|
year: parts.year,
|
|
dayOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(
|
|
parts.weekday,
|
|
),
|
|
};
|
|
}
|
|
const display = (value) =>
|
|
value == null
|
|
? ""
|
|
: typeof value === "object"
|
|
? JSON.stringify(value)
|
|
: String(value);
|
|
function splitFilters(raw) {
|
|
const parts = [];
|
|
let start = 0,
|
|
quote = "",
|
|
escaped = false;
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const c = raw[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (c === "\\" && quote) {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (c === quote) quote = "";
|
|
continue;
|
|
}
|
|
if (c === '"' || c === "'") {
|
|
quote = c;
|
|
continue;
|
|
}
|
|
if (c === "|" && raw[i - 1] !== "|" && raw[i + 1] !== "|") {
|
|
parts.push(raw.slice(start, i).trim());
|
|
start = i + 1;
|
|
}
|
|
}
|
|
parts.push(raw.slice(start).trim());
|
|
return parts;
|
|
}
|
|
export function interpolate(text, context) {
|
|
return String(text ?? "").replace(/\{\{([^{}]+)\}\}/g, (_, raw) => {
|
|
const parts = splitFilters(raw);
|
|
const path = parts.shift();
|
|
let value = path === "time" ? context.time : valueAt(context, path);
|
|
if (value === undefined) {
|
|
try {
|
|
value = evaluate(path, {
|
|
...context,
|
|
time: context.clock || context.time,
|
|
});
|
|
} catch (error) {
|
|
if (/^[\w.]+$/.test(path)) return "";
|
|
throw error;
|
|
}
|
|
}
|
|
for (const filter of parts) {
|
|
if (filter === "json") value = JSON.stringify(value);
|
|
else if (filter === "uptime") {
|
|
const seconds = Math.max(0, number(value));
|
|
value = `${Math.floor(seconds / 86400)}d ${Math.floor(seconds / 3600) % 24}h ${Math.floor(seconds / 60) % 60}m`;
|
|
} else if (Object.hasOwn(functions, filter))
|
|
value = functions[filter](value);
|
|
else throw new Error(`Filtro desconhecido: ${filter}`);
|
|
}
|
|
return display(value);
|
|
});
|
|
}
|
|
export function resolveObject(object, context) {
|
|
if (typeof object === "string") return interpolate(object, context);
|
|
if (Array.isArray(object))
|
|
return object.map((v) => resolveObject(v, context));
|
|
if (object && typeof object === "object")
|
|
return Object.fromEntries(
|
|
Object.entries(object)
|
|
.filter(([key]) => !forbidden.has(key))
|
|
.map(([k, v]) => [k, resolveObject(v, context)]),
|
|
);
|
|
return object;
|
|
}
|
|
export function compare(value, operator, other) {
|
|
switch (operator) {
|
|
case "greater":
|
|
return number(value) > number(other);
|
|
case "less":
|
|
return number(value) < number(other);
|
|
case "atLeast":
|
|
return number(value) >= number(other);
|
|
case "atMost":
|
|
return number(value) <= number(other);
|
|
case "contains":
|
|
return String(value ?? "").includes(String(other));
|
|
case "startsWith":
|
|
return String(value ?? "").startsWith(String(other));
|
|
case "endsWith":
|
|
return String(value ?? "").endsWith(String(other));
|
|
case "exists":
|
|
return value !== undefined && value !== null && value !== "";
|
|
case "notEquals":
|
|
return String(value) !== String(other);
|
|
case "equals":
|
|
return String(value) === String(other);
|
|
default:
|
|
throw new Error("Comparação inválida.");
|
|
}
|
|
}
|