lpcmanager

New widget HMI:MultiLangJsonTable was created to accommodate alarm description translations. Redmine issue #4574
--- a/LPCSVGHMI/analyse_widget.xslt Mon Jan 26 11:31:16 2026 +0100
+++ b/LPCSVGHMI/analyse_widget.xslt Fri Jan 30 08:41:19 2026 +0100
@@ -1209,6 +1209,28 @@
<xsl:text>format string for Y label</xsl:text>
</arg>
</xsl:template>
+ <xsl:template match="widget[@type='MultiLangJsonTable']" mode="widget_desc">
+ <type>
+ <xsl:value-of select="@type"/>
+ </type>
+ <longdesc>
+ <xsl:text>Send given variables as POST to http URL argument, spread returned JSON in
+</xsl:text>
+ <xsl:text>SVG sub-elements of "data" labeled element.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text>Documentation to be written. see svghmi example.
+</xsl:text>
+ </longdesc>
+ <shortdesc>
+ <xsl:text>Http POST variables, spread JSON back</xsl:text>
+ </shortdesc>
+ <arg name="url" accepts="string"/>
+ <path name="edit" accepts="HMI_INT, HMI_REAL, HMI_STRING">
+ <xsl:text>single variable to edit</xsl:text>
+ </path>
+ </xsl:template>
<xsl:template match="widget[@type='Swipe']" mode="widget_desc">
<type>
<xsl:value-of select="@type"/>
--- a/LPCSVGHMI/gen_index_xhtml.xslt Mon Jan 26 11:31:16 2026 +0100
+++ b/LPCSVGHMI/gen_index_xhtml.xslt Fri Jan 30 08:41:19 2026 +0100
@@ -10159,6 +10159,620 @@
<xsl:text> }
</xsl:text>
</xsl:template>
+ <xsl:template match="widget[@type='MultiLangJsonTable']" mode="widget_desc">
+ <type>
+ <xsl:value-of select="@type"/>
+ </type>
+ <longdesc>
+ <xsl:text>Send given variables as POST to http URL argument, spread returned JSON in
+</xsl:text>
+ <xsl:text>SVG sub-elements of "data" labeled element.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text>Documentation to be written. see svghmi example.
+</xsl:text>
+ </longdesc>
+ <shortdesc>
+ <xsl:text>Http POST variables, spread JSON back</xsl:text>
+ </shortdesc>
+ <arg name="url" accepts="string"/>
+ <path name="edit" accepts="HMI_INT, HMI_REAL, HMI_STRING">
+ <xsl:text>single variable to edit</xsl:text>
+ </path>
+ </xsl:template>
+ <xsl:template match="widget[@type='MultiLangJsonTable']" mode="widget_class">
+ <xsl:text>class </xsl:text>
+ <xsl:text>MultiLangJsonTableWidget</xsl:text>
+ <xsl:text> extends Widget{
+</xsl:text>
+ <xsl:text> // arbitrary defaults to avoid missing entries in query
+</xsl:text>
+ <xsl:text> cache = [0,0,0];
+</xsl:text>
+ <xsl:text> init_common() {
+</xsl:text>
+ <xsl:text> this.spread_json_data_bound = this.spread_json_data.bind(this);
+</xsl:text>
+ <xsl:text> this.handle_http_response_bound = this.handle_http_response.bind(this);
+</xsl:text>
+ <xsl:text> this.fetch_error_bound = this.fetch_error.bind(this);
+</xsl:text>
+ <xsl:text> if (this.should_translate === undefined) {
+</xsl:text>
+ <xsl:text> this.should_translate = [];
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> if (this.lang_keys === undefined) {
+</xsl:text>
+ <xsl:text> this.lang_keys = [];
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> this.promised = false;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> handle_http_response(response) {
+</xsl:text>
+ <xsl:text> if (!response.ok) {
+</xsl:text>
+ <xsl:text> console.log("HTTP error, status = " + response.status);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> return response.json();
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> fetch_error(e){
+</xsl:text>
+ <xsl:text> console.log("HTTP fetch error, message = " + e.message + "Widget:" + this.element_id);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> do_http_request(...opt) {
+</xsl:text>
+ <xsl:text> this.abort_controller = new AbortController();
+</xsl:text>
+ <xsl:text> return Promise.resolve().then(() =&gt; {
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const query = {
+</xsl:text>
+ <xsl:text> args: this.args,
+</xsl:text>
+ <xsl:text> range: this.cache[1],
+</xsl:text>
+ <xsl:text> position: this.cache[2],
+</xsl:text>
+ <xsl:text> visible: this.visible,
+</xsl:text>
+ <xsl:text> extra: this.cache.slice(4),
+</xsl:text>
+ <xsl:text> options: opt
+</xsl:text>
+ <xsl:text> };
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const options = {
+</xsl:text>
+ <xsl:text> method: 'POST',
+</xsl:text>
+ <xsl:text> body: JSON.stringify(query),
+</xsl:text>
+ <xsl:text> headers: {'Content-Type': 'application/json'},
+</xsl:text>
+ <xsl:text> signal: this.abort_controller.signal
+</xsl:text>
+ <xsl:text> };
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> return fetch(this.args[0], options)
+</xsl:text>
+ <xsl:text> .then(this.handle_http_response_bound)
+</xsl:text>
+ <xsl:text> .then(this.spread_json_data_bound)
+</xsl:text>
+ <xsl:text> .catch(this.fetch_error_bound);
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> unsub(){
+</xsl:text>
+ <xsl:text> this.abort_controller.abort();
+</xsl:text>
+ <xsl:text> super.unsub();
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> sub(...args){
+</xsl:text>
+ <xsl:text> this.cache[0] = undefined;
+</xsl:text>
+ <xsl:text> super.sub(...args);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> dispatch(value, oldval, index) {
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> if(this.cache[index] != value)
+</xsl:text>
+ <xsl:text> this.cache[index] = value;
+</xsl:text>
+ <xsl:text> else
+</xsl:text>
+ <xsl:text> return;
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> if(!this.promised){
+</xsl:text>
+ <xsl:text> this.promised = true;
+</xsl:text>
+ <xsl:text> this.do_http_request().finally(() =&gt; {
+</xsl:text>
+ <xsl:text> this.promised = false;
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> make_on_click(...options){
+</xsl:text>
+ <xsl:text> let that = this;
+</xsl:text>
+ <xsl:text> return function(evt){
+</xsl:text>
+ <xsl:text> that.do_http_request(...options);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> // on_click(evt, ...options) {
+</xsl:text>
+ <xsl:text> // this.do_http_request(...options);
+</xsl:text>
+ <xsl:text> // }
+</xsl:text>
+ <xsl:text>}
+</xsl:text>
+ </xsl:template>
+ <xsl:template mode="json_table_elt_render" match="svg:*">
+ <xsl:message terminate="yes">
+ <xsl:text>MultiLangJsonTable Widget can't contain element of type </xsl:text>
+ <xsl:value-of select="local-name()"/>
+ <xsl:text>.</xsl:text>
+ </xsl:message>
+ </xsl:template>
+ <func:function name="func:ml_json_expressions">
+ <xsl:param name="expressions"/>
+ <xsl:param name="label"/>
+ <xsl:choose>
+ <xsl:when test="$label">
+ <xsl:variable name="suffixes" select="str:split($label)"/>
+ <xsl:variable name="res">
+ <xsl:for-each select="$suffixes">
+ <expression>
+ <xsl:variable name="suffix" select="."/>
+ <xsl:variable name="pos" select="position()"/>
+ <xsl:variable name="expr" select="$expressions[position() &lt;= $pos][last()]/expression"/>
+ <xsl:if test="$pos = 1">
+ <xsl:variable name="raw_selector" select="$suffix"/>
+ <xsl:variable name="lang_selector">
+ <xsl:choose>
+ <xsl:when test="starts-with($raw_selector, '.')">
+ <xsl:value-of select="substring($raw_selector, 2)"/>
+ </xsl:when>
+ <xsl:otherwise>
+ <xsl:value-of select="$raw_selector"/>
+ </xsl:otherwise>
+ </xsl:choose>
+ </xsl:variable>
+ <xsl:attribute name="lang_selector">
+ <xsl:value-of select="$lang_selector"/>
+ </xsl:attribute>
+ </xsl:if>
+ <xsl:choose>
+ <xsl:when test="contains($suffix, '=')">
+ <xsl:variable name="name" select="substring-before($suffix, '=')"/>
+ <xsl:variable name="content_raw" select="substring-after($suffix, '=')"/>
+ <xsl:if test="$expr/@name[. != $name]">
+ <xsl:message terminate="yes">
+ <xsl:text>MultiLangJsonTable : misplaced '=' or inconsistent names in Json data expressions.</xsl:text>
+ </xsl:message>
+ </xsl:if>
+ <xsl:attribute name="name">
+ <xsl:value-of select="$name"/>
+ </xsl:attribute>
+ <xsl:choose>
+ <xsl:when test="starts-with($content_raw, '_(') and substring($content_raw, string-length($content_raw)) = ')'">
+ <xsl:variable name="raw_key" select="substring($content_raw, 3, string-length($content_raw) - 3)"/>
+ <xsl:variable name="clean_key">
+ <xsl:choose>
+ <xsl:when test="starts-with($raw_key, '.')">
+ <xsl:value-of select="substring($raw_key, 2)"/>
+ </xsl:when>
+ <xsl:otherwise>
+ <xsl:value-of select="$raw_key"/>
+ </xsl:otherwise>
+ </xsl:choose>
+ </xsl:variable>
+ <xsl:attribute name="translation_key">
+ <xsl:value-of select="$clean_key"/>
+ </xsl:attribute>
+ <xsl:attribute name="content">
+ <xsl:value-of select="$expr/@content"/>
+ <xsl:value-of select="$raw_key"/>
+ </xsl:attribute>
+ </xsl:when>
+ <xsl:otherwise>
+ <xsl:attribute name="content">
+ <xsl:value-of select="$expr/@content"/>
+ <xsl:value-of select="$content_raw"/>
+ </xsl:attribute>
+ </xsl:otherwise>
+ </xsl:choose>
+ </xsl:when>
+ <xsl:otherwise>
+ <xsl:copy-of select="$expr/@name"/>
+ <xsl:attribute name="content">
+ <xsl:value-of select="$expr/@content"/>
+ <xsl:value-of select="$suffix"/>
+ </xsl:attribute>
+ </xsl:otherwise>
+ </xsl:choose>
+ </expression>
+ </xsl:for-each>
+ </xsl:variable>
+ <func:result select="exsl:node-set($res)"/>
+ </xsl:when>
+ <xsl:otherwise>
+ <func:result select="$expressions"/>
+ </xsl:otherwise>
+ </xsl:choose>
+ </func:function>
+ <xsl:variable name="ml_initexpr">
+ <expression>
+ <xsl:attribute name="content">
+ <xsl:text>jdata</xsl:text>
+ </xsl:attribute>
+ </expression>
+ </xsl:variable>
+ <xsl:variable name="ml_initexpr_ns" select="exsl:node-set($ml_initexpr)"/>
+ <xsl:template mode="json_table_elt_render" match="svg:use">
+ <xsl:param name="expressions"/>
+ <xsl:variable name="targetid" select="substring-after(@xlink:href,'#')"/>
+ <xsl:variable name="from_list" select="$hmi_lists[(@id | */@id) = $targetid]"/>
+ <xsl:choose>
+ <xsl:when test="count($from_list) &gt; 0">
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>").href.baseVal =
+</xsl:text>
+ <xsl:text> "#"+hmi_widgets["</xsl:text>
+ <xsl:value-of select="$from_list/@id"/>
+ <xsl:text>"].items[</xsl:text>
+ <xsl:value-of select="$expressions/expression[1]/@content"/>
+ <xsl:text>];
+</xsl:text>
+ </xsl:when>
+ <xsl:otherwise>
+ <xsl:message terminate="no">
+ <xsl:text>Clones (svg:use) in MultiLangJsonTable Widget must point to a valid HMI:List widget or item. Reference "</xsl:text>
+ <xsl:value-of select="@xlink:href"/>
+ <xsl:text>" is not valid and will not be updated.</xsl:text>
+ </xsl:message>
+ </xsl:otherwise>
+ </xsl:choose>
+ </xsl:template>
+ <xsl:template mode="json_table_elt_render" match="svg:text">
+ <xsl:param name="expressions"/>
+ <xsl:variable name="value_expr" select="$expressions/expression[1]/@content"/>
+ <xsl:variable name="original" select="@original"/>
+ <xsl:variable name="from_textstylelist" select="$textstylelist_related_ns/list[elt/@eltid = $original]"/>
+ <xsl:choose>
+ <xsl:when test="count($from_textstylelist) &gt; 0">
+ <xsl:variable name="content_expr" select="$expressions/expression[2]/@content"/>
+ <xsl:if test="string-length($content_expr) = 0 or $expressions/expression[2]/@name != 'textContent'">
+ <xsl:message terminate="yes">
+ <xsl:text>Clones (svg:use) in MultiLangJsonTable Widget pointing to a HMI:TextStyleList widget or item must have a "textContent=.someVal" assignment following value expression in label.</xsl:text>
+ </xsl:message>
+ </xsl:if>
+ <xsl:text> {
+</xsl:text>
+ <xsl:text> let elt = id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>");
+</xsl:text>
+ <xsl:text> elt.textContent = String(</xsl:text>
+ <xsl:value-of select="$content_expr"/>
+ <xsl:text>);
+</xsl:text>
+ <xsl:text> elt.style = hmi_widgets["</xsl:text>
+ <xsl:value-of select="$from_textstylelist/@listid"/>
+ <xsl:text>"].styles[</xsl:text>
+ <xsl:value-of select="$value_expr"/>
+ <xsl:text>];
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ </xsl:when>
+ <xsl:otherwise>
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>").textContent = String(</xsl:text>
+ <xsl:value-of select="$value_expr"/>
+ <xsl:text>);
+</xsl:text>
+ </xsl:otherwise>
+ </xsl:choose>
+ </xsl:template>
+ <xsl:template mode="json_table_elt_render" match="svg:image">
+ <xsl:param name="expressions"/>
+ <xsl:variable name="value_expr" select="$expressions/expression[1]/@content"/>
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>").setAttribute('href', String(</xsl:text>
+ <xsl:value-of select="$value_expr"/>
+ <xsl:text>));
+</xsl:text>
+ </xsl:template>
+ <xsl:template mode="json_table_render_except_comments" match="svg:*">
+ <xsl:param name="expressions"/>
+ <xsl:param name="widget_elts"/>
+ <xsl:variable name="label" select="func:filter_non_widget_label(., $widget_elts)"/>
+ <xsl:if test="not(starts-with($label,'#'))">
+ <xsl:apply-templates mode="json_table_render" select=".">
+ <xsl:with-param name="expressions" select="$expressions"/>
+ <xsl:with-param name="widget_elts" select="$widget_elts"/>
+ <xsl:with-param name="label" select="$label"/>
+ </xsl:apply-templates>
+ </xsl:if>
+ </xsl:template>
+ <xsl:template mode="json_table_render" match="svg:*">
+ <xsl:param name="expressions"/>
+ <xsl:param name="widget_elts"/>
+ <xsl:param name="label"/>
+ <xsl:variable name="new_expressions" select="func:ml_json_expressions($expressions, $label)"/>
+ <xsl:variable name="elt" select="."/>
+ <xsl:for-each select="$new_expressions/expression[position() &gt; 1][starts-with(@name,'onClick')]">
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="$elt/@id"/>
+ <xsl:text>").onclick = this.make_on_click('</xsl:text>
+ <xsl:value-of select="@name"/>
+ <xsl:text>', </xsl:text>
+ <xsl:value-of select="@content"/>
+ <xsl:text>);
+</xsl:text>
+ </xsl:for-each>
+ <xsl:apply-templates mode="json_table_elt_render" select=".">
+ <xsl:with-param name="expressions" select="$new_expressions"/>
+ </xsl:apply-templates>
+ </xsl:template>
+ <xsl:template mode="json_table_render" match="svg:g">
+ <xsl:param name="expressions"/>
+ <xsl:param name="widget_elts"/>
+ <xsl:param name="label"/>
+ <xsl:variable name="varprefix">
+ <xsl:text>obj_</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>_</xsl:text>
+ </xsl:variable>
+ <xsl:text> try {
+</xsl:text>
+ <xsl:for-each select="$expressions/expression">
+ <xsl:text> let </xsl:text>
+ <xsl:value-of select="$varprefix"/>
+ <xsl:value-of select="position()"/>
+ <xsl:text> = </xsl:text>
+ <xsl:value-of select="@content"/>
+ <xsl:text>;
+</xsl:text>
+ <xsl:text> if(</xsl:text>
+ <xsl:value-of select="$varprefix"/>
+ <xsl:value-of select="position()"/>
+ <xsl:text> == undefined) {
+</xsl:text>
+ <xsl:text> throw null;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ </xsl:for-each>
+ <xsl:variable name="new_expressions">
+ <xsl:for-each select="$expressions/expression">
+ <xsl:copy>
+ <xsl:copy-of select="@name"/>
+ <xsl:attribute name="content">
+ <xsl:value-of select="$varprefix"/>
+ <xsl:value-of select="position()"/>
+ </xsl:attribute>
+ </xsl:copy>
+ </xsl:for-each>
+ </xsl:variable>
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>").style = "</xsl:text>
+ <xsl:value-of select="@style"/>
+ <xsl:text>";
+</xsl:text>
+ <xsl:apply-templates mode="json_table_render_except_comments" select="*">
+ <xsl:with-param name="expressions" select="func:ml_json_expressions(exsl:node-set($new_expressions), $label)"/>
+ <xsl:with-param name="widget_elts" select="$widget_elts"/>
+ </xsl:apply-templates>
+ <xsl:text> } catch(err) {
+</xsl:text>
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>").style = "display:none";
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ </xsl:template>
+ <xsl:template match="widget[@type='MultiLangJsonTable']" mode="widget_defs">
+ <xsl:param name="hmi_element"/>
+ <xsl:variable name="disability">
+ <xsl:call-template name="defs_by_labels">
+ <xsl:with-param name="hmi_element" select="$hmi_element"/>
+ <xsl:with-param name="labels">
+ <xsl:text>/disabled</xsl:text>
+ </xsl:with-param>
+ <xsl:with-param name="mandatory" select="'no'"/>
+ </xsl:call-template>
+ </xsl:variable>
+ <xsl:value-of select="$disability"/>
+ <xsl:variable name="has_disability" select="string-length($disability)&gt;0"/>
+ <xsl:call-template name="defs_by_labels">
+ <xsl:with-param name="hmi_element" select="$hmi_element"/>
+ <xsl:with-param name="labels">
+ <xsl:text>data</xsl:text>
+ </xsl:with-param>
+ </xsl:call-template>
+ <xsl:variable name="data_elt" select="$result_svg_ns//*[@id = $hmi_element/@id]/*[@inkscape:label = 'data']"/>
+ <xsl:variable name="widget_elts" select="$hmi_element/*[@inkscape:label = 'data']/descendant::svg:*"/>
+ <xsl:variable name="all_parsed_expressions">
+ <xsl:for-each select="$data_elt/descendant-or-self::svg:*[@inkscape:label]">
+ <xsl:variable name="exprs" select="func:ml_json_expressions($ml_initexpr_ns, @inkscape:label)"/>
+ <xsl:copy-of select="exsl:node-set($exprs)//expression[@translation_key or @lang_selector]"/>
+ </xsl:for-each>
+ </xsl:variable>
+ <xsl:variable name="all_exprs_ns" select="exsl:node-set($all_parsed_expressions)"/>
+ <xsl:text> visible: </xsl:text>
+ <xsl:value-of select="count($data_elt/*[@inkscape:label])"/>
+ <xsl:text>,
+</xsl:text>
+ <xsl:text> should_translate: [
+</xsl:text>
+ <xsl:for-each select="$all_exprs_ns/expression[@translation_key]">
+ <xsl:if test="not(@translation_key = preceding-sibling::expression/@translation_key)">
+ <xsl:text> "</xsl:text>
+ <xsl:value-of select="@translation_key"/>
+ <xsl:text>"
+</xsl:text>
+ <xsl:if test="position() != last()">
+ <xsl:text>,
+</xsl:text>
+ </xsl:if>
+ </xsl:if>
+ </xsl:for-each>
+ <xsl:text> ],
+</xsl:text>
+ <xsl:text> lang_keys: [
+</xsl:text>
+ <xsl:for-each select="$all_exprs_ns/expression[@lang_selector]">
+ <xsl:if test="not(@lang_selector = preceding-sibling::expression/@lang_selector)">
+ <xsl:text> "</xsl:text>
+ <xsl:value-of select="@lang_selector"/>
+ <xsl:text>"
+</xsl:text>
+ <xsl:if test="position() != last()">
+ <xsl:text>,
+</xsl:text>
+ </xsl:if>
+ </xsl:if>
+ </xsl:for-each>
+ <xsl:text> ],
+</xsl:text>
+ <xsl:text> spread_json_data: function(janswer) {
+</xsl:text>
+ <xsl:text> let [range,position,jdata] = janswer;
+</xsl:text>
+ <xsl:text> if (jdata.length &gt; 0 &amp;&amp; this.should_translate.length &gt; 0) {
+</xsl:text>
+ <xsl:text> const lang = cache[lang_local_index];
+</xsl:text>
+ <xsl:text> const langcode = langs[lang][1];
+</xsl:text>
+ <xsl:text> for (let row of jdata) {
+</xsl:text>
+ <xsl:text> for (const key of this.should_translate) {
+</xsl:text>
+ <xsl:text> if (key in row) {
+</xsl:text>
+ <xsl:text> const orig = row[key];
+</xsl:text>
+ <xsl:text> const match = translations.find(item =&gt; item[1][0] == orig);
+</xsl:text>
+ <xsl:text> const tr = match ? match[1][lang] : orig;
+</xsl:text>
+ <xsl:text> row[key] = tr;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> for (const key of this.lang_keys) {
+</xsl:text>
+ <xsl:text> if (key in row) {
+</xsl:text>
+ <xsl:text> row[key] = String(row[key]) + "_" + langcode;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> [[1, range], [2, position], [3, this.visible]].map(([i,v]) =&gt; {
+</xsl:text>
+ <xsl:text> this.apply_hmi_value(i,v);
+</xsl:text>
+ <xsl:text> this.cache[i] = v;
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:apply-templates mode="json_table_render_except_comments" select="$data_elt">
+ <xsl:with-param name="expressions" select="$ml_initexpr_ns"/>
+ <xsl:with-param name="widget_elts" select="$widget_elts"/>
+ </xsl:apply-templates>
+ <xsl:text> },
+</xsl:text>
+ <xsl:text> init() {
+</xsl:text>
+ <xsl:text> this.init_common();
+</xsl:text>
+ <xsl:for-each select="$hmi_element/*[starts-with(@inkscape:label,'action_')]">
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>").onclick = this.make_on_click("</xsl:text>
+ <xsl:value-of select="func:escape_quotes(@inkscape:label)"/>
+ <xsl:text>");
+</xsl:text>
+ </xsl:for-each>
+ <xsl:for-each select="$hmi_element/*[starts-with(@inkscape:label,'dict')]">
+ <xsl:text> id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>").style.display = "none";
+</xsl:text>
+ </xsl:for-each>
+ <xsl:text> }
+</xsl:text>
+ </xsl:template>
<xsl:template match="widget[@type='Swipe']" mode="widget_desc">
<type>
<xsl:value-of select="@type"/>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/LPCSVGHMI/widget_multilangjsontable.ysl2 Fri Jan 30 08:41:19 2026 +0100
@@ -0,0 +1,358 @@
+// widget_multilangjsontable.ysl2
+
+widget_desc("MultiLangJsonTable") {
+ longdesc
+ ||
+ Send given variables as POST to http URL argument, spread returned JSON in
+ SVG sub-elements of "data" labeled element.
+
+ Documentation to be written. see svghmi example.
+ ||
+
+ shortdesc > Http POST variables, spread JSON back
+
+ arg name="url" accepts="string" >
+
+ path name="edit" accepts="HMI_INT, HMI_REAL, HMI_STRING" > single variable to edit
+
+}
+
+widget_class("MultiLangJsonTable")
+ ||
+ // arbitrary defaults to avoid missing entries in query
+ cache = [0,0,0];
+ init_common() {
+ this.spread_json_data_bound = this.spread_json_data.bind(this);
+ this.handle_http_response_bound = this.handle_http_response.bind(this);
+ this.fetch_error_bound = this.fetch_error.bind(this);
+ if (this.should_translate === undefined) {
+ this.should_translate = [];
+ }
+ if (this.lang_keys === undefined) {
+ this.lang_keys = [];
+ }
+ this.promised = false;
+ }
+
+ handle_http_response(response) {
+ if (!response.ok) {
+ console.log("HTTP error, status = " + response.status);
+ }
+ return response.json();
+ }
+
+ fetch_error(e){
+ console.log("HTTP fetch error, message = " + e.message + "Widget:" + this.element_id);
+ }
+
+ do_http_request(...opt) {
+ this.abort_controller = new AbortController();
+ return Promise.resolve().then(() => {
+
+ const query = {
+ args: this.args,
+ range: this.cache[1],
+ position: this.cache[2],
+ visible: this.visible,
+ extra: this.cache.slice(4),
+ options: opt
+ };
+
+ const options = {
+ method: 'POST',
+ body: JSON.stringify(query),
+ headers: {'Content-Type': 'application/json'},
+ signal: this.abort_controller.signal
+ };
+
+ return fetch(this.args[0], options)
+ .then(this.handle_http_response_bound)
+ .then(this.spread_json_data_bound)
+ .catch(this.fetch_error_bound);
+ });
+ }
+
+ unsub(){
+ this.abort_controller.abort();
+ super.unsub();
+ }
+
+ sub(...args){
+ this.cache[0] = undefined;
+ super.sub(...args);
+ }
+
+ dispatch(value, oldval, index) {
+
+ if(this.cache[index] != value)
+ this.cache[index] = value;
+ else
+ return;
+
+ if(!this.promised){
+ this.promised = true;
+ this.do_http_request().finally(() => {
+ this.promised = false;
+ });
+ }
+ }
+ make_on_click(...options){
+ let that = this;
+ return function(evt){
+ that.do_http_request(...options);
+ }
+ }
+ // on_click(evt, ...options) {
+ // this.do_http_request(...options);
+ // }
+ ||
+
+gen_index_xhtml {
+
+template "svg:*", mode="json_table_elt_render" {
+ error > MultiLangJsonTable Widget can't contain element of type «local-name()».
+}
+
+
+def "func:ml_json_expressions" {
+ param "expressions";
+ param "label";
+
+ choose {
+ when "$label" {
+ const "suffixes", "str:split($label)";
+ const "res" foreach "$suffixes" expression {
+ const "suffix", ".";
+ const "pos", "position()";
+ const "expr", "$expressions[position() <= $pos][last()]/expression";
+ if "$pos = 1" {
+ const "raw_selector", "$suffix";
+ const "lang_selector" choose {
+ when "starts-with($raw_selector, '.')" value "substring($raw_selector, 2)";
+ otherwise value "$raw_selector";
+ }
+ attrib "lang_selector" value "$lang_selector";
+ }
+ choose {
+ when "contains($suffix, '=')" {
+ const "name", "substring-before($suffix, '=')";
+ const "content_raw", "substring-after($suffix, '=')";
+
+ if "$expr/@name[. != $name]"
+ error > MultiLangJsonTable : misplaced '=' or inconsistent names in Json data expressions.
+ attrib "name" value "$name";
+ choose {
+ when "starts-with($content_raw, '_(') and substring($content_raw, string-length($content_raw)) = ')'" {
+ const "raw_key", "substring($content_raw, 3, string-length($content_raw) - 3)";
+ const "clean_key" choose {
+ when "starts-with($raw_key, '.')" value "substring($raw_key, 2)";
+ otherwise value "$raw_key";
+ }
+ attrib "translation_key" value "$clean_key";
+ attrib "content" > «$expr/@content»«$raw_key»
+ }
+ otherwise {
+ attrib "content" > «$expr/@content»«$content_raw»
+ }
+ }
+ }
+ otherwise {
+ copy "$expr/@name";
+ attrib "content" > «$expr/@content»«$suffix»
+ }
+ }
+ }
+ result "exsl:node-set($res)";
+ }
+ otherwise result "$expressions";
+ }
+}
+
+const "ml_initexpr" expression attrib "content" > jdata
+const "ml_initexpr_ns", "exsl:node-set($ml_initexpr)";
+
+template "svg:use", mode="json_table_elt_render" {
+ param "expressions";
+ // cloned element must be part of a HMI:List or a HMI:List
+ const "targetid", "substring-after(@xlink:href,'#')";
+ const "from_list", "$hmi_lists[(@id | */@id) = $targetid]";
+
+ choose {
+ when "count($from_list) > 0" {
+ | id("«@id»").href.baseVal =
+ // obtain new target id from HMI:List widget
+ | "#"+hmi_widgets["«$from_list/@id»"].items[«$expressions/expression[1]/@content»];
+ }
+ otherwise
+ warning > Clones (svg:use) in MultiLangJsonTable Widget must point to a valid HMI:List widget or item. Reference "«@xlink:href»" is not valid and will not be updated.
+ }
+}
+
+template "svg:text", mode="json_table_elt_render" {
+ param "expressions";
+ const "value_expr", "$expressions/expression[1]/@content";
+ const "original", "@original";
+ const "from_textstylelist", "$textstylelist_related_ns/list[elt/@eltid = $original]";
+ choose {
+
+ when "count($from_textstylelist) > 0" {
+ const "content_expr", "$expressions/expression[2]/@content";
+ if "string-length($content_expr) = 0 or $expressions/expression[2]/@name != 'textContent'"
+ error > Clones (svg:use) in MultiLangJsonTable Widget pointing to a HMI:TextStyleList widget or item must have a "textContent=.someVal" assignment following value expression in label.
+ | {
+ | let elt = id("«@id»");
+ | elt.textContent = String(«$content_expr»);
+ | elt.style = hmi_widgets["«$from_textstylelist/@listid»"].styles[«$value_expr»];
+ | }
+ }
+ otherwise {
+ | id("«@id»").textContent = String(«$value_expr»);
+ }
+ }
+}
+
+template "svg:image", mode="json_table_elt_render" {
+ param "expressions";
+ const "value_expr", "$expressions/expression[1]/@content";
+ | id("«@id»").setAttribute('href', String(«$value_expr»));
+}
+
+template "svg:*", mode="json_table_render_except_comments"{
+ param "expressions";
+ param "widget_elts";
+
+ const "label", "func:filter_non_widget_label(., $widget_elts)";
+ // filter out "# commented" elements
+ if "not(starts-with($label,'#'))"
+ apply ".", mode="json_table_render"{
+ with "expressions", "$expressions";
+ with "widget_elts", "$widget_elts";
+ with "label", "$label";
+ }
+}
+
+
+template "svg:*", mode="json_table_render" {
+ param "expressions";
+ param "widget_elts";
+ param "label";
+
+ const "new_expressions", "func:ml_json_expressions($expressions, $label)";
+
+ const "elt",".";
+ foreach "$new_expressions/expression[position() > 1][starts-with(@name,'onClick')]"
+ | id("«$elt/@id»").onclick = this.make_on_click('«@name»', «@content»);
+
+ apply ".", mode="json_table_elt_render"
+ with "expressions", "$new_expressions";
+}
+
+template "svg:g", mode="json_table_render" {
+ param "expressions";
+ param "widget_elts";
+ param "label";
+
+ // use intermediate variables for optimization
+ const "varprefix" > obj_«@id»_
+ | try {
+
+ foreach "$expressions/expression"{
+ | let «$varprefix»«position()» = «@content»;
+ | if(«$varprefix»«position()» == undefined) {
+ | throw null;
+ | }
+ }
+
+ // because we put values in a variables, we can replace corresponding expression with variable name
+ const "new_expressions" foreach "$expressions/expression" xsl:copy {
+ copy "@name";
+ attrib "content" > «$varprefix»«position()»
+ }
+
+ // revert hiding in case it did happen before
+ | id("«@id»").style = "«@style»";
+
+ apply "*", mode="json_table_render_except_comments" {
+ with "expressions", "func:ml_json_expressions(exsl:node-set($new_expressions), $label)";
+ with "widget_elts", "$widget_elts";
+ }
+ | } catch(err) {
+ | id("«@id»").style = "display:none";
+ | }
+}
+
+}
+
+widget_defs("MultiLangJsonTable") {
+ labels("data");
+ const "data_elt", "$result_svg_ns//*[@id = $hmi_element/@id]/*[@inkscape:label = 'data']";
+ const "widget_elts", "$hmi_element/*[@inkscape:label = 'data']/descendant::svg:*";
+ const "all_parsed_expressions" {
+ foreach "$data_elt/descendant-or-self::svg:*[@inkscape:label]" {
+ const "exprs", "func:ml_json_expressions($ml_initexpr_ns, @inkscape:label)";
+ copy "exsl:node-set($exprs)//expression[@translation_key or @lang_selector]";
+ }
+ }
+ const "all_exprs_ns", "exsl:node-set($all_parsed_expressions)";
+ | visible: «count($data_elt/*[@inkscape:label])»,
+ | should_translate: [
+ foreach "$all_exprs_ns/expression[@translation_key]" {
+ if "not(@translation_key = preceding-sibling::expression/@translation_key)" {
+ | "«@translation_key»"
+ if "position() != last()" {
+ | ,
+ }
+ }
+ }
+ | ],
+ | lang_keys: [
+ foreach "$all_exprs_ns/expression[@lang_selector]" {
+ if "not(@lang_selector = preceding-sibling::expression/@lang_selector)" {
+ | "«@lang_selector»"
+ if "position() != last()" {
+ | ,
+ }
+ }
+ }
+ | ],
+ | spread_json_data: function(janswer) {
+ | let [range,position,jdata] = janswer;
+ | if (jdata.length > 0 && this.should_translate.length > 0) {
+ | const lang = cache[lang_local_index];
+ | const langcode = langs[lang][1];
+ | for (let row of jdata) {
+ | for (const key of this.should_translate) {
+ | if (key in row) {
+ | const orig = row[key];
+ | const match = translations.find(item => item[1][0] == orig);
+ | const tr = match ? match[1][lang] : orig;
+ | row[key] = tr;
+ | }
+ | }
+ | for (const key of this.lang_keys) {
+ | if (key in row) {
+ | row[key] = String(row[key]) + "_" + langcode;
+ | }
+ | }
+ | }
+ | }
+ | [[1, range], [2, position], [3, this.visible]].map(([i,v]) => {
+ | this.apply_hmi_value(i,v);
+ | this.cache[i] = v;
+ | });
+ apply "$data_elt", mode="json_table_render_except_comments" {
+ with "expressions","$ml_initexpr_ns";
+ with "widget_elts","$widget_elts";
+ }
+ | },
+ | init() {
+ | this.init_common();
+ foreach "$hmi_element/*[starts-with(@inkscape:label,'action_')]" {
+ | id("«@id»").onclick = this.make_on_click("«func:escape_quotes(@inkscape:label)»");
+ }
+ foreach "$hmi_element/*[starts-with(@inkscape:label,'dict')]" {
+ | id("«@id»").style.display = "none";
+ }
+ | }
+
+}
\ No newline at end of file