package org.openautonomousconnection.luascript.fx; import javafx.scene.web.WebEngine; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.function.Supplier; /** * Shared utilities for WebEngine JS bridging. */ public final class FxWebBridge { private FxWebBridge() { } /** * Ensures JavaScript is enabled for the engine. * * @param engine engine */ public static void ensureJsEnabled(WebEngine engine) { Objects.requireNonNull(engine, "engine"); if (!engine.isJavaScriptEnabled()) { engine.setJavaScriptEnabled(true); } } /** * Executes bridge code with JavaScript temporarily enabled. * * @param engine engine * @param action action */ public static void runWithJs(WebEngine engine, Runnable action) { Objects.requireNonNull(action, "action"); callWithJs(engine, () -> { action.run(); return null; }); } /** * Executes bridge code with JavaScript temporarily enabled and restores the prior state. * * @param engine engine * @param action action * @param result type * @return action result */ public static T callWithJs(WebEngine engine, Supplier action) { Objects.requireNonNull(engine, "engine"); Objects.requireNonNull(action, "action"); boolean enabledBefore = engine.isJavaScriptEnabled(); if (!enabledBefore) { engine.setJavaScriptEnabled(true); } try { return action.get(); } finally { if (!enabledBefore) { engine.setJavaScriptEnabled(false); } } } /** * Converts a Java value into a safe JavaScript literal. * *

Supported: null, String, Boolean, Number (finite).

* * @param v value * @return JS literal */ public static String toJsLiteral(Object v) { if (v == null) return "null"; if (v instanceof String s) { return "'" + escapeJsSingleQuotedString(s) + "'"; } if (v instanceof Boolean b) { return b ? "true" : "false"; } if (v instanceof Number n) { double d = n.doubleValue(); if (!Double.isFinite(d)) { throw new IllegalArgumentException("Non-finite number is not supported for JS literal: " + d); } return Double.toString(d); } throw new IllegalArgumentException("Unsupported value type for JS literal: " + v.getClass().getName()); } /** * Builds a JavaScript array literal from Java values. * * @param values values * @return array literal */ public static String toJsArrayLiteral(Object[] values) { if (values == null || values.length == 0) return "[]"; StringBuilder sb = new StringBuilder(); sb.append('['); for (int i = 0; i < values.length; i++) { if (i > 0) sb.append(','); sb.append(toJsLiteral(values[i])); } sb.append(']'); return sb.toString(); } /** * Restricts input to a plain JavaScript identifier (prevents injection). * * @param s value * @param label label * @return identifier */ public static String requireJsIdentifier(String s, String label) { if (s == null) throw new IllegalArgumentException(label + " is null"); String v = s.trim(); if (v.isEmpty()) throw new IllegalArgumentException(label + " is blank"); if (!v.matches("^[A-Za-z_$][A-Za-z0-9_$]*$")) { throw new IllegalArgumentException(label + " must be a JS identifier: " + v); } return v; } /** * Normalizes alignment options for scrollIntoView. * * @param align input * @return normalized */ public static String normalizeAlign(String align) { if (align == null) return "nearest"; String a = align.trim().toLowerCase(Locale.ROOT); return switch (a) { case "start", "center", "end", "nearest" -> a; default -> "nearest"; }; } /** * Converts a JS array-like object into a Java string list. * * @param jsValue raw JS value * @return list of string values */ public static List toStringList(Object jsValue) { if (jsValue == null) return List.of(); if (jsValue instanceof List list) { List out = new ArrayList<>(list.size()); for (Object value : list) out.add(value == null ? null : String.valueOf(value)); return out; } try { Class jsObj = Class.forName("netscape.javascript.JSObject"); if (jsObj.isInstance(jsValue)) { Object lenObj = jsObj.getMethod("getMember", String.class).invoke(jsValue, "length"); int len = Integer.parseInt(String.valueOf(lenObj)); List out = new ArrayList<>(len); for (int i = 0; i < len; i++) { Object value = jsObj.getMethod("getSlot", int.class).invoke(jsValue, i); out.add(value == null ? null : String.valueOf(value)); } return out; } } catch (Exception ignored) { // best-effort fallback below } return List.of(String.valueOf(jsValue)); } /** * Converts a JS object into a Java string-object map when possible. * * @param jsValue raw JS value * @return mapped values */ public static Map toStringObjectMap(Object jsValue) { if (jsValue == null) return Map.of(); if (jsValue instanceof Map map) { Map out = new LinkedHashMap<>(); for (Map.Entry entry : map.entrySet()) { if (entry.getKey() == null) continue; out.put(String.valueOf(entry.getKey()), entry.getValue()); } return out; } return Map.of("value", jsValue); } private static String escapeJsSingleQuotedString(String s) { if (s == null || s.isEmpty()) return ""; StringBuilder out = new StringBuilder(s.length() + 16); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); switch (c) { case '\'' -> out.append("\\'"); case '\\' -> out.append("\\\\"); case '\n' -> out.append("\\n"); case '\r' -> out.append("\\r"); case '\t' -> out.append("\\t"); case '\u0000' -> out.append("\\0"); case '\u2028' -> out.append("\\u2028"); case '\u2029' -> out.append("\\u2029"); default -> { if (c < 0x20) out.append(String.format("\\u%04x", (int) c)); else out.append(c); } } } return out.toString(); } }