/** This file is part of Reactor.
 *  Copyright (c) 2020-2026 Kedron Holdings LLC, All Rights Reserved.
 *  Reactor is not public domain or open source. Distribution or derivative works are expressly prohibited.
 */

export const version = 26192;

import api from '/client/ClientAPI.js';
import Rulesets from '/client/Rulesets.js';
import Rule from '/client/Rule.js';
import Expression from '/client/Expression.js';
import * as Common from './reactor-ui-common.js';
import '/common/util.js';  /* global util */

import EmbeddableEditor from './ee.js';
import entitypicker from './entity-picker.js';

import { _T, _LD, locale_time, locale_date, locale_datetime,
    getMonthAbbrevNames, getWeekdayAbbrevNames } from './i18n.js';

import "/common/lexp.js";  /* global lexp */

/* Frequently used */
const isEmpty = Common.isEmpty;
const idSelector = Common.idSelector;
const coalesce = Common.coalesce;

const varRefPattern = /^\$\{\{/;
const truePattern = /^(1|true|t|yes|y|on)$/i;
const falsePattern = /^(0|false|f|no|n|off)$/i;
const CLIPBOARD = "condition-clipboard";

export const condTypeName = {
    "comment": _T(['#cond-comment', "Comment"]),
    "group": _T(['#cond-group', "Group"]),
    "entity": _T(['#cond-entity', "Entity Attribute"]),
    "var": _T(['#cond-var', "Variable Value"]),
    "rule": _T(['#cond-rule', "Rule State"]),
    "weekday": _T(['#cond-weekday', "Week Day"]),
    "trange": _T(['#cond-trange', "Date/Time"]),
    "sun": _T(['#cond-sun', "Sunrise/Sunset"]),
    "interval": _T(['#cond-interval', "Interval"]),
    "startup": _T(['#cond-startup', "System Startup"]),
    "script": _T(['#cond-script', "Script"])
};

/** Options by condition type. Note: default true for the following: hold, pulse, latch */
const condOptions = {
    "group": { sequence: true, duration: true, repeat: true },
    "entity": { sequence: true, duration: true, repeat: true },
    "var": { sequence: true, duration: true, repeat: true },
    "rule": { sequence: true, duration: true, repeat: true },
    "weekday": { },
    "sun": { sequence: true },
    "trange": { },
    "interval": { pulse: false, latch: false, conditions: false },
    "startup": { duration: true, conditions: false },
    "script": { sequence: true, duration: true, repeat: true }
};

/** Option availability by condition type and op. This is used in preference to condOptions above
 *  if there's a key that matches the current condition (type + operator)
 */
const condOptionsDetail = {
    "entity+change": { sequence: true, duration: false, repeat: true, pulse: true },
    "var+change": { sequence: true, duration: false, repeat: true, pulse: true }
};

export const monthNames = getMonthAbbrevNames(); /* i18n */
export const weekdayNames = getWeekdayAbbrevNames(); /* i18n */

export const solarNames = [ 'sunrise', 'sunset', 'civdawn', 'civdusk', 'nautdawn', 'nautdusk', 'astrodawn', 'astrodusk',
    'solarnoon' ].map( n => ( { 'id': n, 'name': _T(n) } ) );
export const solarNamesIndex = {};
solarNames.forEach( n => { solarNamesIndex[ n.id ] = n.name; } );

export const opName = {
    "bet": _T(['#cond-op-bet', "between"]),
    "nob": _T(['#cond-op-nob', "not between"]),
    "after": _T(['#cond-op-after', "after"]),
    "before": _T(['#cond-op-before', "before"])
};

const meridien_ante = _LD.time_meridiem.ante || 'am'; /* ??? i18n: needs improvement */
const meridien_post = _LD.time_meridiem.post || 'pm';

const alltypes = "ui1,i1,ui2,i2,ui4,i4,ui8,i8,int,uint,real";

export const serviceOps = [
    { op: '=', desc: '==', args: 1, optional: 1, not_types: "bool" },
    { op: '<>', desc: '<>', args: 1, optional: 1, not_types: "bool" },
    { op: '<', desc: '<', args: 1, numeric: 1, types: alltypes },
    { op: '<=', desc: '<=', args: 1, numeric: 1, types: alltypes },
    { op: '>', desc: '>', args: 1, numeric: 1, types: alltypes },
    { op: '>=', desc: '>=', args: 1, numeric: 1, types: alltypes },
    { op: 'bet', desc: _T(['#cond-op-bet', "between"]), args: 2, numeric: 1, format: ['#cond-desc-between','{0} and {1}'], types: alltypes },
    { op: 'nob', desc: _T(['#cond-op-nob', "not between"]), args: 2, numeric: 1, format: ['#cond-desc-between','{0} and {1}'], types: alltypes },
    { op: 'starts', desc: _T(['#cond-op-starts', 'starts with']), args: 1, types: "string" },
    { op: 'notstarts', desc: _T(['#cond-op-notstarts', 'does not start with']), args: 1, types: "string"  },
    { op: 'ends', desc: _T(['#cond-op-ends', 'ends with']), args: 1, types: "string" },
    { op: 'notends', desc: _T(['#cond-op-notends', 'does not end with']), args: 1, types: "string" },
    { op: 'contains', desc: _T(['#cond-op-contains', 'contains']), args: 1, types: "string" },
    { op: 'notcontains', desc: _T(['#cond-op-notcontains', 'does not contain']), args: 1, types: "string" },
    { op: 'in', desc: _T(['#cond-op-in', 'in']), args: 1, types: "string" },
    { op: 'notin', desc: _T(['#cond-op-notin', 'not in']), args: 1, types: "string" },
    { op: 'istrue', desc: _T(['#cond-op-istrue', 'is TRUE']), args: 0, nocase: false, types: "bool" },
    { op: 'isfalse', desc: _T(['#cond-op-isfalse', 'is FALSE']), args: 0, nocase: false, types: "bool" },
    { op: 'isnull', desc: _T(['#cond-op-isnull', 'is NULL']), args: 0, nocase: false },
    { op: 'nottrue', desc: _T(['#cond-op-nottrue', 'is NOT TRUE']), args: 0, nocase: false, types: "bool" },
    { op: 'notfalse', desc: _T(['#cond-op-notfalse', 'is NOT FALSE']), args: 0, nocase: false, types: "bool" },
    { op: 'isval', desc: _T(['#cond-op-isval', 'is not NULL']), args: 0, nocase: false },
    { op: 'empty', desc: _T(['#cond-op-empty', 'is EMPTY']), args: 0, nocase: false, not_types: `bool,${alltypes}` },
    { op: 'notempty', desc: _T(['#cond-op-notempty', 'is not EMPTY']), args: 0, nocase: false, not_types: `bool,${alltypes}` },
    { op: 'change', desc: _T(['#cond-op-change', 'changes']), args: 2, format: ['#cond-desc-changes','from {0} to {1}'], optional: 2, blank: _T("(any)"), constraints: false }
    // { op: 'update', desc: 'updates', args: 0, nocase: false }
];

const serviceOpIndex = {}; serviceOps.forEach( function( op ) { serviceOpIndex[op.op] = op; } );

export class RuleEditor extends EmbeddableEditor {
    constructor( root, $container, options ) {
        super( root, $container, options );

        this.$editor.addClass( "re-rule-editor" );

        this.cond_index = false;
    }

    toString() {
        return "RuleEditor";
    }

    revert() {
        super.revert();
        this.reindex();
    }

    reset( d, options ) {
        super.reset( d, options );
        this.reindex();
    }

    notifySaved() {
        super.notifySaved();
        $( '.tbmodified', this.$editor ).removeClass( 'tbmodified' );
        this.cond_index = false;
    }

    isEmpty() {
        return 0 === ( this.data.conditions || [] ).length;
    }

    /* Override of redraw() below */

    /* -------------------- IMPLEMENTATION SPECIFICS ---------------------- */

    indicateError( $el, msg ) {
        $el.addClass('is-invalid').removeClass('is-valid');
        if ( msg ) {
            // $('<div class="invalid-tooltip"></div>').text(msg).insertAfter( $el );
            $el.attr( 'title', msg );
        }
    }

    /** Check field and add error indicator if test expression is true. Return true if
     *  there was no error, false if there was.
     */
    fieldCheck( fails, $el, msg ) {
        if ( fails ) {
            this.indicateError( $el, msg );
        }
        return !fails;
    }

    clone( obj ) {
        return Common.clone( obj );
    }

    divwrap( $el ) {
        return $( '<div></div>' ).append( $el );
    }

    reindex() {
        const self = this;
        self.cond_index = {};
        const rx = function( node, d ) {
            node.__depth = d;
            self.cond_index[node.id] = node;
            const nv = (node.conditions || []).length;
            for ( let k=0; k<nv; k++ ) {
                const m = node.conditions[k];
                m.__parent_id = node.id;
                m.__index = k;
                self.cond_index[m.id] = m;
                if ( self.isGroup( m ) ) {
                    rx( m, d+1 );
                }
            }
        };
        rx( self.data, 0 );
    }

    getCondition( id ) {
        if ( ! this.cond_index ) {
            this.reindex();
        }
        return this.cond_index[ id ] || false;
    }

    /**
     *  Horrible, but temporary until interval with condition is deprecated out */
    static findCondition( rule, condid ) {
        let ix = {};
        Common.traverse( rule.triggers, function( node ) {
            ix[ node.id ] = node;
        });
        Common.traverse( rule.constraints, function( node ) {
            ix[ node.id ] = node;
        });
        return ix[ condid ];
    }

    static isGroup( cond ) {
        return "group" === (cond.type || "group");
    }

    isGroup( cond ) {
        return RuleEditor.isGroup( cond );
    }

    isRoot( cond ) {
        return this.isGroup( cond ) && "undefined" === typeof cond.__parent_id;
    }

    /* Return true if the grp (id) is an ancestor of condition (id) */
    isAncestor( groupID, condID ) {
        const c = this.getCondition( condID );
        if ( ! c.__parent_id ) {
            return false; /* root has no parent */
        }
        if ( c.__parent_id === groupID ) {
            return true;
        }
        /* Move up tree looking for matching group */
        return this.isAncestor( groupID, c.__parent_id );
    }

    /* Return true if node (id) is a descendent of group (id) */
    isDescendent( nodeID, groupID ) {
        const g = this.getCondition( groupID );
        /* Fast exit if our anchor condition isn't a group (only groups have descendents) */
        if ( ! this.isGroup( g ) ) {
            return false;
        }
        const l = g.conditions ? g.conditions.length : 0;
        for ( let k=0; k<l; k++ ) {
            if ( nodeID === g.conditions[k].id ) {
                return true;
            }
            if ( this.isGroup( g.conditions[k] ) && this.isDescendent( nodeID, g.conditions[k].id ) ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Renumber group conditions.
     */
    reindexConditions( grp ) {
        const self = this;
        const $el = $( 'div#' + idSelector( grp.id ) + '.cond-container.cond-group', this.$editor )
            .children( 'div.cond-group-body' ).children( 'div.cond-list' );
        let ix = 0;
        grp.conditions.length = 0; /* empty in place */
        $el.children().each( ( n, row ) => {
            const id = $( row ).attr( 'id' );
            const obj = self.getCondition( id );
            if ( obj ) {
                // console.log("reindexConditions(" + grp.id + ") " + id + " is now " + ix);
                $( row ).removeClass( 'level' + String( obj.__depth || 0 ) ).removeClass( 'levelmod0 levelmod1 levelmod2 levelmod3' );
                grp.conditions[ix] = obj;
                obj.__parent_id = grp.id;
                obj.__index = ix++;
                if ( self.isGroup( obj.type ) ) {
                    obj.__depth = grp.__depth + 1;
                    $( row ).addClass( 'level' + obj.__depth ).addClass( 'levelmod' + (obj.__depth % 4) );
                }
            } else {
                /* Not found. Remove from UI */
                $( row ).remove();
            }
        });
    }

    checkOperandField( $params, $el, op, val ) {
        if ( "string" === typeof val && val.match( varRefPattern ) ) {
            /* Var ref OK, no further checks */
        } else {
            const attr_type = $params.data( 'attr-type' ) || "";
            if ( attr_type.match( /^bool/i ) ) {
                if ( "string" === typeof val ) {
                    let bt = null === val.match( truePattern );
                    let bf = null === val.match( falsePattern );
                    if ( this.fieldCheck( bt === bf, $el, _T('Invalid boolean value.') ) ) {
                        val = !bt;
                    }
                } else if ( "number" === typeof val ) {
                    val = 0 !== val;
                } else {
                    this.fieldCheck( true, $el, _T('Invalid boolean value.') );
                }
            } else if ( attr_type.match( /u?(int|i[1248])/ ) ) {
                const n = parseInt( val );
                if ( this.fieldCheck( Number.isNaN( n ), $el, _T('Must be integer.') ) ) {
                    /* OK */
                    val = n;
                }
            } else if ( "real" === attr_type || op?.numeric ) {
                const n = parseFloat( val );
                if ( this.fieldCheck( Number.isNaN( n ), $el, _T('Must be numeric.') ) ) {
                    /* OK */
                    val = n;
                }
            } else {
                val = String( val );
            }
        }

        return val;
    }

    checkBasicOperands( $row, op, cond ) {
        //console.log("checkBasicOperands()", $row);
        const $params = $( 'div.params', $row );
        let val;
        let $el = $( 'input.nocase', $row );
        const typ = $params.data( 'attr-type' ) || "string";
        if ( 0 === ( op.numeric || 0 ) && "string" === typ && false !== op.nocase ) {
            /* Case-insensitive (nocase==true) is the default */
            val = $el.prop( 'checked' );
            if ( val !== ("undefined" === typeof cond.data.nocase) ) {
                if ( val ) {
                    delete cond.data.nocase;
                } else {
                    cond.data.nocase = false;
                }
                this.signalModified();
            }
        } else if ( undefined !== cond.data.nocase ) {
            delete cond.data.nocase;
            this.signalModified();
        }
        delete cond.data.value2; /* ??? remove later, only .value is used */
        if ( op.args > 1 ) {
            /* First value */
            const $el1 = $( 'input#' + idSelector( cond.id + '-val1' ), $params );
            let v1 = ( $el1.val() || "" ).trim();
            if ( this.fieldCheck( isEmpty( v1 ) && (op.optional || 0) < 1, $el1, _T('Required value.') ) ) {
                v1 = isEmpty( v1 ) ? null : this.checkOperandField( $params, $el1, op, v1 );
            } else {
                v1 = null;
            }
            val = [ v1 ];

            /* Second */
            const $el2 = $( 'input#' + idSelector( cond.id + '-val2' ), $params );
            let v2 = ( $el2.val() || "" ).trim();
            if ( this.fieldCheck( isEmpty( v2 ) && (op.optional || 0) < 2, $el2, _T('Required value.') ) ) {
                v2 = isEmpty( v2 ) ? null : this.checkOperandField( $params, $el2, op, v2 );
            } else {
                v2 = null;
            }
            val.push( v2 );

            /* Shrink (trim nulls). There are quicker ways for large arrays, but this one tops out at just 2 elements. */
            while ( val.length && null === val.at( -1 ) ) {
                val.splice( -1 );
            }

            if ( ! util.deepCompare( val, cond.data.value ) ) {
                cond.data.value = val;
                this.signalModified();
            }
        } else if ( 1 === op.args ) {
            $el = $("input.operand", $params);
            val = ( $el.val() || "" ).trim();
            if ( this.fieldCheck( isEmpty( val ) && (op.optional || 0) < 1, $el, _T('Required value.') ) ) {
                val = isEmpty( val ) ? null : this.checkOperandField( $params, $el, op, val );
            } else {
                val = null;
            }
            if ( val !== cond.data.value ) {
                cond.data.value = val;
                this.signalModified();
            }
        } else {
            if ( undefined !== cond.data.value ) {
                this.signalModified();
            }
            delete cond.data.value;

        }
    }

    /**
     * Update row structure from current display data.
     */
    updateConditionRow( $row, target ) {
        const condId = $row.attr("id");
        const cond = this.getCondition( condId );
        const typ = $row.hasClass( "cond-cond" ) ? $("select.re-condtype", $row).val() || "comment" : "group";
        cond.type = typ;
        $( '.tberror', $row ).removeClass( 'tberror' );
        $row.removeClass( 'tberror' );
        $( '.is-invalid', $row ).removeClass( 'is-invalid' ).attr( 'title', '' );
        $( 'div.invalid-tooltip', $row ).remove();
        let val, $el;
        switch (typ) {
            case 'group':
                delete cond.data;
                if ( 0 === ( cond.conditions || [] ).length ) {
                    $row.addClass( 'tberror' );
                }
                break;

            case 'comment':
                $el = $( "div.params textarea.re-comment", $row );
                cond.data = { comment: $el.val() || "" };
                $el.toggleClass( 'tberror', isEmpty( cond.data.comment ) );
                break;

            case 'entity':
            case 'var':
                {
                    cond.data = {};
                    if ( 'var' === cond.type ) {
                        $el = $( "div.params select.exprmenu", $row );
                        let varname = $el.val() || "";
                        if ( this.fieldCheck( isEmpty( varname ), $el, _T('Required value.') ) ) {
                            let m = varname.split( /:/ );
                            if ( m.length > 1 ) {
                                /* Rule-based */
                                cond.data.rule = m.shift();
                                cond.data.var = m.shift();
                            } else {
                                cond.data.var = varname;
                            }
                        }
                    } else {
                        $el = $( 'div.entity-selector', $row );
                        cond.data.entity = entitypicker.getEntity( $el );
                        if ( this.fieldCheck( isEmpty( cond.data.entity ), $el, _T('Required value.') ) ) {
                            const $am = $( "div.params select.varmenu", $row );
                            cond.data.attribute = $am.val() || "";
                            this.fieldCheck( isEmpty( cond.data.attribute ), $am, _T('Required value.') );
                        } else {
                            delete cond.data.attribute;
                        }
                    }

                    cond.data.op = $("div.params select.opmenu", $row).val() || "=";
                    const op = serviceOps.find( v => v.op === cond.data.op ) || serviceOps[0];
                    this.checkBasicOperands( $row, op, cond );
                }
                break;

            case 'rule':
                {
                    $el = $( 'select.rulemenu', $row );
                    this.fieldCheck( isEmpty( $el.val() ), $el, _T('Required value.') );
                    cond.data = { rule: $el.val() || "" };
                    cond.data.op = $("div.params select.opmenu", $row).val() || "istrue";
                    const op = serviceOps.find( v => v.op === cond.data.op ) || serviceOps[0];
                    this.checkBasicOperands( $row, op, cond );
                }
                break;

            case 'weekday':
                cond.data = { op: $("div.params select.wdcond", $row).val() || "" };
                cond.data.days = [];
                $("input.wdopt:checked", $row).each( ( ix, control ) => {
                    cond.data.days.push( parseInt( control.value /* DOM element */ ) );
                });
                break;

            case 'trange':
                {
                    cond.data = { "op": $("div.params select.opmenu", $row).val() || "bet" };
                    const between = "bet" === cond.data.op || "nob" === cond.data.op;
                    if ( target !== undefined && target.hasClass( 'year' ) ) {
                        const pdiv = target.closest('div');
                        const newval = target.val().trim();
                        if ( this.fieldCheck( !isEmpty( newval) && ( (!newval.match( /^[0-9]+$/ )) || newval < 1970 || newval > 2199 ),
                            target, _T('Must be in numeric range 1970-2199') ) ) {
                            let losOtros;
                            if ( pdiv.hasClass('start') ) {
                                losOtros = $('div.re-endfields input.year', $row);
                            } else {
                                losOtros = $('div.re-startfields input.year', $row);
                            }
                            if ( newval === "" && losOtros.val() !== "" ) {
                                losOtros.val("");
                            } else if ( newval !== "" && losOtros.val() === "" ) {
                                losOtros.val(newval);
                            }
                        }
                    }
                    cond.data.start = {};
                    cond.data.start.hr = parseInt( $("div.re-startfields select.hourmenu", $row).val() || "0" ) || 0;
                    cond.data.start.min = parseInt( $("div.re-startfields select.minmenu", $row).val() || "0" ) || 0;
                    if ( between ) {
                        cond.data.end = {};
                        cond.data.end.hr = parseInt( $("div.re-endfields select.hourmenu", $row).val() || "0" ) || 0;
                        cond.data.end.min = parseInt( $("div.re-endfields select.minmenu", $row).val() || "0" ) || 0;
                        $( 'div.re-endfields', $row ).removeClass( 'd-none' );
                    } else {
                        $( 'div.re-endfields', $row ).removeClass( 'tberror' ).addClass( 'd-none' );
                    }
                    const dom = $( 'div.re-startfields select.daymenu', $row ).val() || "";
                    if ( isEmpty( dom ) ) {
                        /* Start day is blank. So must be end day if present. */
                        $( 'select.daymenu', $row ).val( "" );
                        $( 'div.re-endfields select.daymenu', $row ).hide();
                        /* Months out */
                        $( 'select.monthmenu', $row ).val( "" );
                    } else {
                        cond.data.start.day = parseInt( dom );
                        if ( between ) {
                            /* Between with start day, end day must also be specified. */
                            $el = $( 'div.re-endfields select.daymenu', $row );
                            $el.show();
                            let edom = $el.val() || "";
                            if ( isEmpty( edom ) ) {
                                $el.val( dom );
                                edom = dom;
                            }
                            cond.data.end.day = parseInt( edom );
                        }
                    }

                    $( '.monthmenu', $row ).toggle( ! isEmpty( dom ) );
                    const mon = $("div.re-startfields select.monthmenu", $row).val() || "";
                    if ( isEmpty( mon ) ) {
                        /* Ending month must also be blank, if present. */
                        $( 'select.monthmenu', $row ).val( "" );
                        $( 'div.re-endfields select.monthmenu', $row ).hide();
                        /* Force blank years */
                        $( '.datespec', $row ).val( "" );
                    } else {
                        /* Month specified, year becomes optional, but either both
                           years must be specified or neither for between/not. */
                        cond.data.start.mon = parseInt( mon );
                        if ( between ) {
                            $el = $( 'div.re-endfields select.monthmenu', $row );
                            $el.show();
                            let emon = $el.val() || "";
                            if ( isEmpty( emon ) ) {
                                $el.val( mon );
                                emon = mon;
                            }
                            cond.data.end.mon = parseInt( emon );
                        }
                    }

                    $( '.datespec', $row ).toggle( ! isEmpty( mon ) );
                    $el = $( 'div.re-startfields input.year', $row );
                    let y1 = $el.val() || "";
                    if ( isEmpty( y1 ) ) {
                        $( 'input.year', $row ).val( "" );
                        $( 'div.re-endfields input.year', $row ).hide();
                    } else {
                        y1 = parseInt( y1 );
                        if ( this.fieldCheck( isNaN(y1) || y1 < 2018 || y1 > 2100, $el, 'Invalid year; range 2018 - 2100' ) ) {
                            cond.data.start.year = y1;
                            if ( between ) {
                                let $m2 = $( 'div.re-endfields input.year', $row );
                                $m2.show();
                                let y2 = $m2.val() || "";
                                if ( isEmpty( y2 ) ) {
                                    $m2.val( y1 );
                                    y2 = y1;
                                }
                                if ( this.fieldCheck( isNaN( y2 ) || y2 < y1 || y2 > 2100, $m2, _T('Invalid year') ) ) {
                                    cond.data.end.year = y2;
                                }
                            }
                        }
                    }
                    cond.data.tzo = new Date().getTimezoneOffset();
                }
                break;

            case 'sun':
                {
                    cond.data = { "tzo": new Date().getTimezoneOffset() };
                    cond.data.op = $('div.params select.opmenu', $row).val() || "after";
                    cond.data.start = $('div.params select.re-sunstart', $row).val() || "sunrise";
                    $el = $('div.params input.re-startoffset', $row);
                    let offset = Common.getInteger( $el.val() || "0" );
                    if ( !this.fieldCheck( isNaN( offset ), $el, _T('Must be numeric.') ) ) {
                        offset = 0;
                    }
                    cond.data.start_offset = offset * parseInt( $( 'select.re-startoffs-dir', $row ).val() || "1" );
                    if ( cond.data.op === "bet" || cond.data.op === "nob" ) {
                        $( 'div.re-endfields', $row ).removeClass( 'd-none' );
                        cond.data.end = $('select.re-sunend', $row).val() || "sunset";
                        $el = $('input.re-endoffset', $row);
                        offset = Common.getInteger( $el.val() || "0" );
                        if ( !this.fieldCheck( isNaN( offset ), $el, _T('Must be numeric.') ) ) {
                            offset = 0;
                        }
                        cond.data.end_offset = offset * parseInt( $( 'select.re-endoffs-dir', $row ).val() || "1" );
                    } else {
                        $( 'div.re-endfields', $row ).removeClass( 'tberror' ).addClass( 'd-none' );
                        delete cond.data.end;
                        delete cond.data.end_offset;
                    }
                }
                break;

            case 'interval':
                {
                    cond.data = {};
                    let nmin = 0;
                    $el = $('div.params .re-days', $row);
                    let v = $el.val() || "0";
                    cond.data.days = 0;
                    if ( this.fieldCheck( v.match( varRefPattern ) ), $el,
                            _T('Substitution not permitted here.') ) {
                        v = Common.getOptionalInteger( v, 0 );
                        if ( this.fieldCheck( isNaN(v) || v < 0, $el, _T('Must be integer >= 0') ) ) {
                            cond.data.days = v;
                            nmin = nmin + 1440 * v;
                        }
                    }
                    if ( "number" === typeof cond.data.days && 0 !== cond.data.days ) {
                        $('div.params .re-hours,.re-mins', $row).prop('disabled', true).val("0");
                        cond.data.hours = 0;
                        cond.data.mins = 0;
                    } else {
                        $('div.params .re-hours, div.params .re-mins', $row).prop('disabled', false);
                        $el = $('div.params .re-hours', $row);
                        v = $el.val() || "0";
                        cond.data.hours = 0;
                        if ( this.fieldCheck( v.match( varRefPattern ), $el, _T('Substitution not permitted here.') ) ) {
                            v = Common.getOptionalInteger( v, 0 );
                            if ( this.fieldCheck( isNaN(v) || v < 0 || v > 23, $el, _T('Must be integer 0-23') ) ) {
                                cond.data.hours = v;
                                nmin = nmin + 60 * v;
                            }
                        }
                        $el = $('div.params .re-mins', $row);
                        v = $el.val() || "0";
                        cond.data.mins = 0;
                        if ( this.fieldCheck( v.match( varRefPattern ), $el, _T('Substitution not permitted here.') ) ) {
                            v = Common.getOptionalInteger( v, 0 );
                            if ( this.fieldCheck( isNaN(v) || v < 0 || v > 59, $el, _T('Must be integer 0-59') ) ) {
                                cond.data.mins = v;
                                nmin = nmin + v;
                            }
                        }
                        if ( 0 !== nmin ) {
                            $( '.re-days', $row ).prop( 'disabled', true ).val( "0" );
                        } else {
                            $( '.re-days', $row ).prop( 'disabled', false );
                        }
                    }
                    if ( nmin <= 0 ) {
                        $( 'div.params .re-days,.re-hours,.re-mins', $row ).addClass( 'is-invalid' );
                    }
                    /* Interval relative to... */
                    v = $( 'div.params select.re-relto', $row ).val() || "";
                    delete cond.data.basedate; /* deprecated data, no longer used */
                    if ( "condtrue" === v ) {
                        cond.data.relto = v;
                        $el = $( 'div.params select.re-relcond', $row);
                        cond.data.relcond = $el.val() || "";
                        this.fieldCheck( "" === cond.data.relcond, $el,
                            _T('Please select a condition; if none listed, none are eligible and you cannot use this mode.') );
                        delete cond.data.basetime;
                    } else {
                        cond.data.basetime = {
                            hr: parseInt( $( 'div.params select.re-relhour', $row ).val() ) || 0,
                            min: parseInt( $( 'div.params select.re-relmin', $row ).val() )|| 0
                        };
                        $el = $( 'div.params input.re-relyear', $row );
                        let ry = $el.val() || "";
                        if ( isEmpty( ry ) ) {
                            $( '.re-reldate', $row ).prop( 'disabled', true );
                        } else {
                            $( '.re-reldate', $row ).prop( 'disabled', false );
                            ry = parseInt( ry );
                            if ( this.fieldCheck( isNaN( ry ) || ry < 2018 || ry > 2100, $el, _T('Invalid year; range 2018 - 2100') ) ) {
                                cond.data.basetime.year = ry;
                                cond.data.basetime.mon = parseInt( $( 'div.params select.re-relmon', $row ).val() ) || 0;
                                cond.data.basetime.day = parseInt( $( 'div.params select.re-relday', $row ).val() ) || 1;
                            }
                        }
                        delete cond.data.relcond;
                        delete cond.data.relto;
                    }
                }
                break;

            case 'startup':
                /* No fields */
                cond.data = {};
                break;

            case 'script':
                $el = $( "div.params textarea.re-script-expr", $row );
                cond.data = { script: ( $el.val() || "" ).trim() };
                if ( this.fieldCheck( isEmpty( cond.data.script ), $el, _T('Script/expression required.') ) && lexp ) {
                    try {
                        lexp.compile( cond.data.script );
                        $( 'div.re-script-err', $row ).remove();
                    } catch ( err ) {
                        this.fieldCheck( true, $el, err.message );
                        $( '<div class="re-script-err"></div>' )
                            .text( err.message )
                            .appendTo( $( 'div.params', $row ) );
                        console.error( err );
                    }
                }
                break;

            default:
                /* Leave cond.data alone */
                $el = $( 'select.re-condtype', $row );
                this.indicateError( $el, 'Invalid condition/trigger type' );
        }

        /* If condition options are present, check them, too. Otherwise, untouched. */
        let $ct = $row.hasClass( 'cond-group' ) ? $row.children( 'div.condopts' ) : $( 'div.condopts', $row );
        if ( this.options.constraints && this.options.stateless ) {
            delete cond.options;  /* All options have to go in stateless constraints */
        } else if ( $ct.length > 0 ) {

            cond.options = cond.options || {};

            /* Predecessor condition (sequencing) */
            const $pred = $( 'select.re-predecessor', $ct );
            if ( isEmpty( $pred.val() ) ) {
                $( 'input.re-predtime', $ct ).prop( 'disabled', true ).val( "" );
                $( 'input.predmode', $ct ).prop( 'disabled', true );
                if ( undefined !== cond.options.after ) {
                    delete cond.options.after;
                    delete cond.options.aftertime;
                    delete cond.options.aftermode;
                    this.signalModified();
                }
            } else {
                let $f = $( 'input.re-predtime', $ct ).prop( 'disabled', false );
                $( 'input.predmode', $ct ).prop( 'disabled', false );
                let pt = $f.val() || "";
                if ( !pt.match( varRefPattern ) ) {
                    pt = Common.getInteger( pt );
                    if ( ! this.fieldCheck( isNaN( pt ) || pt < 0, $f, _T('Must be integer >= 0') ) ) {
                        pt = 0;
                    }
                }
                const predmode = $( 'input.predmode', $ct ).prop( 'checked' ) ? 0 : 1;
                if ( cond.options.after !== $pred.val() || cond.options.aftertime !== pt ||
                    ( cond.options.aftermode || 0 ) != predmode ) {
                    cond.options.after = $pred.val();
                    if ( "true" === $( $pred.prop( "selectedOptions" ) ).attr( "rule" ) ) {
                        cond.options.afterrule = true;
                    } else {
                        delete cond.options.afterrule;
                    }
                    cond.options.aftertime = pt;
                    if ( predmode === 1 ) {
                        cond.options.aftermode = true;
                    } else {
                        delete cond.options.aftermode;
                    }
                    this.signalModified();
                }
            }

            /* Repeats */
            let $rc = $('input.re-repeatcount', $ct);
            val = $rc.val() || "";
            if ( isEmpty( val ) || $rc.prop( 'disabled' ) ) {
                $('input.re-duration', $ct).prop('disabled', false);
                $('select.re-durop', $ct).prop('disabled', false);
                $('input.re-repeatspan', $ct).val("").prop('disabled', true);
                if ( undefined !== cond.options.repeatcount ) {
                    delete cond.options.repeatcount;
                    delete cond.options.repeatwithin;
                    this.signalModified();
                }
            } else {
                if ( ! val.match( varRefPattern ) ) {
                    val = Common.getInteger( val );
                    if ( ! this.fieldCheck( isNaN( val ) || val < 2, $rc, _T('Must be integer > 1') ) ) {
                        val = 2;
                    }
                }
                if ( val !== cond.options.repeatcount ) {
                    cond.options.repeatcount = val;
                    delete cond.options.duration;
                    delete cond.options.duration_op;
                    this.signalModified();
                }
                $('input.re-duration', $ct).val("").prop('disabled', true);
                $('select.re-durop', $ct).val("ge").prop('disabled', true);
                $('input.re-repeatspan', $ct).prop('disabled', false);
                $rc = $( 'input.re-repeatspan', $ct );
                val = $rc.val() || "";
                if ( Common.isEmpty( val ) ) {
                    val = 60;
                    $rc.val( val );
                } else if ( ! val.match( varRefPattern ) ) {
                    val = Common.getInteger( val );
                    if ( ! this.fieldCheck( isNaN( val ) || val < 1 , $rc, _T('Must be integer > 0') ) ) {
                        val = 60;
                    }
                }
                if ( val !== cond.options.repeatwithin ) {
                    cond.options.repeatwithin = val;
                    this.signalModified();
                }
            }

            /* Duration (sustained for) */
            let $dd = $('input.re-duration', $ct).removeClass( 'tberror' );
            val = $dd.val() || "";
            if ( isEmpty( val ) || $dd.prop('disabled') ) {
                $('input.re-repeatcount', $ct).prop('disabled', false);
                // $('input.re-repeatspan', $ct).prop('disabled', false);
                if ( undefined !== cond.options.duration ) {
                    delete cond.options.duration;
                    delete cond.options.duration_op;
                    this.signalModified();
                }
            } else {
                $( 'input.re-repeatcount', $ct ).val( "" ).prop( 'disabled', true );
                $( 'input.re-repeatspan', $ct ).val( "" ).prop( 'disabled', true );
                delete cond.options.repeatwithin;
                delete cond.options.repeatcount;
                if ( ! val.match( varRefPattern ) ) {
                    val = Common.getInteger( val );
                    if ( ! this.fieldCheck( isNaN( val ) || val < 0, $dd, _T('Must be integer >= 0') ) ) {
                        val = 0;
                    }
                }
                const durop = $('select.re-durop', $ct).val() || "ge";
                if ( val !== ( cond.options.duration || 0 ) || durop !== cond.options.duration_op ) {
                    /* Changed */
                    if ( val === 0 ) {
                        delete cond.options.duration;
                        delete cond.options.duration_op;
                        $( 'input.re-repeatcount', $ct ).prop( 'disabled', false );
                        // $('input.re-repeatspan', $ct).prop('disabled', false);
                    } else {
                        cond.options.duration = val;
                        cond.options.duration_op = durop;
                    }
                    this.signalModified();
                }
            }

            const mode = $( 'input.opt-output:checked', $ct ).val() || "";
            if ( "L" === mode ) {
                /* Latching */
                $( '.followopts,.pulseopts', $ct ).prop( 'disabled', true );
                $( '.latchopts', $ct ).prop( 'disabled', false );
                this.configModified = this.configModified || ( undefined !== cond.options.holdtime );
                delete cond.options.holdtime;
                this.configModified = this.configModified || ( undefined !== cond.options.pulsetime );
                delete cond.options.pulsetime;
                delete cond.options.pulsebreak;
                delete cond.options.pulsecount;

                if ( undefined === cond.options.latch ) {
                    cond.options.latch = true;
                    this.configModified = true;
                }
            } else if ( "P"  === mode ) {
                /* Pulse output */
                $( '.followopts,.latchopts', $ct ).prop( 'disabled', true );
                $( '.pulseopts', $ct ).prop( 'disabled', false );
                this.configModified = this.configModified || ( undefined !== cond.options.holdtime );
                delete cond.options.holdtime;
                $( 'input.re-pulsetime', $ct ).prop( 'disabled', false );
                this.configModified = this.configModified || ( undefined !== cond.options.latch );
                delete cond.options.latch;

                let $f = $( 'input.re-pulsetime', $ct );
                let pulsetime = $f.val() || "";
                if ( isEmpty( pulsetime ) ) {
                    pulsetime = 15; /* force a default */
                    $f.val( pulsetime );
                    this.configModified = this.configModified || pulsetime !== cond.options.pulsetime;
                    cond.options.pulsetime = pulsetime;
                } else if ( pulsetime.match( varRefPattern ) ) {
                    this.fieldCheck( true, $f, _T('Substitution not permitted here.') );
                    pulsetime = 15; /* in this case we don't modify field */
                    this.configModified = this.configModified || pulsetime !== cond.options.pulsetime;
                    cond.options.pulsetime = 15;
                } else {
                    pulsetime = Common.getInteger( pulsetime );
                    if ( this.fieldCheck( isNaN( pulsetime ) || pulsetime <= 0, $f, _T('Must be integer > 0') ) ) {
                        this.configModified = this.configModified || pulsetime !== cond.options.pulsetime;
                        cond.options.pulsetime = pulsetime;
                    }
                }
                const repeats = "repeat" === $( 'select.re-pulsemode', $ct ).val();
                $( "div.re-pulsebreakopts", $ct ).toggleClass( 'd-none', ! repeats );
                if ( repeats ) {
                    $f = $( 'input.re-pulsebreak', $ct );
                    pulsetime = $f.val() || "";
                    if ( this.fieldCheck( pulsetime.match( varRefPattern ), $f, _T('Substitution not permitted here.') ) ) {
                        pulsetime = Common.getInteger( pulsetime );
                        if ( ! this.fieldCheck( isNaN( pulsetime ) || pulsetime <= 0, $f, _T('Must be integer > 0') ) ) {
                            pulsetime = cond.options.pulsetime; /* default equals pulse length */
                        }
                    } else {
                        pulsetime = cond.options.pulsetime; /* default equals pulse length */
                    }
                    this.configModified = this.configModified || pulsetime !== cond.options.pulsebreak;
                    cond.options.pulsebreak = pulsetime;

                    $f = $( 'input.re-pulsecount', $ct );
                    let lim = $f.val() || "";
                    if ( isEmpty( lim ) ) {
                        this.configModified = this.configModified || undefined !== cond.options.pulsecount;
                        delete cond.options.pulsecount;
                    } else {
                        if ( this.fieldCheck( lim.match( varRefPattern ), $f, _T('Substitution not permitted here.') ) ) {
                            lim = Common.getInteger( lim );
                            if ( ! this.fieldCheck( isNaN( lim ) || lim < 0, $f, 'Must be integer >= 0' ) ) {
                                lim = 0;
                            }
                        } else {
                            lim = 0;
                        }
                        if ( 0 === lim && cond.options.pulsecount ) {
                            this.configModified = this.configModified || undefined !== cond.options.pulsecount;
                            delete cond.options.pulsecount;
                        } else if ( lim !== cond.options.pulsecount ) {
                            cond.options.pulsecount = lim;
                            this.configModified = true;
                        }
                    }
                } else {
                    if ( undefined !== cond.options.pulsebreak ) {
                        this.configModified = true;
                    }
                    delete cond.options.pulsebreak;
                    delete cond.options.pulsecount;
                }
            } else {
                /* Follow mode (default) */
                $( '.pulseopts,.latchopts', $ct ).prop( 'disabled', true );
                $( '.followopts', $ct ).prop( 'disabled', false );
                this.configModified = this.configModified || ( undefined !== cond.options.pulsetime );
                delete cond.options.pulsetime;
                delete cond.options.pulsebreak;
                delete cond.options.pulsecount;
                this.configModified = this.configModified || ( undefined !== cond.options.latch );
                delete cond.options.latch;

                /* Hold time (delay reset) */
                $dd = $( 'input.re-holdtime', $ct );
                if ( isEmpty( $dd.val() ) ) {
                    /* Empty and 0 are equivalent */
                    this.configModified = this.configModified || ( undefined !== cond.options.holdtime );
                    delete cond.options.holdtime;
                } else {
                    let holdtime = $dd.val();
                    if ( holdtime.match( varRefPattern ) ) {
                        if ( holdtime !== cond.options.holdtime ) {
                            cond.options.holdtime = holdtime;
                            this.configModified = true;
                        }
                    } else {
                        holdtime = Common.getInteger( $dd.val() );
                        if ( ! this.fieldCheck( isNaN( holdtime ) || holdtime < 0, $dd, _T('Must be integer >= 0') ) ) {
                            delete cond.options.holdtime;
                        } else if ( ( cond.options.holdtime || 0 ) !== holdtime ) {
                            if ( holdtime > 0 ) {
                                cond.options.holdtime = holdtime;
                            } else {
                                delete cond.options.holdtime;
                            }
                            this.configModified = true;
                        }
                    }
                }
            }
        }

        /* Options open or not, make sure options expander is highlighted */
        const $optButton = $( $row.hasClass( 'cond-group' ) ? '.cond-group-header > div > button.re-condmore:first' : '.cond-actions > button.re-condmore', $row );
        if ( Common.hasAnyProperty( cond.options ) ) {
            $optButton.addClass( 'attn' );
        } else {
            $optButton.removeClass( 'attn' );
            delete cond.options;
        }

        $row.has('.tberror,.is-invalid').addClass('tberror');
        if ( this.configModified ) {
            this.data.editor_version = version;
            this.signalModified();
        } else {
            this.updateSaveControls();
        }
    }

    /**
     * Handler for row change (generic change to some value we don't otherwise
     * need additional processing to respond to)
     */
    handleConditionRowChange( ev ) {
        const $el = $( ev.currentTarget );
        const $row = $el.closest('div.cond-container');

//console.log('handleConditionRowChange ' + String($row.attr('id')));

        $row.addClass( 'tbmodified' );
        this.signalModified();
        this.updateConditionRow( $row, $el );
        return false;
    }

    handleRuleMenuChange( ev ) {
        const $el = $( ev.currentTarget );
        const $row = $el.closest('div.cond-container');
        this.unsubscribe( false, $row.attr( 'id' ) || "" );
        this.updateCurrentServiceValue( $row );
        return this.handleConditionRowChange( ev );
    }

    handleExprMenuChange( ev ) {
        const $el = $( ev.currentTarget );
        const $row = $el.closest('div.cond-container');
        this.unsubscribe( false, $row.attr( 'id' ) || "" );
        this.updateCurrentServiceValue( $row );
        return this.handleConditionRowChange( ev );
    }

    /**
     * Update current value display for service condition
     */
    async updateCurrentServiceValue( $row ) {
        console.assert( $row.hasClass("cond-cond") );
        const condid = $row.attr( 'id' );
        const typ = $( ".re-condtype", $row ).val() || "";
        const $blk = $( 'div.currval', $row );
        if ( 0 === $blk.length ) {
            this.unsubscribe( false, condid );
            return;
        }
        const self = this;
        switch ( typ ) {
            case "entity":
                {
                    const $ep = $( 'div.entity-selector', $row );
                    const device = entitypicker.getEntity( $ep );
                    const attr = $("select.varmenu", $row).val() || "";
                    if ( ! ( isEmpty( device ) || isEmpty( attr ) ) ) {
                        const e = api.getEntity( device );
                        if ( !e ) {
                            $blk.text( _T("Can't find referenced entity!") );
                            return;
                        }
                        this.subscribe( e, function( event ) {  // eslint-disable-line no-unused-vars
                            self.updateCurrentServiceValue( $row );
                        }, condid );
                        const val = e.hasAttribute( attr ) ? e.getAttribute( attr ) : undefined;
                        if ( undefined === val ) {
                            $blk.text( _T('Current value: not set') )
                                .attr( 'title', _T('This attribute is not present in the entity.') );
                        } else {
                            let vt = null === val ? 'null' : ( Array.isArray( val ) ? 'array-len' : typeof( val ) );
                            let dv = String( val );
                            if ( null === val || "boolean" === vt ) {
                                dv = _T( dv );
                            } else {
                                dv = JSON.stringify( dv );
                            }
                            $blk.text( _T('Current value: ({0}) {1}',
                                _T( ['#data-type-' + vt, vt], Array.isArray( val ) ? val.length : 0 ), dv ) )
                                .attr( 'title', "string" === vt && 0 === val.length ?
                                    _T('The string is blank/empty.') : dv );
                        }
                    } else {
                        $blk.empty().attr( 'title', "" );
                    }
                }
                break;

            case "var":
                {
                    let name = $( ".exprmenu", $row ).val() || "";
                    if ( ! isEmpty( name ) ) {
                        let m = name.split( /:/ );
                        let rid = false;
                        if ( m.length > 1 ) {
                            rid = m.shift();
                            name = m.shift();
                        }
                        let dobj, st;
                        try {
                            if ( rid ) {
                                /* In rule */
                                let rule = await Rule.getInstance( rid );
                                dobj = await rule.getStatesDataObject();
                                st = ( ( dobj.value || {} ).expr || {} )[name];
                            } else {
                                /* Global expression */
                                let exp = await Expression.getInstance( name );
                                dobj = await exp.getStateDataObject();
                                st = dobj.value;
                            }
                        } catch ( err ) {
                            console.log("updateCurrentServiceValue() no",name,rid,":",err);
                            st = false;
                        }
                        if ( st ) {
                            let val = coalesce( st.lastvalue );
                            let vt = null === val ? 'null' : ( Array.isArray( val ) ? 'array-len' : typeof( val ) );
                            let dv = String( val );
                            if ( null === val || "boolean" === vt ) {
                                dv = _T( dv );
                            } else {
                                dv = JSON.stringify( val );
                            }
                            $blk.text( _T('Current value: ({0}) {1}',
                                _T( ['#data-type-' + vt, vt], Array.isArray( val ) ? val.length : 0 ), dv ) )
                                .attr( 'title', "string" === vt && 0 === val.length ?
                                    _T('The string is blank/empty.') : dv );
                        } else {
                            $blk.text( _T('Current value: not set') )
                                .attr( 'title', _T('This expression has not been evaluated or no longer exists.') );
                        }
                        if ( dobj ) {
                            this.subscribe( dobj, function( event ) {  // eslint-disable-line no-unused-vars
                                self.updateCurrentServiceValue( $row );
                            }, condid );
                        }
                    }
                }
                break;

            case "rule":
                {
                    let rid = $( ".rulemenu", $row ).val() || "";
                    try {
                        let rule = await Rule.getInstance( rid );
                        let dobj = await rule.getStatesDataObject();
                        let state = coalesce( ( ( dobj.value || {} ).rule || {} ).evalstate );
                        /* Simplified formatting: evalstate/state is always boolean or null */
                        let vt = null === state ? 'null' : typeof state;
                        let dv = _T( String( state ) );
                        $blk.text( _T('Current value: ({0}) {1}', _T( ['#data-type-' + vt, vt] ), dv ) );
                        this.subscribe( dobj, function( event ) {  // eslint-disable-line no-unused-vars
                            self.updateCurrentServiceValue( $row );
                        }, condid );
                    } catch ( err ) {
                        console.log("updateCurrentServiceValue() cond", condid, "rule", rid, "failed", err );
                        $blk.empty().attr( 'title', "" );
                    }
                }
                break;

            case "script":
                {
                    const dobj = await self.options.contextRule.getStatesDataObject();
                    const st = ( dobj?.value || {} )[condid];
                    if ( st ) {
                        let val = coalesce( st.lastvalue );
                        let vt = null === val ? 'null' : ( Array.isArray( val ) ? 'array-len' : typeof( val ) );
                        let dv = String( val );
                        if ( null === val || "boolean" === vt ) {
                            dv = _T( dv );
                        } else {
                            dv = JSON.stringify( val );
                        }
                        $blk.text( _T('Current value: ({0}) {1}',
                            _T( ['#data-type-' + vt, vt], Array.isArray( val ) ? val.length : 0 ), dv ) )
                            .attr( 'title', "string" === vt && 0 === val.length ?
                                _T('The string is blank/empty.') : dv );
                    } else {
                        $blk.text( _T('Current value: not set') )
                            .attr( 'title', _T('This expression has not been evaluated or no longer exists.') );
                    }
                    if ( dobj ) {
                        this.subscribe( dobj, function( event ) {  // eslint-disable-line no-unused-vars
                            self.updateCurrentServiceValue( $row );
                        }, condid );
                    }
                }
                break;

            default:
                $blk.empty().attr( 'title', "" );
        }
    }

    /* Set up fields for condition based on current operator */
    setUpConditionOpFields( $params, cond ) {
        let vv = [];
        if ( "undefined" !== typeof cond.data.value ) {
            vv = Array.isArray( cond.data.value ) ? cond.data.value : [ cond.data.value ];
        }

        if ( [ "entity", "var", "rule" ].includes( cond.type ) ) {
            let selectedOp = $( 'select.opmenu', $params ).val() || "="; /* Use actual selected op, not what's on cond */
            const op = serviceOps.find( v => v.op === selectedOp ) || serviceOps[0];
            let $inp = $( 'input#' + idSelector( cond.id + '-value' ), $params );
            if ( op.args > 1 ) {
                if ( $inp.length > 0 ) {
                    /* Single input field; change this one for double */
                    $inp.attr( 'id', cond.id + '-val1' ).show();
                } else {
                    /* Already there */
                    $inp = $( 'input#' + idSelector( cond.id + '-val1' ), $params );
                }
                /* Work on second field */
                let $in2 = $( 'input#' + idSelector( cond.id + '-val2' ), $params );
                if ( 0 === $in2.length ) {
                    $in2 = $inp.clone().attr('id', cond.id + '-val2')
                        .off( 'change.reactor' ).on( 'change.reactor', this.handleConditionRowChange.bind(this) )
                        .inputAutogrow( { minWidth: 32, maxWidth: $inp.parent().width() } );
                    $in2.insertAfter( $inp );
                }
                if ( op.optional ) {
                    $inp.attr( 'placeholder', _T('blank=any value') );
                    $in2.attr( 'placeholder', _T('blank=any value') );
                } else {
                    $inp.attr( 'placeholder', "" );
                    $in2.attr( 'placeholder', "" );
                }
                /* Labels. Use the format */
                $( 'label.re-secondaryinput', $params ).remove();
                let fmt = _T( op.format || ( '{},'.repeat( op.args ) ), ...Array(op.args).fill('!!!',0,op.args) );
                let k = 0;
                let $last = false;
                while ( fmt.length > 0 ) {
                    const lbl = fmt.match( /^(.*?)!!!/ ); /* non-greedy matching */
                    if ( null !== lbl ) {
                        let id = idSelector( `${cond.id}-val${k+1}` );
                        $last = $( 'input#' + id, $params );
                        $( '<label class="re-secondaryinput mt-1"></label>' )
                            .attr( 'for', id )
                            .text( lbl[1] )
                            .insertBefore( $last );
                        /* Restore value */
                        $last.val( k >= vv.length ? "" : ( isEmpty( vv[k] ) ? "" : String(vv[k]) ) )
                            .trigger( "autogrow" );
                        /* Arm for next field */
                        fmt = fmt.substring( lbl[0].length );
                        ++k;
                    } else {
                        /* Trailing string? Add as trailing label */
                        if ( fmt.trim().length > 0 && $last ) {
                            $( '<label class="re-secondaryinput"></label>' )
                                .text( fmt )
                                .insertAfter( $last );
                        }
                        break;
                    }
                }
            } else {
                if ( 0 === $inp.length ) {
                    /* Convert double fields back to single */
                    $inp = $( 'input#' + idSelector( cond.id + '-val1' ), $params )
                        .attr( 'id', cond.id + '-value' )
                        .attr( 'placeholder', '' );
                    $( 'input#' + idSelector( cond.id + '-val2' ) + ',label.re-secondaryinput', $params ).remove();
                }
                $inp.val( vv.length > 0 ? ( null === vv[0] ? "" : String(vv[0]) ) : "" );
                if ( 0 === op.args ) {
                    $inp.val( "" ).removeClass( 'tberror' ).hide();
                } else {
                    $inp.show().trigger( "autogrow" );
                }
            }
            const $opt = $( 'div.re-nocaseopt', $params );
            const typ = $params.data( 'attr-type' ) || "string";
            //console.log("params",$params,"typ",typ);
            if ( 0 === ( op.numeric || 0 ) && "string" === typ && false !== op.nocase ) {
                $opt.show();
                /* ??? remapping from old values; remove later */
                if ( cond.data.nocase === "0" || cond.data.nocase === 0 ) {
                    cond.data.nocase = false;
                }
                $( 'input.nocase', $opt ).prop( 'checked', false !== cond.data.nocase );
            } else {
                $opt.hide();
            }
        } else {
            console.log( `Invalid cond type in setUpConditionOpFields(): ${cond.type}` );
            return;
        }
    }

    /**
     * Handler for operator change
     */
    handleConditionOperatorChange( ev ) {
        const $el = $( ev.currentTarget );
        const val = $el.val();
        const $row = $el.closest('div.cond-container');
        const cond = this.getCondition( $row.attr( 'id' ) );

        let p;
        if ( cond.options && Common.hasAnyProperty( cond.options ) ) {
            p = Common.showSysModal( {
                title: _T(['#cond-opchange-title','Operator Change']),
                body: _T(['#cond-opchange-msg','Changing the operator on this condition will remove the non-default condition options currently set. Proceed?']),
                buttons: [{
                    label: _T( 'OK' ),
                    event: "OK",
                    class: "btn-success"
                },{
                    label: _T( 'Cancel' ),
                    close: true,
                    class: "btn-primary"
                }]
            });
        } else {
            p = Promise.resolve( 'OK' );
        }
        p.then( async event => {
            if ( "OK" === event ) {
                /* Remove condition options on operator change. Brute force for now. Eventually,
                 * need to just revise what's displayed, but that's a bigger rework of how it's displayed
                 * (disabled vs not shown), etc.
                 */
                const $co = $row.hasClass( 'cond-group' ) ? $row.children( 'div.condopts' ) : $( 'div.condopts', $row );
                $co.remove();
                delete cond.options;

                cond.data.op = val;
                this.setUpConditionOpFields( $( 'div.params', $row ), cond );
                this.updateConditionRow( $row, $el );
                this.signalModified();
            } else {
                /* Restore display of previously-configured operator */
                $el.val( cond.data.op || "" );
            }
        });
    }

    setValidOpsForType( attr, $params ) {
        const $opmenu = $( 'select.opmenu', $params );
        let t = "string";
        if ( "" === attr ) {
            $( 'option', $opmenu ).prop( 'disabled', false ).show();
            return;
        }
        const eid = entitypicker.getEntity( $( 'div.entity-selector', $params ) );
        let e = api.getEntity( eid );
        let adef;
        if ( e ) {
            adef = e.getAttributeDef( attr );
        }
        if ( ! adef ) {
            /* Not defined; all options available unconditionally */
            console.log( "setValidOpsForType() attr ", attr, " not defined" );
            $( 'option', $opmenu ).prop( 'disabled', false ).show();
            $params.data( 'attr-type', "" ).attr( 'data-attr-type' , "" );
            return;
        } else {
            t = adef.type || "any";
            if ( "boolean" === t ) {
                console.warn( "Attribute def", attr, " type boolean should be bool" );
                t = "bool";
            }
        }

        $params.data( 'attr-type', t ).attr( 'data-attr-type' , t );
        serviceOps.forEach( op => {
            let enab = "any" === t ||
                ( 0 === ( op.types || [] ).length || op.types.indexOf( t ) >= 0 ) &&
                ( 0 === ( op.not_types || [] ).length || op.not_types.indexOf( t ) < 0 );
            if ( false === op.constraints && this.options.constraints ) {
                enab = false;
            }
            $( 'option[value="' + op.op + '"]', $opmenu )
                .prop( 'disabled', !enab ).toggle( enab );
        });
    }

    /**
     * Handler for variable change. Change the displayed current value.
     */
    handleConditionVarChange( ev ) {
        const $el = $( ev.currentTarget );
        const $row = $el.closest( 'div.cond-container' );
        // const cid = $row.attr( 'id' );
        const $params = $el.closest( 'div.params' );

        const newattr = $el.val() || "";
        this.setValidOpsForType( newattr, $params );

        /* Ick. Special handling, but go with it. */
        let op = $( 'select.opmenu', $params ).val();
        op = serviceOps.find( v => v.op === op ) || serviceOps[0];
        const typ = $params.data( 'attr-type' ) || "string";
        $( 'div.re-nocaseopt', $params ).toggle(
            0 === ( op.numeric || 0 ) && "string" === typ && false !== op.nocase
        );

        /* Same closing as handleConditionRowChange() */
        this.updateConditionRow( $row, $el );

        this.signalModified();

        this.updateCurrentServiceValue( $row );
    }

    /**
     * Make a service/variable menu of all state defined for the device. Be
     * brief, using only the variable name in the menu, unless that name is
     * used by multiple services, in which case the last component of the
     * serviceId is added parenthetically to draw the distinction.
     */
    makeVariableMenu( device, variable ) {
        const $el = $('<select class="varmenu form-select form-select-sm"></select>');
        const devobj = api.getEntity( device );
        if ( !devobj ) {
            $( '<option></option>').text( _T('Invalid entity') )
                .prop( 'disabled', true )
                .appendTo( $el );
            Common.menuSelectDefaultInsert( $el, coalesce( variable, "" ) );
            return $el;
        }

        /* First, for every declared capability, add the known attributes */
        let $opt, $xg;
        let any = false;
        let xs = devobj.getAttributes();
        const caps = devobj.getCapabilities();
        /* Show the capabilities in the order they are defined */
        const scap = [ ...caps ];
        scap.sort().forEach( function( cn ) {
            $xg = false;
            const cap = devobj.getCapability( cn );
            const attrs = Object.keys( cap.attributes || {} );
            attrs.sort().forEach( at => {
                if ( !$xg ) {
                    $xg = $( '<optgroup></optgroup>' )
                        .attr( 'label', _T('Capability: {0}', cn ) )
                        .appendTo( $el );
                    if ( cap.deprecated ) {
                        if ( "string" === typeof cap.deprecated ) {
                            $xg.attr( 'label', $xg.attr( 'label' ) + ' -- ' + _T('#deprecated-action-use', cap.deprecated ) );
                        } else {
                            $xg.attr( 'label', $xg.attr( 'label' ) + ' -- ' + _T('#deprecated-action') );
                        }
                    }
                }
                const adef = cap.attributes[ at ] || {};
                const attr = cn + '.' + at;
                $opt = $('<option></option>').val( attr );
                if ( ! devobj.hasCapabilityAttribute( cn, at ) ) {
                    $opt.text( _T('{0} (no value)', attr) );
                } else if ( cap.deprecated || adef.deprecated ) {
                    $opt.text( _T('{0} (DEPRECATED)', attr) );
                } else if ( attr === devobj.getPrimaryAttribute() ) {
                    $opt.text( _T('{0} (primary)', attr) );
                } else {
                    $opt.text( attr );
                }
                $opt.appendTo( $xg );
                delete xs[ attr ];
                any = true;
            });
        });
        $xg = false;
        for ( let a of Object.keys( xs ) ) {
            if ( !$xg ) {
                $xg = $( '<optgroup></optgroup>' ).attr( 'label', _T('Extended Attributes') )
                    .appendTo( $el );
            }
            $opt = $('<option></option>').val( a ).text( a )
                .appendTo( $xg );
            any = true;
        }
        if ( !any ) {
            $( '<option></option>' ).val( "" )
                .text( _T('(no eligible attributes)') )
                .appendTo( $el );
        }

        if ( variable ) {
            Common.menuSelectDefaultInsert( $el, variable );
        } else if ( devobj.getPrimaryAttribute() ) {
            $el.val( devobj.getPrimaryAttribute() );
        } else {
            Common.menuSelectDefaultFirst( $el );
        }

        return $el;
    }

    /**
     * Handler for device change
     */
    handleDeviceChange( ev, result ) {
        console.log("entity picker change notification", result);
        const $row = $( ev.currentTarget ).closest( 'div.cond-container' );
        const newDev = result.id;
        const entity = api.getEntity( newDev );
        const condId = $row.attr( 'id' );
        const cond = this.getCondition( condId );

        if ( cond.data.entity !== newDev ) {
            cond.data.entity = newDev;

            /* Make a new service/variable menu and replace it on the row. */
            const $vm = $( 'select.varmenu', $row );
            const $newMenu = this.makeVariableMenu( cond.data.entity );
            $vm.empty().append( $newMenu.children() );

            /* Reselect current attribute if available, otherwise primary or first. */
            const $prevOpt = $( 'option[value=' + Common.quot( cond.data.attribute ) + ']', $vm );
            if ( 0 !== $prevOpt.length ) {
                $vm.val( cond.data.attribute );
            } else if ( entity.getPrimaryAttribute() ) {
                cond.data.attribute = entity.getPrimaryAttribute();
                $vm.val( cond.data.attribute );
            } else {
                Common.menuSelectDefaultFirst( $vm );
                cond.data.attribute = $vm.val();
            }

            this.setValidOpsForType( $vm.val() || "", $( 'div.params', $row ) );

            this.unsubscribe( false, $row.attr( 'id' ) || "" );
            this.updateCurrentServiceValue( $row );

            this.updateConditionRow( $row ); /* pass it on */

            this.signalModified();
        }
    }

    /**
     * Type menu selection change handler.
     */
    async handleTypeChange( ev ) {
        const $el = $( ev.currentTarget );
        const newType = $el.val();
        const $row = $el.closest( 'div.cond-container' );
        const condId = $row.attr( 'id' );
        const cond = this.getCondition( condId );
        if ( ! cond ) {
            return;
        }

        if ( newType !== cond.type ) {
            /* Change type */
            cond.type = newType;
            cond.data = {};
            delete cond.options;
            await this.setConditionForType( cond, $row );

            $row.addClass( 'tbmodified' );
            this.updateConditionRow( $row );

            this.signalModified();
        }
    }

    /**
     * Handle click on Add Condition button.
     */
    async handleAddConditionClick( ev ) {
        const $el = $( ev.currentTarget );
        const $parentGroup = $el.closest( 'div.cond-container' );
        const parentId = $parentGroup.attr( 'id' );

        /* Create a new condition in data, assign an ID */
        const cond = { id: Common.getUID( 'cond' ), type: "comment", data: { comment: _T('Enter comment text')} }; // ???

        /* Insert new condition in UI */
        const condel = this.getConditionTemplate( cond.id );
        $( 'select.re-condtype', condel ).val( cond.type );
        await this.setConditionForType( cond, condel );
        $( 'div.cond-list:first', $parentGroup ).append( condel );

        /* Add to data */
        const grp = this.getCondition( parentId );
        grp.conditions.push( cond );
        cond.__parent_id = grp.id;
        cond.__rule = grp.__rule;
        this.cond_index[ cond.id ] = cond;
        this.reindexConditions( grp );

        condel.addClass( 'tbmodified' );
        this.updateConditionRow( condel );

        this.signalModified();

        $( 'select.re-condtype', condel ).focus();
    }

    static async makeConditionDescription( rule, cond ) {
        let result = "";
        let op, t;
        console.log("makeConditionDescription", rule, cond);

        switch ( cond.type || "group" ) {
            case 'group':
                result = String(cond.name || cond.id);
                break;

            case 'comment':
                result = String(cond.data.comment);
                break;

            case 'entity':
            case 'var':
                if ( "entity" === cond.type ) {
                    const e = api.getEntity( cond.data.entity );
                    if ( e ) {
                        result = `${e.getName()} (${e.getCanonicalID()}) ${String(cond.data.attribute)}`;
                    } else {
                        result = _T('{0} (missing entity) {1}', cond.data.entity, cond.data.attribute);
                    }
                } else {
                    if ( cond.data.rule ) {
                        if ( cond.data.rule === rule.id ) {
                            result = String(cond.data.var);
                        } else {
                            try {
                                let vrule = await Rule.getInstance( cond.data.rule );
                                result = `(${vrule.name}) ${cond.data.var}`;
                            } catch ( err ) {  // eslint-disable-line no-unused-vars
                                result = _T('(missing rule {0:q}) {1}', cond.data.rule, cond.data.var);
                            }
                        }
                    } else {
                        result = _T('(global expression) {0}', cond.data.var);
                    }
                }
                op = serviceOpIndex[ cond.data.op ] || {};
                result += ' ' + String( op.desc || String(cond.data.op) );
                try {
                    /* Be conservative here */
                    if ( (op.args || 0) > 0 ) {
                        if ( op.args > 1 ) {
                            let vv = [ ...(cond.data.value || []) ];  // must shallow copy!!!
                            vv = Array.isArray( vv ) ? vv : [ vv ];
                            while ( vv.length < op.args ) {
                                vv.push( "" );
                            }
                            vv = vv.map( el => isEmpty( el ) ? ( op.blank || "" ) : el ); /* remap null to empty string */
                            result += ' ' + _T( op.format || "{0},{1}", ...vv );
                        } else {
                            result += ' ' + String( coalesce( cond.data.value, "" ) );
                        }
                    }
                } catch( err ) {
                    console.log("makeConditionDescription() formatting cond", cond, "opdata", op, err);
                    result += ' ' + String( isEmpty( cond.data.value ) ? "" : cond.data.value );
                }
                if ( false !== op.nocase && false === cond.data.nocase ) {
                    result += ' ' + _T('(case-sensitive)');
                }
                break;

            case 'rule':
                try {
                    let r = await Rule.getInstance( cond.data.rule || "" );
                    result = String( r.name || r.id );
                    r = await r.getRuleset();
                    result += ` (${r?.name || "unknown"})`;
                } catch ( err ) {  // eslint-disable-line no-unused-vars
                    result = _T('{0} (missing rule)', String(cond.data.rule));
                }

                op = serviceOpIndex[ cond.data.op ] || {};
                result += " " + (serviceOpIndex[ cond.data.op ] || {}).desc || cond.data.op;
                try {
                    /* Be conservative here */
                    if ( (op.args || 0) > 0 ) {
                        if ( op.args > 1 ) {
                            let vv = [ ...(cond.data.value || []) ];  // must shallow copy!!!
                            vv = Array.isArray( vv ) ? vv : [ vv ];
                            while ( vv.length < op.args ) {
                                vv.push( "" );
                            }
                            vv = vv.map( el => isEmpty( el ) ? ( op.blank || "" ) : el ); /* remap null to empty string */
                            result += ' ' + _T( op.format || "{0},{1}", ...vv );
                        } else {
                            result += ' ' + String( coalesce( cond.data.value, "" ) );
                        }
                    }
                } catch( err ) {
                    console.log("makeConditionDescription() formatting cond", cond, "opdata", op, err);
                    result += ' ' + String( isEmpty( cond.data.value ) ? "" : cond.data.value );
                }
                break;

            case 'sun':
                // '#sun-desc-after': 'at/after [n minutes][before/after] sunset/sunrise...'
                // '#sun-desc-before': 'before [n minutes][before/after] sunrise'
                // '#sun-desc-between': 'from [n minutes][before/after] sunset to [n minutes][before/after] sunrise'
                // '#sun-desc-notbetween': 'not between [n minutes][before/after] sunset to [n minutes][before/after] sunrise'
                result = _T('#sun-desc-' + cond.data.op, cond.data.start || "", cond.data.start_offset || 0,
                    cond.data.end || "", cond.data.end_offset || 0 );
                break;

            case 'weekday':
                t = [];
                (cond.data.days || []).forEach( day => {
                    t.push( weekdayNames[day] );
                });
                result = _T('#weekday-desc-' + cond.data.op || '', t.join(', ') );
                break;

            case 'trange':
                {
                    // '#trange-desc-after': 'at/after 00:00'
                    //                       'at/after every day n each month at 00:00'
                    //                       'at/after every Jan 1 at 00:00'
                    //                       'at/after 2021-Jan-1'
                    let dt = new Date();
                    let start = false;
                    let m = cond.data.start;
                    if ( !isEmpty( m.year ) ) {
                        /* YMD - Use native locale formatter */
                        start = _T( '#trange-DMY', m.year, m.mon, m.day, monthNames[m.mon],
                            locale_date( new Date( m.year, m.mon, m.day ) ) );
                    } else if ( ! isEmpty( m.mon ) ) {
                        /* MD */
                        start = _T( '#trange-DM', m.mon, m.day, monthNames[m.mon],
                            new Date( dt.getFullYear(), m.mon, m.day )
                                .toLocaleDateString( _LD.locale, { month:'short', day:'numeric' } ) );
                    } else if ( ! isEmpty( m.day ) ) {
                        start = _T( '#trange-D', m.day );
                    }
                    t = locale_time( new Date( dt.setHours( m.hr, m.min, 0, 0) ) );
                    start = start ? _T(['#trange-at-date-time','{0} at {1}'], start, t ) : t;

                    let end = false;
                    m = cond.data.end;
                    if ( m && ! isEmpty( m.hr ) ) {
                        if ( !isEmpty( m.year ) ) {
                            /* YMD - Use native locale formatter */
                            end = _T( '#trange-DMY', m.year, m.mon, m.day, monthNames[m.mon],
                                locale_date( new Date( m.year, m.mon, m.day ) ) );
                        } else if ( ! isEmpty( m.mon ) ) {
                            /* MD */
                            end = _T( '#trange-DM', m.mon, m.day, monthNames[m.mon],
                                new Date( dt.getFullYear(), m.mon, m.day )
                                    .toLocaleDateString( _LD.locale, { month:'short', day:'numeric' } ) );
                        } else if ( ! isEmpty( m.day ) ) {
                            end = _T( '#trange-D', m.day );
                        }
                        t = locale_time( new Date(dt.setHours( m.hr, m.min, 0, 0) ) );
                        end = end ? _T(['#trange-at-date-time','{0} at {1}'], end, t ) : t;
                    }
                    result = _T( '#trange-desc-' + cond.data.op, start, end );
                }
                break;

            case 'interval':
                result = _T(['#interval-desc','every {0:d?>0}{0:" days"?>0} {1:d?>0}{1:" hours"?>0} {2:d?>0}{2:" minutes"?>0}'],
                    cond.data.days || 0, cond.data.hours || 0, cond.data.mins );
                if ( "condtrue" === cond.data.relto ) {
                    let xc = RuleEditor.findCondition( rule, cond.data.relcond || "" );
                    if ( xc ) {
                        xc = await RuleEditor.makeConditionDescription( rule, xc );
                    } else {
                        xc = '? missing condition ?';
                    }
                    result += ' ' + _T(['#interval-condrel', '(relative to {0})'], xc );
                } else {
                    let xd;
                    if ( cond.data.basedate ) {
                        /* Deprecated form */
                        xd = locale_datetime( new Date( cond.data.basedate ) );
                    } else {
                        if ( cond.data.basetime?.year ) {
                            xd = locale_datetime( new Date( cond.data.basetime.year, cond.data.basetime.mon,
                                cond.data.basetime.day, cond.data.basetime.hr||0, cond.data.basetime.min||0 ) );
                        } else {
                            xd = new Date();
                            xd.setHours( cond.data.basetime?.hr||0, cond.data.basetime?.min||0, 0, 0 );
                            xd = locale_datetime( xd );
                        }
                    }
                    result += ' ' + _T(['#interval-condrel', '(relative to {0})'], xd );
                }
                break;

            case 'startup':
                result = "";
                break;

            case 'script':
                result = String(cond.data.script);
                break;

            default:
                result = JSON.stringify( cond, Common.clean_json_keys );
        }

        return result;
    }

    /** Make human-readable text for condition options */
    static async makeCondOptionDesc( rule, cond ) {
        const condOpts = cond.options || {};
        const condDesc = [];
        if ( ! isEmpty( condOpts.after ) ) {
            let cd;
            if ( condOpts.afterrule ) {
                try {
                    const rule = await Rule.getInstance( condOpts.after );
                    cd = rule.getName();
                } catch ( err ) {
                    console.log( condOpts.after, err );
                    cd = condOpts.after + "???";
                }
            } else {
                let co = RuleEditor.findCondition( rule, condOpts.after );
                if ( co ) {
                    cd = await RuleEditor.makeConditionDescription( rule, co );
                } else {
                    cd = condOpts.after + "???";
                }
            }
            let t = _T(['#condopt-seq','{0:"within "?>0}{0:d?>0}{0:" secs "?>0}after {1:q}{2:" (which is still TRUE)"?true}'],
                condOpts.aftertime, cd, 0 === ( condOpts.aftermode || 0 ) );
            condDesc.push( t );
        }
        if ( ( condOpts.repeatcount || 0 ) > 1 ) {
            let t = _T(['#condopt-repeat','repeats {0} times within {1} secs'], condOpts.repeatcount,
                condOpts.repeatwithin || 60 );
            condDesc.push( t );
        } else if ( ( condOpts.duration || 0 ) > 0 ) {
            let t = _T(['#condopt-sustain','for {0:"less than "?:lt}{0:"at least "?!:lt}{1} secs'], condOpts.duration_op,
                condOpts.duration);
            condDesc.push( t );
        }
        if ( ( condOpts.holdtime || 0 ) > 0 ) {
            condDesc.push( _T(['#condopt-hold','delay reset for {0} secs'], condOpts.holdtime) );
        }
        if ( 0 !== ( condOpts.pulsetime || 0 ) ) {
            let t = _T(['#condopt-pulse','pulse for {0} secs{1:", repeat after "?>0}{1:d?>0}{1:" secs"?>0}{2:" up to "?>0}{2:d?>0}{2:" times"?>0}'],
                condOpts.pulsetime, condOpts.pulsebreak || 0, condOpts.pulsecount || 0 );
            condDesc.push( t );
        }
        if ( 0 !== ( condOpts.latch || 0 ) ) {
            condDesc.push( _T(['#condopt-latch','latching']) );
        }
        return condDesc.join( '; ' );
    }

    /**
     * Make a menu of (global) rulesets and rules.
     */
    async makeRuleMenu( currval ) {
        const $mm = $( '<select class="rulemenu form-select form-select-sm"></select>' );
        const rulesets = await Rulesets.getRulesets();
        const nrs = rulesets.length;
        for ( let k=0; k<nrs; k++ ) {
            const rs = rulesets[k];
            if ( null === rs.id.match( /^_sys_/ ) ) {
                const nrr = ( rs.rules || [] ).length;
                if ( nrr > 0 ) {
                    const $xg = $( '<optgroup></optgroup>' ).attr( { id: rs.id, label: rs.name } )
                        .appendTo( $mm );
                    for ( let j=0; j<nrr; j++ ) {
                        if ( ! this.options.contextRule || this.options.inReaction ||
                                rs.rules[j] !== this.options.contextRule.id ) {
                            const rule = await Rule.getInstance( rs.rules[j] );
                            $( '<option></option>' ).val( rule.id ).text( rule.name || rule.id )
                                .appendTo( $xg );
                        }
                    }
                }
            }
        }
        if ( currval ) {
            Common.menuSelectDefaultInsert( $mm, coalesce( currval, "" ) );
        } else {
            Common.menuSelectDefaultFirst( $mm );
        }
        return $mm;
    }

    /**
     * Make a menu of defined expressions
     */
    async makeExprMenu( currExpr ) {
        const $el = $( '<select class="exprmenu form-select form-select-sm"></select>' );
        $( '<option></option>' ).val( "" ).text( _T('#opt-choose','--choose--') ).appendTo( $el );

        /* Local (rule) expressions */
        if ( this.options.contextRule ) {
            const tab = this.$editor.closest( '.re-tab-container' ).data( 'tabobject' );
            if ( tab ) {
                const expr_editor = tab.getEditor( "expr_editor" );
                if ( expr_editor ) {
                    const exp = Object.values( expr_editor.current() || {} );
                    exp.sort( ( a, b ) => {
                        if ( a.index === b.index ) {
                            return 0;
                        }
                        return a.index < b.index ? -1 : 1;
                    });
                    let $og = false;
                    for ( let expr of exp ) {
                        if ( ! $og ) {
                            $og = $( '<optgroup></optgroup>' ).attr( 'label', _T('Local Rule Variables') )
                                .appendTo( $el );
                        }
                        $( '<option></option>' ).val( this.options.contextRule.id + ':' + expr.name )
                            .text( expr.name )
                            .appendTo( $og );
                    };
                } else {
                    console.warn( "RuleEditor no expr_editor for", this.$editor );
                }
            } else {
                console.warn( "RuleEditor no tab for", this.$editor );
            }
        }

        /* Global expressions (already an array) */
        const exp = await Expression.getGlobalExpressions();
        let $og = false;
        for ( let expr of exp ) {
            if ( ! $og ) {
                $og = $( '<optgroup></optgroup>' ).attr( 'label', _T('Global Variables') )
                    .appendTo( $el );
            }
            $( '<option></option>' ).val( expr.name ).text( expr.name )
                .appendTo( $og );
        }

if ( false ) {  // eslint-disable-line no-constant-condition
        let rss = await Rulesets.getRulesets();
        await Common.asyncForEach( rss, async (set) => {
            let $rsg = false;
            let rules = await set.getRules();
            rules.forEach( rule => {
                if ( rule.id !== this.options.contextRule.id ) {
                    let $rg = false;
                    let exprlist = Object.values( rule.expressions );
                    exprlist.forEach( expr => {
                        if ( ! $rsg ) {
                            $( '<option class="data-divider"></option>' ).appendTo( $el );
                            $rsg = $( '<optgroup></optgroup>' ).attr( 'label', set.name.toUpperCase() )
                                .appendTo( $el );
                        }
                        if ( ! $rg ) {
                            $rg = $( '<optgroup></optgroup>' ).attr( 'label', rule.name )
                                .appendTo( $el );
                        }
                        $( '<option></option>' ).val( rule.id + ':' + expr.name )
                            .text( expr.name )
                            .appendTo( $rg );
                    });
                }
            });
        });
}

        /* Select current (insert if not present) */
        Common.menuSelectDefaultInsert( $el, currExpr || "" );
        return $el;
    }

    makeServiceOpMenu() {
        const $el = $('<select class="opmenu form-select form-select-sm"></select>');
        serviceOps.forEach( op => {
            $( '<option></option>' ).val( op.op ).text( op.desc || op.op )
                .appendTo( $el );
        });
        return $el;
    }

    makeDateTimeOpMenu( op ) {
        const $el = $('<select class="opmenu form-select form-select-sm"></select>');
        $( '<option></option>' ).val( 'bet' ).text( opName.bet ).appendTo( $el );
        $( '<option></option>' ).val( 'nob' ).text( opName.nob ).appendTo( $el );
        $( '<option></option>' ).val( 'after' ).text( opName.after ).appendTo( $el );
        $( '<option></option>' ).val( 'before' ).text( opName.before ).appendTo( $el );
        Common.menuSelectDefaultInsert( $el, coalesce( op, "bet" ) );
        return $el;
    }

    async handleExpandOptionsClick( ev ) {
        const $row = $( ev.currentTarget ).closest( 'div.cond-container' );
        return await this.doToggleOptions( $row );
    }

    async doToggleOptions( $row ) {
        const has_group = $row.hasClass( 'cond-group' );
        const cond = this.getCondition( $row.attr( "id" ) );
        const $btn = $( 'button.re-condmore:first', $row );

        /* If the options container already exists, just show it. */
        let $container = $( 'div.condopts', $row );
        if ( $container.length > 0 ) {
            /* Container exists and is open, close it, remove it. */
            $container.slideUp({
                complete: function() {
                    $container.remove();
                }
            });
            // $( 'i', $btn ).removeClass.text( 'expand_more' );
            $btn.attr( 'title', _T('Show condition options') );
            if ( $row.hasClass( 'tbautohidden' ) ) {
                $( '.cond-group-title button.re-expand', $row ).click();
                $row.removeClass( 'tbautohidden' );
            }
            return;
        }

        /* Doesn't exist. Create the options container and add options */
        // $( 'i', $btn ).removeClass( 'bi-three-dots' ).addClass( 'bi-box-arrow-up' );
        $btn.attr( 'title', _T('Hide condition options') );
        $container = $( '<div class="condopts"></div>' ).hide();

        let displayed = condOptionsDetail[ ( cond.type || "group" ) + "+" + ( ( cond.data || {} ).op || "" ) ] ||
            condOptions[ cond.type || "group" ] || {};
        let condOpts = cond.options || {};

        if ( this.options.constraints ) {
            /* Constraints have limited options, depending on whether or not they are stateless. For output, follow
             * is always available, but pulse is never available, and latched is not available when stateless. For
             * restrictions, nothing is available when stateless. */
            displayed.pulse = false;
            displayed.sequence = displayed.duration = displayed.repeat = ! this.options.stateless;
            displayed.hold = displayed.latch = ! this.options.stateless;
            if ( this.options.stateless ) {
                condOpts = {};
            } else {
                delete condOpts.pulsetime;
                delete condOpts.pulsebreak;
                delete condOpts.pulsecount;
            }
        }

        /* Options now fall into two general groups: output control, and restrictions. */

        /* Output Control */
        const out = $( '<div></div>', { "id": "outputopt", "class": "tboptgroup" } ).appendTo( $container );
        $( `<div class="opttitle">${_T('Output Control')}</div>` )
            .append( Common.getWiki( 'Condition-Options' ) )
            .appendTo( out );
        let $fs = $( '<div class="opt-fs d-flex flex-row"></div> ').appendTo( out );
        const rid = "output" + Common.getUID( 'radio' );
        let $el = Common.getRadio( rid, 1, "", _T('#condoutput-follow-label'), "opt-output" )
            .addClass( "mt-1" );
        $fs.append( $el ); // already a div

        if ( false !== displayed.hold ) {
            $fs.append( '<div class="mt-1 me-2">;</div>' );
            let p = _T(['#condoutput-form-delayreset','delay reset {0} seconds (0 = no delay)'], "|" ).split( /\|/ );
            let $l = $( '<label class="mt-1 mx-1"></label>' );
            $l.text( p[0] || "" );
            $fs.append( this.divwrap( $l.clone() ) );
            $fs.append( this.divwrap( $( `<input type="number" class="form-control form-control-sm narrow followopts re-holdtime">` ) ) );
            $l.text( p[1] || "" );
            $fs.append( this.divwrap( $l ) );
        }

        if ( false !== displayed.pulse || condOpts.pulsetime ) {
            $fs = $( '<div class="opt-fs d-flex flex-row"></div>' ).appendTo( out );
            $el = Common.getRadio( rid, 2, "P", _T('#condoutput-pulse-label'), "opt-output" ).addClass( "mt-1" );
            $fs.append( $el ); // already a div
            let p = _T(['#condoutput-form-pulsetime','{0} seconds'], "|" ).split( /\|/ );
            let $l = $( '<label class="mt-1 mx-1"></label>' );
            $l.html( p[0] || "" );
            $fs.append( this.divwrap( $l.clone() ) );
            $fs.append( this.divwrap( $( '<input type="number" class="form-control form-control-sm narrow pulseopts re-pulsetime">' ) ) );
            $l.html( p[1] || "" );
            $fs.append( this.divwrap( $l.clone() ) );
            $fs.append( this.divwrap( $( `<select class="form-select form-select-sm pulseopts re-pulsemode">
<option value="">${_T(['#condoutput-pulse-once','once'])}</option>
<option value="repeat">${_T(['#condoutput-pulse-repeat','repeat'])}</option></select>` ) ) );
            let $gr = $( `<div class="re-pulsebreakopts d-flex flex-row"></div>` ).appendTo( $fs );
            p = _T(['#condoutput-form-pulserepeat','after {0} seconds'], "|" ).split( /\|/ );
            $l.html( p[0] || "" );
            $gr.append( this.divwrap( $l.clone() ) );
            $gr.append( this.divwrap( $( '<input type="number" class="form-control form-control-sm narrow pulseopts re-pulsebreak">') ) );
            $l.html( p[1] || "" );
            $gr.append( this.divwrap( $l.clone() ) );
            p = _T(['#condoutput-form-pulselimit','up to {0} times (0/blank=no&nbsp;limit)'], "|" ).split( /\|/ );
            $l.html( p[0] || "" );
            $gr.append( this.divwrap( $l.clone() ) );
            $gr.append( this.divwrap( $( '<input type="number" class="form-control form-control-sm narrow pulseopts re-pulsecount">') ) );
            $l.html( p[1] || "" );
            $gr.append( this.divwrap( $l ) );
        }
        if ( false !== displayed.latch ) {
            $fs = $( '<div class="opt-fs d-flex flex-row"></div>' ).appendTo( out );
            Common.getRadio( rid, 3, "L", _T(['#condoutput-latch',"Latch - output is held true until reset by sibling"]),
                "opt-output" )
                .addClass( "mt-1" )
                .appendTo( $fs );
        }

        /* Restore/configure */
        if ( ( condOpts.pulsetime || 0 ) > 0 ) {
            $( '.pulseopts', out ).prop( 'disabled', false );
            $( 'input#' + idSelector(rid + '2'), out ).prop( 'checked', true );
            $( 'input.re-pulsetime', out ).val( condOpts.pulsetime || 15 );
            $( 'input.re-pulsebreak', out ).val( condOpts.pulsebreak || "" );
            $( 'input.re-pulsecount', out ).val( condOpts.pulsecount || "" );
            const pbo = (condOpts.pulsebreak || 0) > 0;
            $( 'select.re-pulsemode', out ).val( pbo ? "repeat" : "" );
            $( 'div.re-pulsebreakopts', out ).toggleClass( 'd-none', ! pbo );
            $( '.followopts,.latchopts', out ).prop( 'disabled', true );
        } else if ( 0 !== ( condOpts.latch || 0 ) ) {
            $( '.latchopts', out ).prop( 'disabled', false );
            $( 'input#' + idSelector(rid + '3'), out ).prop( 'checked', true );
            $( '.followopts,.pulseopts', out ).prop( 'disabled', true );
            $( 'div.re-pulsebreakopts', out ).addClass( 'd-none' );
        } else {
            $( '.followopts', out ).prop( 'disabled', false );
            $( 'input#' + idSelector(rid + '1'), out ).prop( 'checked', true );
            $( '.latchopts,.pulseopts', out ).prop( 'disabled', true );
            $( 'div.re-pulsebreakopts', out ).addClass( 'd-none' );
            $( 'input.re-holdtime', out ).val( 0 !== (condOpts.holdtime || 0) ? condOpts.holdtime : "" );
        }

        /* Restrictions */
        if ( displayed.sequence || displayed.duration || displayed.repeat ) {
            const rst = $( '<div></div>', { "id": "restrictopt", "class": "tboptgroup" } ).appendTo( $container );
            $( `<div class="opttitle">${_T('Restrictions')}</div>` )
                .append( Common.getWiki( 'Condition-Options' ) )
                .appendTo( rst );
            /* Sequence (predecessor condition) */
            if ( displayed.sequence ) {
                $fs = $( '<div class="opt-fs d-flex flex-row"></div>' ).appendTo( rst );
                const $preds = $( `<select class="form-select form-select-sm re-predecessor ms-1"><option value="">${_T('(any time/no sequence)')}</option></select>`);
                /* Add groups that are not ancestor of condition */
                const self = this;
                let predlist = [];
                Common.traverse( this.data, function( node ) {
                    predlist.push( node );
                }, false, function( node ) {
                    /* If node is not ancestor (line to root) or descendent of cond, allow as predecessor */
                    return "comment" !== node.type && cond.id !== node.id && !self.isAncestor( node.id, cond.id ) && !self.isDescendent( node.id, cond.id );
                });
                $( '<optgroup></optgroup>' ).attr( { label: _T( ['#condrestrict-seq-conds','Eligible Conditions'] ) } )
                    .appendTo( $preds );
                await Common.asyncForEach( predlist, async (node) => {
                    $preds.append( $( '<option></option>' ).val( node.id )
                        .text( await RuleEditor.makeConditionDescription( self.options.contextRule, node ) ) );
                });
                /* Add Rules to list of eligible sequence items */
                const rulesets = await Rulesets.getRulesets();
                for ( let rs of rulesets ) {
                    if ( null === rs.id.match( /^_sys_/ ) ) {
                        if ( rs.rules && rs.rules.length ) {
                            const $xg = $( '<optgroup></optgroup>' ).attr( { id: rs.id, label: rs.name } )
                                .appendTo( $preds );
                            for ( let rl of rs.rules ) {
                                if ( ! this.options.contextRule || rl !== this.options.contextRule.id ) {
                                    const rule = await Rule.getInstance( rl );
                                    $( '<option></option>' ).val( rule.id )
                                        .attr( "rule", "true" )
                                        .text( rule.name || rule.id )
                                        .appendTo( $xg );
                                }
                            }
                        }
                    }
                }
                let p = _T(['#condrestrict-seqmenu','Condition must occur after {0}'], "|" ).split( /\|/ );
                let $l = $( `<label class="mt-1 mx-1"></label>` ).text( p[0] || "" );
                $fs.append( this.divwrap( $l.clone() ) );
                $fs.append( this.divwrap( $preds ) );
                $fs.append( this.divwrap( $l.text( p[1] || "" ).clone() ) );

                p = _T(['#condrestrict-seq-time','within {0} seconds (0 = no time limit)'], "|" ).split( /\|/ );
                $fs.append( this.divwrap( $l.text( p[0] || "" ).clone() ) );
                $fs.append( this.divwrap( $( '<input type="number" class="form-control form-control-sm narrow re-predtime mx-1" autocomplete="off">') ) );
                $fs.append( this.divwrap( $l.text( p[1] || "" ).clone() ) );
                $fs.append( Common.getCheckbox( Common.getUID( 'check' ), "0",
                    _T('#condrestrict-seq-mode', 'Predecessor must still be true for this restriction to be met'),
                    "predmode" )
                    .addClass( "mt-1 ms-2" )
                );
                $preds.val( condOpts.after || "" );
                $('input.re-predtime', $fs).val( condOpts.aftertime || 0 )
                    .prop( 'disabled', "" === ( condOpts.after || "" ) );
                $('input.predmode', $fs)
                    .prop( 'checked', 0 === ( condOpts.aftermode || 0 ) )
                    .prop( 'disabled', "" === ( condOpts.after || "" ) );
            }

            /* Duration */
            if ( displayed.duration ) {
                $fs = $( '<div class="opt-fs d-flex flex-row"></div>' ).appendTo( rst );
                let p = _T(['#condrestrict-sustain','Condition must be sustained for {0}{1} seconds'], "|", "|" ).split( /\|/ );
                let $l = $( '<label class="mt-1 mx-1"></label>' ).text( p[0] || "" );
                $fs.append( this.divwrap( $l.clone() ) );
                $fs.append( this.divwrap( $( `<select class="form-select form-select-sm re-durop"><option value="ge">${_T(['#condrestrict-sustain-atleast','at least'])}</option><option value="lt">${_T(['#condrestrict-sustain-lessthan','less than'])}</option></select>` ) ) );
                $fs.append( this.divwrap( $l.text( p[1] || "" ) ) );
                $fs.append( this.divwrap( $( '<input type="number" class="form-control form-control-sm narrow re-duration" autocomplete="off">' ) ) );
                $fs.append( this.divwrap( $l.text( p[2] || "" ) ) );
            }

            /* Repeat */
            if ( displayed.repeat ) {
                $fs = $( '<div class="opt-fs d-flex flex-row"></div>' ).appendTo( rst );
                let p = _T(['#condrestrict-repeat','Condition must repeat {0} times within {1} seconds'], "|", "|" ).split( /\|/ );
                let $l = $( '<label class="mt-1 mx-1"></label>' ).text( p[0] || "" );
                $fs.append( this.divwrap( $l.clone() ) );
                $fs.append( this.divwrap( $( '<input type="number" class="form-control form-control-sm narrow re-repeatcount mx-1" autocomplete="off">' ) ) );
                $fs.append( this.divwrap( $l.text( p[1] || "" ).clone() ) );
                $fs.append( this.divwrap( $( '<input type="number" class="form-control form-control-sm narrow re-repeatspan mx-1" autocomplete="off">' ) ) );
                $fs.append( this.divwrap( $l.text( p[2] || "" ) ) );
            }

            if ( ( condOpts.duration || 0 ) > 0 ) {
                $('input.re-repeatcount,input.re-repeatspan', rst).prop('disabled', true);
                $('input.re-duration', rst).val( condOpts.duration );
                $('select.re-durop', rst).val( condOpts.duration_op || "ge" );
            } else {
                const rc = condOpts.repeatcount || "";
                $('input.re-duration', rst).prop('disabled', ! isEmpty(rc) );
                $('select.re-durop', rst).prop('disabled', ! isEmpty(rc) );
                $('input.re-repeatcount', rst).val( rc );
                $('input.re-repeatspan', rst).prop('disabled', isEmpty(rc) )
                    .val( isEmpty( rc ) ? "" : ( condOpts.repeatwithin || "60" ) );
            }
        }

        /* Handler for all fields */
        $( 'input,select', $container ).on( 'change.reactor', this.handleConditionRowChange.bind(this) );

        /* Add the options container (specific immediate child of this row selection) */
        if ( has_group ) {
            $container.insertBefore( $row.children( 'div.cond-group-body' ) );  // PR 0000328 fix; direct child only
        } else {
            $row.append( $container );
        }
        $container.slideDown();
    }

    /**
     * Set condition fields and data for type. This also replaces existing
     * data from the passed condition. The condition must have at least
     * id and type keys set (so new conditions may be safely be otherwise
     * empty).
     */
    async setConditionForType( cond, $row ) {
        let k, v, $mm, $fs, $el, $gr;
        if ( !$row ) {
            $row = $( 'div.cond-container#' + idSelector( cond.id ) );
        }
        const $container = $('div.params', $row).empty();

        $row.children( 'button.re-condmore' ).prop( 'disabled', "comment" === cond.type );

        /* Ensure cond.data exists to be used. */
        cond.data = cond.data || {};

        switch (cond.type) {
            case "":
                break;

            case 'comment':
                $el = $('<textarea class="form-control form-control-sm re-comment" wrap="soft" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>')
                    .appendTo( $container )
                    .on( 'change.reactor', this.handleConditionRowChange.bind(this) )
                    .val( cond.data.comment || "" );
                break;

            case 'entity':
            case 'var':
                if ( "var" === cond.type ) {
                    let vk = cond.data.var;
                    if ( cond.data.rule ) {
                        vk = cond.data.rule + ':' + cond.data.var;
                    }
                    $mm = await this.makeExprMenu( vk );
                    $container.append( this.divwrap( $mm ) );
                    $mm.on( "change.reactor", this.handleExprMenuChange.bind(this) );
                } else {
                    let name = "";
                    if ( ! isEmpty( cond.data.entity ) ) {
                        let entity = api.getEntity( cond.data.entity );
                        if ( entity ) {
                            name = entity.getName();
                        } else {
                            name = "?unknown?";
                        }
                    }
                    $fs = entitypicker.getPickerControl( false, cond.data.entity, name ).appendTo( $container );
                    $fs.on( 'change.reactor', this.handleDeviceChange.bind( this ) );

                    $fs = $('<div class="vargroup"></div>').appendTo( $container );
                    $mm = this.makeVariableMenu( cond.data.entity, cond.data.attribute );
                    $mm.appendTo( $fs );
                    $("select.varmenu", $container).on( 'change.reactor', this.handleConditionVarChange.bind(this) );
                }

                $mm = this.makeServiceOpMenu();
                $container.append( this.divwrap( $mm ) );
                this.setValidOpsForType( cond.data.attribute || "", $container );
                $fs = $( 'option[value=' + JSON.stringify( cond.data.op ) + ']', $mm );
                if ( $fs.prop( 'disabled' ) ) {
                    $fs.prop( 'disabled', false ).show().prop( 'checked', true );
                } else {
                    Common.menuSelectDefaultInsert( $mm, cond.data.op || "=" );
                }

                $('<input type="text" class="form-control form-control-sm operand" autocomplete="off" list="reactorvarlist">')
                    .attr( 'id', cond.id + '-value' )
                    .inputAutogrow( { minWidth: 32, maxWidth: $container.width() } )
                    .appendTo( $container );
                /* This looks great, but doesn't send change events, so can't use. Maybe workaround? */
                /*
                $( '<span class="input operand" role="textbox" contenteditable="true">value</span>' )
                    .attr( 'id', cond.id + '-value' )
                    .appendTo( $container );
                */

                $el = $( '<div class="re-nocaseopt mx-2 mt-1"></div>' ).appendTo( $container );
                Common.getCheckbox( cond.id + "-nocase", "1", _T(['#cond-nocase','Ignore&nbsp;case']),
                    "nocase" ).appendTo( $el );
                $container.append('<div class="currval"></div>');

                $("input.operand", $container).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                $('input.nocase', $container).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                $("select.opmenu", $container).on( 'change.reactor', this.handleConditionOperatorChange.bind(this) );

                this.setUpConditionOpFields( $container, cond );

                this.updateCurrentServiceValue( $row );
                break;

            case 'rule':
                $mm = await this.makeRuleMenu( cond.data.rule );
                $container.append( this.divwrap( $mm ) );
                /* Can't use current rule */
                $( 'option[value="' + this.data.id + '"]', $mm ).prop( 'disabled', true );

                $mm = $('<select class="opmenu form-select form-select-sm"></select>');
                $container.append( this.divwrap( $mm ) );
                $( '<option></option>' ).val( 'istrue' ).text( serviceOpIndex.istrue.desc )
                    .appendTo( $mm );
                $( '<option></option>' ).val( 'isfalse' ).text( serviceOpIndex.isfalse.desc )
                    .appendTo( $mm );
                $( '<option></option>' ).val( 'nottrue' ).text( serviceOpIndex.nottrue.desc )
                    .appendTo( $mm );
                $( '<option></option>' ).val( 'notfalse' ).text( serviceOpIndex.notfalse.desc )
                    .appendTo( $mm );
                $( '<option></option>' ).val( 'isnull' ).text( serviceOpIndex.isnull.desc )
                    .appendTo( $mm );
                $( '<option></option>' ).val( 'isval' ).text( serviceOpIndex.isval.desc )
                    .appendTo( $mm );
                $( '<option></option>' ).val( 'change' ).text( serviceOpIndex.change.desc )
                    .appendTo( $mm );
                Common.menuSelectDefaultInsert( $mm, coalesce( cond.data.op, "istrue" ) );

                $('<input type="text" class="form-control form-control-sm operand" autocomplete="off" list="reactorvarlist">')
                    .attr( 'id', cond.id + '-value' )
                    .inputAutogrow( { minWidth: 32, maxWidth: $container.width() } )
                    .appendTo( $container );

                $container.append( '<div class="currval"></div>' );

                $("input.operand", $container).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                $("select.rulemenu", $container).on( 'change.reactor', this.handleRuleMenuChange.bind(this) );
                $("select.opmenu", $container).on( 'change.reactor', this.handleConditionOperatorChange.bind(this) );

                // Value is always boolean (it's a rule state)
                $container.data( 'attr-type', 'bool' ).attr( 'data-attr-type', 'bool' );

                this.setUpConditionOpFields( $container, cond );

                this.updateCurrentServiceValue( $row );
                break;

            case 'weekday':
                $fs = $( '<select class="wdcond form-select form-select-sm"></select>' );
                $container.append( this.divwrap( $fs ).addClass( 'me-2' ) );
                (_LD.weekday_modes || [ 'Every', 'First', 'Second', 'Third', 'Fourth', 'Fifth', 'Last' ]).forEach( ( m, ix ) => {
                    $( '<option></option>' ).val( 0 === ix ? "" : ( 6 === ix ? "L" : ix ) )
                        .text( m )
                        .appendTo( $fs );
                });
                $gr = $( '<div class="re-wdopts d-flex flex-row"></div>' ).appendTo( $container );
                weekdayNames.forEach( ( n, ix ) => {
                    Common.getCheckbox( cond.id + '-wd-' + ix, ix.toString(), n,
                        `wdopt ${ix>0 && ix<6 ? 'weekday' : 'weekend'}` )
                        .addClass( 'me-3 mt-1' )
                        .appendTo( $gr );
                });
                $fs = $( `<a href="#">${_T('Weekday')}</a>` ).on( 'click.reactor', ( event ) => {
                    let $row = $( event.target ).closest( 'div.cond-container' );
                    $( 'input.wdopt', $row ).each( ( ix, obj ) => {
                        $( obj ).prop( 'checked', $( obj ).hasClass( 'weekday' ) );
                    });
                    this.handleConditionRowChange( event );
                });
                $gr.append( this.divwrap( $fs ).addClass( "mt-1 me-3") );
                $fs = $( `<a href="#">${_T('Weekend')}</a>` ).on( 'click.reactor', ( event ) => {
                    let $row = $( event.target ).closest( 'div.cond-container' );
                    $( 'input.wdopt', $row ).each( ( ix, obj ) => {
                        $( obj ).prop( 'checked', $( obj ).hasClass( 'weekend' ) );
                    });
                    this.handleConditionRowChange( event );
                });
                $gr.append( this.divwrap( $fs ).addClass( "mt-1") );

                /* Load */
                Common.menuSelectDefaultFirst( $( 'select.wdcond', $container ), cond.data.op );
                (cond.data.days || []).forEach( function( val ) {
                    $('input.wdopt[value="' + val + '"]', $container).prop('checked', true);
                });
                $("input", $container).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                $("select.wdcond", $container).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                break;

            case 'sun':
                $fs = this.makeDateTimeOpMenu( cond.data.op || "bet" );
                $container.append( this.divwrap( $fs ) );
                $gr = $( '<div class="re-startfields d-flex flex-row flex-nowrap"></div>' ).appendTo( $container );
                $fs = $( '<input type="text" value="" class="narrow form-control form-control-sm re-startoffset mx-1" autocomplete="off">' );
                $gr.append( this.divwrap( $fs ) );
                $fs = $( `<select class="form-select form-select-sm re-startoffs-dir"><option value="-1">${_T('minutes before')}</option><option value="1">${_T('minutes after')}</option></select>` )
                    .val( "-1" );
                $gr.append( this.divwrap( $fs ) );
                $gr.append( this.divwrap( $( '<select class="form-select form-select-sm re-sunstart mx-1"></select>' ) ) );
/*
                $( `<div class="re-startfields">${_T(['#condform-sunstart','{0} offset {1} minutes'],
                    ,
                    '<input type="text" value="" class="narrow form-control form-control-sm re-startoffset mx-1" autocomplete="off">')}</div>` )
                    .appendTo( $container );
*/
                $gr = $( '<div class="re-endfields d-flex flex-row flex-nowrap"></div>' ).appendTo( $container );
                $gr.append( `<label class="mx-1 mt-1">${_T(['#cond-desc-between','{0} and {1}'], "", "")}</label>` );
                $fs = $( '<input type="text" value="" class="narrow form-control form-control-sm re-endoffset mx-1" autocomplete="off">' );
                $gr.append( this.divwrap( $fs ) );
                $fs = $( `<select class="form-select form-select-sm re-endoffs-dir"><option value="-1">${_T('minutes before')}</option><option value="1">${_T('minutes after')}</option></select>` )
                    .val( "-1" );
                $gr.append( this.divwrap( $fs ) );
                $gr.append( this.divwrap( $( '<select class="form-select form-select-sm re-sunend mx-1"></select>' ) ) );

                /* Populate solar names menus */
                $mm = $( '<select class="form-select form-select-sm"></select>' );
                solarNames.forEach( n => {
                    $( '<option></option>' ).val( n.id ).text( n.name )
                        .appendTo( $mm );
                });
                $( 'select.re-sunend', $container ).replaceWith( $mm.clone().addClass( 're-sunend' ) );
                $( 'select.re-sunstart', $container ).replaceWith( $mm.addClass( 're-sunstart' ) );
                $( 'select.re-sunstart,select.re-sunend', $container )
                    .on( 'change.reactor', this.handleConditionRowChange.bind( this ) );

                /* Restore. Condition first... */
                v = Common.menuSelectDefaultFirst( $("select.opmenu", $container), cond.data.op );
                $("select.opmenu", $container).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                if ( "bet" === v || "nob" === v ) {
                    $("div.re-endfields", $container).removeClass( 'd-none' );
                } else {
                    $("div.re-endfields", $container).removeClass( 'tberror' ).addClass( 'd-none' );
                }
                /* Start */
                Common.menuSelectDefaultFirst( $( 'select.re-sunstart', $container ),
                    cond.data.start || "sunrise" );
                k = cond.data.start_offset || 0;
                if ( k < 0 ) {
                    k = -k;
                    $( 'select.re-startoffs-dir', $container )
                        .on( 'change.reactor', this.handleConditionRowChange.bind( this ) )
                        .val( "-1" );
                } else {
                    $( 'select.re-startoffs-dir', $container )
                        .on( 'change.reactor', this.handleConditionRowChange.bind( this ) )
                        .val( "1" );
                }
                $( 'input.re-startoffset', $container )
                    .on( 'change.reactor', this.handleConditionRowChange.bind(this) )
                    .val( k );
                /* End */
                Common.menuSelectDefaultFirst( $( 'select.re-sunend', $container ),
                    cond.data.end || "sunset" );
                k = cond.data.end_offset || 0;
                if ( k < 0 ) {
                    k = -k;
                    $( 'select.re-endoffs-dir', $container )
                        .on( 'change.reactor', this.handleConditionRowChange.bind( this ) )
                        .val( "-1" );
                } else {
                    $( 'select.re-endoffs-dir', $container )
                        .on( 'change.reactor', this.handleConditionRowChange.bind( this ) )
                        .val( "1" );
                }
                $( 'input.re-endoffset', $container )
                    .on( 'change.reactor', this.handleConditionRowChange.bind(this) )
                    .val( k );
                break;

            case 'trange':
                {
                    $fs = this.makeDateTimeOpMenu( cond.data.op || "bet" );
                    $container.append( this.divwrap( $fs ) );
                    const $hours = $('<select class="hourmenu form-select form-select-sm"></select>');
                    for ( k=0; k<24; k++ ) {
                        let hh = k % 12;
                        if ( hh === 0 ) {
                            hh = 12;
                        }
                        $( '<option></option>' ).val( k ).text( `${k < 10 ? ('0'+k) : k} (${hh + ( k < 12 ? meridien_ante : meridien_post )})` )
                            .appendTo( $hours );
                    }
                    const $mins = $('<select class="minmenu form-select form-select-sm"></select>');
                    for ( let mn=0; mn<60; mn+=5 ) {
                        $( '<option></option>' ).val( mn ).text( `${mn < 10 ? '0' : ''}${mn}` )
                            .appendTo( $mins );
                    }
                    const $days = $( `<select class="daymenu form-select form-select-sm"><option value="">${_T(['#condform-trange-daily','(every day)'])}</option></select>` );
                    for ( k=1; k<=31; k++ ) {
                        $( '<option></option>' ).val( k ).text( k )
                            .appendTo( $days );
                    }
                    const $months = $( `<select class="monthmenu form-select form-select-sm"><option value="">${_T(['#condform-trange-monthly','(every month)'])}</option></select>` );
                    for ( k=0; k<12; k++ ) {
                        $( '<option></option>' ).val( k ).text( `${monthNames[k]} (${k+1})` )
                            .appendTo( $months );
                    }
                    $container.append('<div class="re-startfields d-flex flex-row"></div>')
                        .append( `<div class="re-endfields d-flex flex-row"><label class="mx-1 mt-1">${_T(['#cond-desc-between','{0} and {1}'], "", "")}</label></div>` );
                    $("div.re-startfields", $container)
                        .append( this.divwrap( $hours.clone() ) )
                        .append( this.divwrap( $mins.clone() ) )
                        .append( this.divwrap( $days.clone() ) )
                        .append( $months.clone() )
                        .append( this.divwrap( $( `<input type="text" placeholder="${_T('yyyy')}" title="${_T('#condform-trange-year-tip')}" class="year narrow datespec form-control form-control-sm" autocomplete="off">` ) ) );
                    $("div.re-endfields", $container)
                        .append( this.divwrap( $hours ) )
                        .append( this.divwrap( $mins ) )
                        .append( this.divwrap( $days ) )
                        .append( this.divwrap( $months ) )
                        .append( this.divwrap( $( `<input type="text" placeholder="${_T('yyyy')}" class="year narrow datespec form-control form-control-sm" autocomplete="off">` ) ) );
                    /* Default all menus to first option */
                    $("select", $container).each( ( ix, obj ) => {
                        $(obj).val( $("option:first", $(obj) ).val() );
                    });
                    /* Restore values. */
                    v = Common.menuSelectDefaultFirst( $( "select.opmenu", $container ), cond.data.op );
                    /* Start */
                    cond.data.start = cond.data.start || {};
                    cond.data.end = cond.data.end || {};
                    $( 'div.re-startfields input.year', $container ).val( cond.data.start.year || "" );
                    Common.menuSelectDefaultFirst( $( 'div.re-startfields select.monthmenu', $container ),
                        isEmpty( cond.data.start.mon ) ? "" : cond.data.start.mon );
                    Common.menuSelectDefaultFirst( $( 'div.re-startfields select.daymenu', $container ),
                        cond.data.start.day || "" );
                    Common.menuSelectDefaultInsert( $( 'div.re-startfields select.hourmenu', $container ),
                        cond.data.start.hr || 0 );
                    Common.menuSelectDefaultInsert( $( 'div.re-startfields select.minmenu', $container ),
                        cond.data.start.min || 0 );
                    if ( "bet" === v || "nob" === v ) {
                        /* End */
                        $("div.re-endfields", $container).removeClass( 'd-none' );
                        $( 'div.re-endfields input.year', $container ).val( cond.data.end.year || "" );
                        Common.menuSelectDefaultFirst( $( 'div.re-endfields select.monthmenu', $container ),
                            isEmpty( cond.data.end.mon ) ? "" : cond.data.end.mon );
                        Common.menuSelectDefaultFirst( $( 'div.re-endfields select.daymenu', $container ),
                            cond.data.end.day || "" );
                        Common.menuSelectDefaultInsert( $( 'div.re-endfields select.hourmenu', $container ),
                            cond.data.end.hr || 0 );
                        Common.menuSelectDefaultInsert( $( 'div.re-endfields select.minmenu', $container ),
                            cond.data.end.min || 0 );
                        $( 'div.re-endfields select.daymenu', $container ).toggle( ! isEmpty( cond.data.start.day ) );
                    } else {
                        $("div.re-endfields", $container).removeClass( 'tberror' ).addClass( 'd-none' );
                    }
                    /* Enable date fields if month spec present on start */
                    $( '.datespec', $container ).toggle( undefined !== cond.data.start.mon );
                    $( '.monthmenu', $container ).toggle( undefined !== cond.data.start.day );
                    $( "select", $container ).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                    $( "input", $container ).on( 'change.reactor', this.handleConditionRowChange.bind(this) );
                }
                break;

            case 'interval':
                {
                    k = _T(['#condform-interval-when','every {0} days {1} hours {2} minutes'], "|", "|", "|" );
                    k = k.split( /\|/ );
                    $container.append( this.divwrap( $( `<label class="mt-1 ms-1">${ k[0] || "" }</label>` ) ) );
                    $container.append( this.divwrap( $( '<input type="text" value="0" class="narrow form-control form-control-sm re-days mx-1">' ) ) );
                    $container.append( this.divwrap( $( `<label class="mt-1">${ k[1] || "" }</label>` ) ) );
                    $container.append( this.divwrap( $( '<input type="text" class="narrow form-control form-control-sm re-hours mx-1">' ) ) );
                    $container.append( this.divwrap( $( `<label class="mt-1">${ k[2] || "" }</label>` ) ) );
                    $container.append( this.divwrap( $( '<input type="text" value="0" class="narrow form-control form-control-sm re-mins mx-1">' ) ) );
                    $container.append( this.divwrap( $( `<label class="mt-1">${ k[3] || "" }</label>` ) ) );

                    $( 'input.re-days', $container ).attr( 'title', _T(['#condform-interval-days-tip','Enter an integer >= 0; hours and minutes must be 0!']) );
                    $( 'input.re-hours', $container ).attr( 'title', _T(['#condform-interval-hours-tip','Enter an integer >= 0']) );
                    $( 'input.re-mins', $container ).attr( 'title', _T(['#condform-interval-mins-tip','Enter an integer >= 0']) );

                    /* Interval relative time or condition (opposing divs) */
                    $container.append( this.divwrap( $( `<label class="mx-1 mt-1">${_T(['#condform-interval-rel','relative to {0}'], "")}</label>` ) ) );

                    $mm = $( '<select class="form-select form-select-sm re-relto ms-1"></select>' );
                    $( '<option></option>' ).val( "" )
                        .text( _T(['#condform-interval-relopt-time','Time']) )
                        .appendTo( $mm );
                    $( '<option></option>' ).val( "condtrue" )
                        .text( _T(['#condform-interval-relopt-cond','Condition TRUE']) + ' (DEPRECATED)' )
                        .appendTo( $mm );
                    $container.append( this.divwrap( $mm ) );

                    /* Time relative */
                    $fs = $( '<div class="re-reltimeset d-flex flex-row flex-nowrap"></div>' ).appendTo( $container );
                    $fs.append(
                        this.divwrap(
                            $( `<input type="text" placeholder="${_T('yyyy')}" class="re-relyear narrow datespec form-control form-control-sm" autocomplete="off">` )
                        )
                    );
                    $mm = $('<select class="form-select form-select-sm re-relmon re-reldate">');
                    for ( k=0; k<12; ) {
                        $( '<option></option>').val( k ).text( monthNames[k] + ' (' + (++k) + ')' ).appendTo( $mm );
                    }
                    $fs.append( this.divwrap( $mm ) );
                    $mm = $('<select class="form-select form-select-sm re-relday re-reldate">').appendTo( $fs );
                    for ( k=1; k<=31; k++) {
                        $( '<option></option>' ).val( k ).text( k ).appendTo( $mm );
                    }
                    $fs.append( this.divwrap( $mm ) );
                    $mm = $('<select class="form-select form-select-sm re-relhour"></select>');
                    for ( k=0; k<24; k++ ) {
                        v = ( k < 10 ? "0" : "" ) + String(k);
                        $mm.append( $('<option></option>').val( k ).text( v ) );
                    }
                    $fs.append( this.divwrap( $mm ) );
                    $fs.append( this.divwrap( $( '<label class="mt-1">:</label>' ) ) );
                    $mm = $('<select class="form-select form-select-sm re-relmin"></div>');
                    for ( k=0; k<60; k+=5 ) {
                        v = ( k < 10 ? "0" : "" ) + String(k);
                        $mm.append( $('<option></option>').val( k ).text( v ) );
                    }
                    $fs.append( this.divwrap( $mm ) );

                    /* Condition relative (DEPRECATED) */
                    $fs = $( '<div class="re-relcondset d-flex flex-row"></div>' ).appendTo( $container );
                    $mm = $( '<select class="form-select form-select-sm re-relcond"></select>' );
                    $( '<option></option>' ).val( "" ).text( _T(['#opt-choose','--choose--']) )
                        .appendTo( $mm );
                    /* We don't load the menu here; it gets loaded when the user chooses that option */
                    $fs.append( this.divwrap( $mm ) );

                    $( ".re-days", $container ).val( cond.data.days || 0 );
                    $( ".re-hours", $container ).val( cond.data.hours===undefined ? 1 : cond.data.hours );
                    $( ".re-mins", $container ).val( cond.data.mins || 0 );
                    $( "select.re-relto", $container ).val( cond.data.relto || "" );
                    if ( "condtrue" === cond.data.relto ) {
                        /* Relative to condition */
                        $( "div.re-relcondset", $container ).removeClass( 'd-none' );
                        $( "div.re-reltimeset", $container ).removeClass( 'tberror' ).addClass( 'd-none' );
                        /* Rebuild the menu of conditions, in case changed */
                        $mm = $( 'select.re-relcond', $container );
                        $( 'option[value!=""]', $mm ).remove();
                        /* Funky structure to get around async makeConditionDescription */
                        let nodes = [];
                        const self = this;
                        Common.traverse( self.data, function( n ) { nodes.push( n ); }, false, function( n ) {
                                return "comment" !== n.type && n.id !== cond.id && !self.isAncestor( n.id, cond.id );
                        });
                        let nn = nodes.length;
                        for ( k=0; k<nn; ++k ) {
                            let n = nodes[k];
                            let tt = await RuleEditor.makeConditionDescription( self.options.contextRule, n );
                            tt = (condTypeName[n.type || "group"] || n.type || "?") + ": " + tt;
                            $mm.append( $( '<option></option>' ).val( n.id ).text( tt ) );
                        }
                        Common.menuSelectDefaultInsert( $mm, coalesce( cond.data.relcond, "" ) );
                    } else {
                        /* Relative to time (default) */
                        $( "div.re-reltimeset", $container ).removeClass( 'd-none' );
                        $( "div.re-relcondset", $container ).removeClass( 'tberror' ).addClass( 'd-none' );
                        if ( "number" === typeof cond.data.basedate ) {
                            /* Deprecated form */
                            const dt = new Date( cond.data.basedate );
                            $( 'input.re-relyear', $container ).val( dt.getFullYear() );
                            Common.menuSelectDefaultFirst( $( '.re-relmon', $container ), dt.getMonth() );
                            Common.menuSelectDefaultFirst( $( '.re-relday', $container ), dt.getDate() );
                            Common.menuSelectDefaultInsert( $( '.re-relhour', $container ), dt.getHours() );
                            Common.menuSelectDefaultInsert( $( '.re-relmin', $container ), dt.getMinutes() );
                        } else if ( "object" === typeof cond.data.basetime ) {
                            if ( cond.data.basetime.year ) {
                                $( 'input.re-relyear', $container ).val( cond.data.basetime.year || "" );
                                Common.menuSelectDefaultFirst( $( '.re-relmon', $container ), cond.data.basetime.mon || 0 );
                                Common.menuSelectDefaultFirst( $( '.re-relday', $container ), cond.data.basetime.day || 1 );
                            }
                            Common.menuSelectDefaultInsert( $( '.re-relhour', $container ), (cond.data.basetime || {}).hr || 0 );
                            Common.menuSelectDefaultInsert( $( '.re-relmin', $container ), (cond.data.basetime || {}).min || 0 );
                        }
                        if ( "" === ( cond.data.basetime?.year || "" ) ) {
                            $( '.re-relmon', $container ).prop( 'disabled', true );
                            $( '.re-relday', $container ).prop( 'disabled', true );
                        }
                    }

                    const self = this;
                    $("select,input", $container).on( 'change.reactor', async function( ev ) {
                        const $el = $( ev.currentTarget );
                        const $row = $el.closest( 'div.cond-container' );
                        if ( $el.hasClass( "re-relto" ) ) {
                            const relto = $el.val() || "";
                            if ( "condtrue" === relto ) {
                                $( '.re-reltimeset', $row ).removeClass( 'tberror' ).addClass( 'd-none' );
                                $( '.re-relcondset', $row ).removeClass( 'd-none' );
                                /* Rebuild the menu of conditions, in case changed */
                                const $mm = $( 'select.re-relcond', $row );
                                $( 'option[value!=""]', $mm ).remove();
                                /* Funky structure to get around async makeConditionDescription */
                                const nodes = [];
                                Common.traverse( self.data, function( n ) { nodes.push( n ); }, false, function( n ) {
                                        return "comment" !== n.type && n.id !== cond.id && !self.isAncestor( n.id, cond.id );
                                });
                                const nn = nodes.length;
                                for ( k=0; k<nn; ++k ) {
                                    const n = nodes[k];
                                    let tt = await RuleEditor.makeConditionDescription( self.options.contextRule, n );
                                    tt = (condTypeName[n.type || "group"] || n.type || "?") + ": " + tt;
                                    $mm.append( $( '<option></option>' ).val( n.id ).text( tt ) );
                                }
                                $mm.val( "" );
                            } else {
                                $( '.re-reltimeset', $row ).removeClass( 'd-none' );
                                $( '.re-relcondset', $row ).removeClass( 'tberror' ).addClass( 'd-none' );
                            }
                        }
                        return self.handleConditionRowChange( ev ); /* pass on */
                    } );
                }
                break;

            case 'script':
                $container.removeClass( "flex-row" ).addClass( "flex-column" );
                $el = $('<textarea class="form-control re-script-expr" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>')
                    .attr( 'placeholder', _T('Enter script/expression') )
                    .appendTo( $container )
                    .on( 'change.reactor', this.handleConditionRowChange.bind(this) )
                    .val( cond.data.script || "" );

                $container.append('<div class="currval"></div>');
                this.updateCurrentServiceValue( $row );
                break;

            default:
                /* nada */
        }

        /** Set up display of condition options. Not all conditions have options, and those that do don't have all
         *  options. Clear the UI each time, so it's rebuilt as needed.
         */
        $( 'div.condopts', $row ).remove();
        const $btn = $( 'button.re-condmore', $row );
        const displayed = condOptionsDetail[ ( cond.type || "group" ) + "+" + ( ( cond.data || {} ).op || "" ) ] ||
            condOptions[ cond.type || "group" ] || false;
        if ( displayed ) {
            $btn.prop( 'disabled', false );
            if ( Common.hasAnyProperty( cond.options ) ) {
                $btn.addClass( 'attn' );
            } else {
                $btn.removeClass( 'attn' );
                delete cond.options;
            }
        } else {
            $btn.removeClass( 'attn' ).prop( 'disabled', true );
            delete cond.options;
        }
    }

    handleCondTitleChange( ev ) {
        const input = $( ev.currentTarget );
        const grpid = input.closest( 'div.cond-container.cond-group' ).attr( 'id' );
        const newname = (input.val() || "").trim();
        const span = $( 'span.re-title', input.parent() );
        const grp = this.getCondition( grpid );
        input.removeClass( 'tberror' );
        if ( newname !== grp.name ) {
            /* Group name check */
            if ( newname.length < 1 ) {
                ev.preventDefault();
                $( 'button.saveconf' ).prop( 'disabled', true );
                input.addClass( 'tberror' );
                input.focus();
                return;
            }

            /* Update config */
            input.closest( 'div.cond-group' ).addClass( 'tbmodified' );
            grp.name = newname;
            this.configModified = true;
        }

        /* Remove input field and replace text */
        input.remove();
        span.text( newname );
        span.closest( 'div.cond-group-title' ).children().show();
        if ( this.configModified ) {
            this.signalModified();
        } else {
            this.updateSaveControls();
        }
    }

    handleCondTitleClick( ev ) {
        /* N.B. Click can be on span or icon */
        const $el = $( ev.currentTarget );
        const $p = $el.closest( 'div.cond-group-title' );
        $p.children().hide();
        const grpid = $p.closest( 'div.cond-container.cond-group' ).attr( 'id' );
        const grp = this.getCondition( grpid );
        if ( grp ) {
            $p.append( $( `<input class="titleedit form-control form-control-sm" title="${_T('Enter new group name')}">` )
                .val( grp.name || grp.id || "" ) );
            $( 'input.titleedit', $p ).on( 'change.reactor', this.handleCondTitleChange.bind(this) )
                .on( 'blur.reactor', this.handleCondTitleChange.bind(this) )
                .focus();
        }
    }

    /**
     * Handle click on group expand/collapse.
     */
    handleGroupExpandClick( ev ) {
        const $el = $( ev.currentTarget );
        const $p = $el.closest( 'div.cond-container.cond-group' );
        const $l = $( 'div.cond-group-body:first', $p );
        if ( $el.hasClass( 're-collapse' ) ) {
            $l.slideUp();
            $el.addClass( 're-expand' ).removeClass( 're-collapse' )
                .attr( 'title', _T('Expand group') );
            $( 'i', $el ).removeClass( 'bi-arrows-collapse' ).addClass( 'bi-arrows-expand' );
            try {
                const n = $( 'div.cond-list:first > div', $p ).length;
                $( 'span.re-titlemessage:first', $p )
                    .text( _T(['#cond-collapsed-count','({0:d} condition{0:"s"?!=1} collapsed)'], n) );
            } catch( e ) {  // eslint-disable-line no-unused-vars
                $( 'span.re-titlemessage:first', $p ).text( " (conditions collapsed)" );
            }
        } else {
            $l.slideDown();
            $el.removeClass( 're-expand' ).addClass( 're-collapse' )
                .attr( 'title', _T('Collapse group') );
            $( 'i', $el ).removeClass( 'bi-arrows-expand' ).addClass( 'bi-arrows-collapse' );
            $( 'span.re-titlemessage:first', $p ).text( "" );
        }
    }

    /**
     * Utility:
     * Reset IDs on all conditions. Used for import and copy operations.
     * Note that this method is static, because it is used externally
     * by Reactions for group/while constraints.
     */

    static reassignConditionIDs( root, stateless ) {
        /* Traverse condition tree, assigning new IDs to all conditions. Make map of old to new. */
        const map = new Map();
        Common.traverse( root, node => {
            if ( stateless ) {
                delete node.options;  /* No options on stateless conditions (e.g. global reaction constraints) */
            }
            /* Assign new ID; preserve old in map (old -> new). */
            const oldid = node.id;
            node.id = RuleEditor.isGroup( node.type ) ? Common.getUID( 'grp' ) : Common.getUID( 'cond' );
            map.set( oldid, node.id );
        });

        /* Re-traverse looking for sequence conditions; fix to new ID. */
        if ( ! stateless ) {
            Common.traverse( root, node => {
                if ( node.options?.after && map.has( node.options.after ) ) {
                    node.options.after = map.get( node.options.after );
                }
            });
        }

        map.clear();
    }

    /**
     * Handle click on rule import button.
     */
    async handleImportRuleClick( ev ) {
        const $el = $( ev.currentTarget );
        const $p = $el.closest( 'div.cond-container.cond-group' );
        const $condlist = $el.closest( '.cond-group-body' ).children( '.cond-list' );
        let grpid = $p.attr( 'id' );
        let grp = this.getCondition( grpid );

        /* Build the form */
        let $body = $( '<div></div>' );
        let $gr = $( '<div class="form-group"></div>' ).appendTo( $body );
        let $mm = await this.makeRuleMenu( false );
        let $lb = $( '<label></label>' )
            .text( _T(['#cond-import-select-label','Select Rule to import:']) )
            .appendTo( $gr );
        $mm.appendTo( $lb );
        $gr = $( '<div class="form-check form-check-inline mt-1"></div>' ).appendTo( $body );
        let ii = Common.getUID( 'inp' );
        $( '<input class="form-check-input" type="radio" name="importRadioOpt" value="T">' )
            .attr( 'id', ii )
            .prop( 'checked', true )
            .appendTo( $gr );
        $( '<label class="form-check-label"></label>' )
            .attr( 'for', ii )
            .text( _T(['#cond-import-select-trig','Triggers']) )
            .appendTo( $gr );
        $gr = $( '<div class="form-check form-check-inline"></div>' ).appendTo( $body );
        ii = Common.getUID( 'inp' );
        $( '<input class="form-check-input" type="radio" name="importRadioOpt" value="C">' )
            .attr( 'id', ii )
            .appendTo( $gr );
        $( '<label class="form-check-label"></label>' )
            .attr( 'for', ii )
            .text( _T(['#cond-import-select-cons','Constraints']) )
            .appendTo( $gr );

        Common.showSysModal( { title: _T(['#cond-import-title','Import Rule as Group']),
            body: $body,
            buttons: [{
                label: _T(['#cond-import-button-import','Import Rule']),
                event: "import",
                class: "btn-success"
            },{
                label: _T(['#cond-import-button-cancel','Cancel']),
                close: true,
                class: "btn-primary"
            }]
        }).then( async event => {
            if ( "import" === event ) {
                /* Import conditions under a new group as a container. */
                let ruleid = $( '#sysdialog select' ).val();
                let what = $( '#sysdialog input[type="radio"]:checked' ).val() || "T";
                let rule = await Rule.getInstance( ruleid );
                let newgrp = this.clone( "C" === what ? rule.constraints : rule.triggers );
                newgrp.type = 'group';
                newgrp.id = Common.getUID( 'grp' );
                newgrp.name = ( rule.name || rule.getID() ) + ' (' +
                    ( "C" === what ? _T('imported constraints') : _T('imported triggers') ) +
                    ')';
                newgrp.conditions = newgrp.conditions || [];

                /* Assign new IDs to all conditions. */
                RuleEditor.reassignConditionIDs( newgrp, this.options?.stateless );

                /* Add new group as condition of current group */
                grp.conditions = grp.conditions || [];
                grp.conditions.push( newgrp );
                this.reindex();
                this.redrawGroup( newgrp, $condlist );
                this.signalModified();
            }
        });
        return false;
    }

    /**
     * Handle click on group export button.
     */
    handleGroupExportClick( ev ) {
        const $el = $( ev.currentTarget );
        const $p = $el.closest( 'div.cond-container.cond-group' );
        let grpid = $p.attr( 'id' );
        let grp = this.getCondition( grpid );
        Common.showSysModal( {
            title: _T(['#cond-export-title','Export Group to Rule']),
            body: _T(['#cond-export-prompt', 'Really export this group to a rule? It will be replaced with a "Rule" condition referencing the new rule, to preserve the logic.']),
            buttons: [{
                label: _T(['#cond-export-button-export','Export']),
                event: "export",
                class: "btn-warning"
            },{
                label: _T(['#cond-export-button-cancel','Cancel']),
                close: true,
                class: "btn-primary"
            }]
        }).then( async event => {
            if ( "export" === event ) {
                /* Get the current rule's ruleset */
                let ruleset = await Rulesets.findRule( this.options.contextRule.id );
                /* Create the new rule. */
                let newrule = await Rule.create( ruleset );
                let dobj = newrule.getDataObject();
                dobj.value.name = grp.name || grp.id;
                if ( grp.options ) {
                    /* Place entire group in as wrapper */
                    dobj.value.triggers.conditions = [ this.clone( grp ) ];
                } else {
                    /* Copy conditions to trigger root */
                    dobj.value.triggers.conditions = grp.conditions ? this.clone( grp.conditions ) : [];
                    dobj.value.triggers.data = grp.data ? this.clone( grp.data ) : { op: "and" };
                }
                await dobj.save();
                /* Replace the group with a Rule condition. Empty the group. */
                ( grp.conditions || [] ).forEach( cond => {
                    this.deleteCondition( cond, grp, false );
                });
                /* Now change the condition type and point to the new rule. */
                grp.type = 'rule';
                grp.data = { rule: dobj.value.id, op: 'istrue' };
                delete grp.options;
                this.reindex();
                /* Replace group presentation with condition */
                let $np = this.getConditionTemplate( grp.id );
                $p.replaceWith( $np );
                $( 'select.re-condtype', $np ).val( grp.type );
                this.setConditionForType( grp, $np );
                /* Mark modified */
                this.signalModified();
            }
        });
        return false;
    }

    /** N.B.: Not currently used; saved as placekeeper.
     * Handle click on group focus--collapses all groups except clicked
     */
    handleGroupFocusClick( ev ) {
        const self = this;
        const $el = $( ev.currentTarget );
        const $p = $el.closest( 'div.cond-container.cond-group' );
        const $l = $( 'div.cond-group-body:first', $p );
        const focusGrp = this.getCondition( $p.attr('id') );

        const $btn = $( 'button.re-expand', $p );
        if ( $btn.length > 0 ) {
            $l.slideDown();
            $btn.removeClass( 're-expand' ).addClass( 're-collapse' ).attr( 'title', 'Collapse group' );
            $( 'i', $btn ).text( 'expand_less' );
            $( 'span.re-titlemessage:first', $p ).text( "" );
        }

        const hasCollapsedParent = function( /* grp */ ) {
            // var parent = grp.__parent_id;
            return false;
        };

        /*
            collapse: descendents of this group -- NO
                      ancestors of this group -- NO
                      siblings of this group -- YES
                      none of the above -- YES
        */
        Common.traverse( this.data,
            function( node ) {
                const gid = node.id;
                const $p = $( 'div#' + idSelector( gid ) + ".cond-group" );
                const $l = $( 'div.cond-group-body:first', $p );
                const $btn = $( 'button.re-collapse', $p );
                if ( $btn.length > 0 && !hasCollapsedParent( focusGrp ) ) {
                    $l.slideUp();
                    $( 'button.re-collapse', $p ).removeClass( 're-collapse' ).addClass( 're-expand' ).attr( 'title', 'Expand group');
                    $( 'i', $btn ).removeClass('bi-arrows-collapse').addClass('bi-arrows-expand');
                    try {
                        const n = $( 'div.cond-list:first > div', $p ).length;
                        $( 'span.re-titlemessage:first', $p )
                            .text( _T(['#cond-collapsed-count', '({0:d} condition{0:"s"?!=1} collapsed)'], n) );
                    } catch( e ) {  // eslint-disable-line no-unused-vars
                        $( 'span.re-titlemessage:first', $p ).text( " (conditions collapsed)" );
                    }
                }
            },
            false,
            function( node ) {
                /* Filter out non-groups, focusGrp, and nodes that are neither ancestors nor descendents of focusGrp */
                return self.isGroup( node ) &&
                    node.id !== focusGrp.id &&
                    ! ( self.isAncestor( node.id, focusGrp.id ) || self.isDescendent( node.id, focusGrp.id ) );
            }
        );
    }

    /**
     * Delete condition. If it's a group, delete it and all children
     * recursively.
     */
    deleteCondition( condId, pgrp, reindex ) {
        const cond = this.getCondition( condId );
        if ( ! cond ) {
            return;
        }
        pgrp = pgrp || this.getCondition( cond.__parent_id );
        if ( undefined === reindex ) {
            reindex = true;
        }

        /* Remove references to this cond in options (sequences, intervals) */
        Object.keys( this.cond_index ).forEach( cond => {
            if ( cond.data && condId === cond.data.relcond ) {
                delete cond.data.relto;
                delete cond.data.relcond;
            }
            if ( cond.options && condId === cond.options.after ) {
                delete cond.options.after;
                delete cond.options.aftertime;
                delete cond.options.aftermode;
            }
        });

        /* If this condition is a group, delete all subconditions (recursively) */
        if ( this.isGroup( cond.type ) ) {
            const lx = cond.conditions ? cond.conditions.length : 0;
            /* Delete end to front to avoid need to reindex each time */
            for ( let ix=lx-1; ix>=0; ix-- ) {
                this.deleteCondition( cond.conditions[ix].id, cond, false );
            }
        }

        /* Remove from index, and parent group, possibly reindex */
        pgrp.conditions.splice( cond.__index, 1 );
        delete this.cond_index[condId];
        if ( reindex ) {
            this.reindexConditions( pgrp );
        }
    }

    /**
     * Handle delete group button click
     */
    async handleDeleteGroupClick( ev ) {
        const $el = $( ev.currentTarget );
        if ( $el.prop( 'disabled' ) || "root" === $el.attr( 'id' ) ) { return; }

        const $grpEl = $el.closest( 'div.cond-container.cond-group' );
        const grpId = $grpEl.attr( 'id' );

        const grp = this.getCondition( grpId );
        /* Confirm deletion only if group is not empty */
        if ( ( grp.conditions || [] ).length > 0 ) {
            let ans = await Common.showSysModal({
                title: _T(['#cond-delgroup-title','Delete Group']),
                body: _T(['#cond-delgroup-prompt','This group has conditions and/or sub-groups, which will all be deleted as well. Really delete this group?']),
                buttons: [{
                    label: _T(['#cond-delgroup-button-delete','Delete']),
                    event: "delete",
                    class: "btn-danger"
                },{
                    label: _T(['#cond-delgroup-button-cancel','Cancel']),
                    close: true,
                    class: "btn-success"
                }]
            });
            if ( "delete" !== ans ) {
                return;
            }
        }

        $grpEl.remove();
        this.deleteCondition( grpId, this.getCondition( grp.__parent_id ), true );
        $el.closest( 'div.cond-container.cond-group' ).addClass( 'tbmodified' ); // ??? NO! Parent group!
        this.signalModified();
    }

    /**
     * Handle click on Add Group button.
     */
    async handleAddGroupClick( ev ) {
        const $el = $( ev.currentTarget );

        /* Create a new condition group div, assign a group ID */
        const newId = Common.getUID( 'grp' );
        const $condgroup = this.getGroupTemplate( newId );

        /* Create an empty condition group in the data */
        const $parentGroup = $el.closest( 'div.cond-container.cond-group' );
        const $container = $( 'div.cond-list:first', $parentGroup );
        const parentId = $parentGroup.attr( 'id' );
        const grp = this.getCondition( parentId );
        const newgrp = { id: newId, name: newId, data: { op: "and" }, type: "group", conditions: [] };
        newgrp.__parent_id = grp.id;
        newgrp.__index = grp.conditions.length;
        newgrp.__depth = ( grp.__depth || 0 ) + 1;
        grp.conditions.push( newgrp );
        this.cond_index[ newId ] = newgrp;

        /* Append the new condition group to the container */
        $container.append( $condgroup );
        $condgroup.addClass( 'level' + newgrp.__depth ).addClass( 'levelmod' + (newgrp.__depth % 4) );
        $condgroup.addClass( 'tbmodified' );

        /* Add a comment to the group */
        const cond = { id: Common.getUID( 'cond' ), type: "comment", data: { comment: _T('Enter comment text') } }; // ???
        const $condel = this.getConditionTemplate( cond.id );
        $( 'select.re-condtype', $condel ).val( cond.type );
        await this.setConditionForType( cond, $condel );
        $( 'div.cond-list:first', $condgroup ).append( $condel );

        /* Add comment to new group's data */
        newgrp.conditions.push( cond );
        cond.__parent_id = newgrp.id;
        cond.__rule = newgrp.__rule;
        this.cond_index[ cond.id ] = cond;
        this.reindexConditions( grp );

        $condel.addClass( 'tbmodified' );
        this.updateConditionRow( $condgroup );
        this.signalModified();
    }

    /**
     *  Handle click on condition clone button. The clicked condition (which may be a group) is copied and
     *  appended to the current group. New IDs are assigned.
     */
    handleConditionCloneClick( ev ) {
        const $cel = $( ev.currentTarget ).closest( 'div.cond-container' );
        return this.doConditionClone( $cel );
    }

    /* Clone condition given its UI container */
    doConditionClone( $cel ) {
        const cid = $cel.attr( 'id' );
        const cond = this.getCondition( cid );

        /* Clone and assign new ID(s). Works fine for conditions and groups */
        const newcond = this.clone( cond );
        RuleEditor.reassignConditionIDs( newcond, this.options?.stateless );

        /* Insert and redraw */
        const pgrp = this.getCondition( cond.__parent_id );
        const sp = $( window ).scrollTop();
        pgrp.conditions.splice( cond.__index + 1, 0, newcond ); /* Insert after original */
        this.reindex();
        this.redrawGroup( pgrp ); /* redraw in place */
        this.signalModified();
        setTimeout( () => {
            $( window ).scrollTop( sp );
            $( `div.cond-container#${idSelector(newcond.id)}`, this.$editor ).addClass( "tbmodified" );
        }, 100 );
    }

    /**
     *  Receive a node at the end of a drag/drop (list-to-list move).
     *
     *  NB: The observed sequence of events when dragging from one list to another is an update callback to the
     *     source sortable, then a receive callback, then an update callback to the receiving sortable. This all
     *     seems a little redundant, and we need to be careful what actions we take at each step, so the imple-
     *     mentation now is pretty conservative: the sortables take care of moving the DOM elements, so we just
     *     scan the DOM tree to figure out what goes where (or has gone). So far, simple and reliable.
     */
    handleNodeReceive( ev, ui ) {
        const $el = $( ui.item );
        const $target = $( ev.target ); /* receiving .cond-list */

        // See if it's coming from another editor (!)
        const $sender = $( ui.sender ).closest( 'div.re-rule-editor' );
        if ( $sender.attr( 'id' ) !== this.$editor.attr( 'id' ) ) {
            /* Coming from another editor */
            alert("Dragging between editors isn't supported."); /* ??? */
            return false;
        }

        /* All within the same editor */
        /* First, resort the list we came from (which removes this object) */
        const obj = this.getCondition( $el.attr( 'id' ) );
        let par = this.getCondition( obj.__parent_id );
        this.reindexConditions( par );

        /* Attach it to new parent. */
        const $pargrp = $target.closest( 'div.cond-container.cond-group' );
        par = this.getCondition( $pargrp.attr( 'id' ) );
        obj.__parent_id = par.id;
        /* Don't get fancy, just reindex as it now appears. */
        this.reindexConditions( par );

        $el.addClass( 'tbmodified' );
        $pargrp.addClass( 'tbmodified' );
        this.signalModified();
    }

    /**
     *  Handle update of an element's position within a sortable. See notes above on handleNodeReceive() for
     *  additional behaviors, as the Rule Editor permits moving between sortables (i.e. between groups).
     */
    handleNodeUpdate( ev, ui ) {
        const $el = $( ui.item );
        const $target = $( ev.target ); /* receiving .cond-list */
        // const $from = $( ui.sender );

        /* UI is handled, so just reindex parent */
        const $pargrp = $target.closest( 'div.cond-container.cond-group' );
        const par = this.getCondition( $pargrp.attr( 'id' ) );
        this.reindexConditions( par );

        $el.addClass( 'tbmodified' );
        $pargrp.addClass( 'tbmodified' );
        this.signalModified();
    }

    /**
     * Handle click on the condition delete tool
     */
    async handleConditionDelete( ev ) {
        const $el = $( ev.currentTarget );
        if ( $el.prop( 'disabled' ) ) { return; }

        const $row = $el.closest( 'div.cond-container' );
        return await this.doConditionDelete( $row );
    }

    async doConditionDelete( $row ) {
        const condId = $row.attr('id');

        /* See if the condition is referenced in a sequence */
        let okDelete = false;
        for ( let ci of Object.keys( this.cond_index ) ) {
            if ( ( this.cond_index[ci].options || {} ).after === condId ) {
                if ( !okDelete ) {
                    let ans = await Common.showSysModal({
                        title: _T(['#cond-delcond-title','Delete Condition']),
                        body: _T(['#cond-delcond-prompt','This condition is used in a sequence option in another condition. Click OK to delete it and disconnect the sequence, or Cancel to leave everything unchanged.']),
                        buttons: [{
                            label: _T(['#cond-delcond-button-delete','OK']),
                            event: "delete",
                            class: "btn-danger"
                        },{
                            label: _T(['#cond-delcond-button-cancel','Cancel']),
                            close: true,
                            class: "btn-success"
                        }]
                    });
                    okDelete = "delete" === ans;
                    if ( !okDelete ) {
                        return;
                    }
                }
                delete this.cond_index[ci].options.after;
                delete this.cond_index[ci].options.aftertime;
                delete this.cond_index[ci].options.aftermode;
            }
        }

        this.deleteCondition( condId, false, true );

        /* Remove the condition row from display, reindex parent. */
        $row.remove();

        $row.parent().closest( 'div.cond-container.cond-group' ).addClass( 'tbmodified' );
        this.signalModified();
    }

    /**
     * Handle click on group controls (NOT/AND/OR/XOR/NUL)
     */
    handleGroupControlClick( ev ) {
        const $el = $( ev.target );
        const $grpel = $el.closest( 'div.cond-container.cond-group' );
        const grpid = $grpel.attr( 'id' );
        const grp = this.getCondition( grpid );

        /* Generic handling */
        if ( $el.closest( '.btn-group' ).hasClass( 'tb-btn-radio' ) ) {
            $el.closest( '.btn-group' ).find( '.checked' ).removeClass( 'checked' );
            $el.addClass( 'checked' );
        } else {
            $el.toggleClass( "checked" );
        }

        if ( $el.hasClass( 're-op-not' ) ) {
            grp.invert = $el.hasClass( "checked" );
            if ( ! grp.invert ) {
                delete grp.invert;
            }
        } else if ( $el.hasClass( 're-disable' ) ) {
            grp.disabled = $el.hasClass( "checked" );
            if ( !grp.disabled ) {
                delete grp.disabled;
            }
        } else {
            grp.op = $( '.re-logic-op button.checked', $grpel ).data( 'op' ) || "and";
        }

        $el.closest( 'div.cond-container.cond-group' ).addClass( 'tbmodified' );
        this.signalModified();
    }

    /**
     *  Show context menu for condition or group.
     */
    showContextMenu( $menu, $row ) {
        console.log('showContextMenu', this, $menu, $row);
        $menu.empty();

        $( '<a class="dropdown-item"></a>' )
            .data( 'action', 'dup' )
            .attr( 'data-action', 'dup' )
            .text( _T( ['#context-menu-duplicate', 'Duplicate Condition'] ) )
            .prop( 'disabled', $row.hasClass( 'tberror' ) )
            .toggleClass( 'disabled', $row.hasClass( 'tberror' ) )
            .appendTo( $menu );

        $( '<a class="dropdown-item"></a>' )
            .data( 'action', 'options' )
            .attr( 'data-action', 'options' )
            .text( $( 'div.condopts', $row ).length
                ? _T ( ['#context-menu-options-hide', 'Hide Options'] )
                : _T( ['#context-menu-options-show', 'Show Options'] )
            ).appendTo( $menu );

        $( '<div class="context-divider"></div>' ).appendTo( $menu );

        $( '<a class="dropdown-item"></a>' )
            .data( 'action', 'copy' )
            .attr( 'data-action', 'copy' )
            .text( _T( ['#context-menu-copy', 'Copy' ] ) )
            .prop( 'disabled', $row.hasClass( 'tberror' ) )
            .toggleClass( 'disabled', $row.hasClass( 'tberror' ) )
            .appendTo( $menu );

        $( '<a class="dropdown-item"></a>' )
            .data( 'action', 'paste' )
            .attr( 'data-action', 'paste' )
            .text( _T( ['#context-menu-paste-after', 'Paste (after)' ] ) )
            .prop( 'disabled', !localStorage.getItem( CLIPBOARD ) )
            .toggleClass( 'disabled', !localStorage.getItem( CLIPBOARD ) )
            .appendTo( $menu );

        $( '<div class="context-divider"></div>' ).appendTo( $menu );

        $( '<a class="dropdown-item"></a>' )
            .data( 'action', 'delete' )
            .attr( 'data-action', 'delete' )
            .text( _T( ['#context-menu-delete', 'Delete' ] ) )
            .addClass( 'text-danger' )
            .appendTo( $menu );
    }

    async handleContextMenuClick( action, $row ) {
        console.log("context menu", action, $row);

        switch ( action ) {

            case "dup":
                this.doConditionClone( $row );
                break;

            case "options":
                this.doToggleOptions( $row );
                break;

            case "copy":
                {
                    const cond = this.getCondition( $row.attr( 'id' ) );
                    if ( cond ) {
                        localStorage.setItem( CLIPBOARD, JSON.stringify( cond ) );
                    } else {
                        localStorage.removeItem( CLIPBOARD );
                    }
                }
                break;

            case "paste":
                {
                    const cond = this.getCondition( $row.attr( 'id' ) );
                    let newcond;
                    try {
                        const s = localStorage.getItem( CLIPBOARD );
                        newcond = JSON.parse( s );
                    } catch ( err ) {
                        console.error( "Failed to paste condition:", err );
                        localStorage.removeItem( CLIPBOARD );
                        return false;
                    }

                    // All new IDs throughout
                    RuleEditor.reassignConditionIDs( newcond, this.options?.stateless );

                    // Find the condition group to which this (right-clicked + paste) condition belongs.
                    const $parent = $row.parent().closest( 'div.cond-container.cond-group' );
                    const pgrp = this.getCondition( $parent.attr( 'id' ) );

                    // Insert the condition in the parent group's list of conditions
                    pgrp.conditions.splice( cond.__index+1, 0, newcond );

                    // Draw the new condition.
                    this.reindex();
                    await this.redrawGroup( pgrp );
                    $( `div.cond-container#${idSelector(newcond.id)}`, $parent ).addClass( "tbmodified" );
                    this.signalModified();
                }
                break;

            case "delete":
                this.doConditionDelete( $row );
                break;
        }
    }

    /**
     * Create an empty condition row. Only type selector is pre-populated.
     */
    getConditionTemplate( id ) {
        const $el = $( `
<div class="cond-container cond-cond">
  <div class="cond-body">
    <div class="cond-base d-flex flex-row flex-nowrap align-items-start">
      <div class="cond-type">
        <select class="form-select form-select-sm re-condtype"><option value="">${_T(['#opt-choose','--choose--'])}</option></select>
      </div>
      <div class="params mx-1 flex-grow-1 d-flex flex-row flex-wrap align-items-start"></div>
      <div class="cond-actions ms-auto text-end text-nowrap">
        <button class="btn bi-btn bi-btn-white re-condmore nostateless" title="${_T('Show condition options')}"><i class="bi bi-three-dots"></i></button>
        <button class="btn bi-btn bi-btn-white re-condcopy" title="${_T(['#cond-button-clone','Clone condition'])}"><i class="bi bi-stickies"></i></button>
        <button class="btn bi-btn bi-btn-white draghandle" title="${_T('Move condition (drag)')}"><i class="bi bi-arrows-move"></i></button>
        <button class="btn bi-btn bi-btn-white re-delcond" title="${_T('Delete condition')}"><i class="bi bi-x text-danger"></i></button>
      </div>
    </div>
</div>` );

        [ "comment", "entity", "rule", "var", "sun", "weekday", "trange", "interval", "startup", "script" ].forEach( function( k ) {
            $( "select.re-condtype", $el ).append(
                    $( "<option></option>" ).val( k ).text( condTypeName[k] || k )
                );
        });

        $el.attr( 'id', id );
        $('select.re-condtype', $el).on( 'change.reactor', this.handleTypeChange.bind(this) );
        $('button.re-delcond', $el).on( 'click.reactor', this.handleConditionDelete.bind(this) );
        $("button.re-condmore", $el).on( 'click.reactor', this.handleExpandOptionsClick.bind(this) );
        $('button.re-condcopy', $el).on( 'click.reactor', this.handleConditionCloneClick.bind(this) );

        Common.attachContextMenu( $el,
            this.showContextMenu.bind( this ),
            this.handleContextMenuClick.bind( this )
        );

        if ( this.options.constraints ) {
            $( '.noconstraints', $el ).remove();
            $( 'select.re-condtype option[value="interval"]', $el ).remove();
            $( 'select.re-condtype option[value="startup"]', $el ).remove();
            if ( this.options.stateless ) {
                $( '.nostateless', $el ).remove();
            }
        }
        return $el;
    }

    getGroupTemplate( grpid ) {
        const $el = $( `
<div class="cond-container cond-group">
  <div class="cond-group-header">
    <div class="float-end">
      <button class="btn bi-btn bi-btn-white re-condmore noroot nostateless" title="${_T('Show condition options')}"><i class="bi bi-three-dots"></i></button>
      <button class="btn bi-btn bi-btn-white re-condcopy noroot" title="${_T(['#grp-button-clone','Clone group'])}"><i class="bi bi-stickies"></i></button>
      <button class="btn bi-btn bi-btn-white draghandle noroot" title="${_T('Move group (drag)')}"><i class="bi bi-arrows-move"></i></button>
      <button class="btn bi-btn bi-btn-white re-delgroup noroot" title="${_T('Delete group')}"><i class="bi bi-x text-danger"></i></button>
    </div>
    <div class="cond-group-conditions">
      <div class="btn-group btn-group-sm cond-group-control tb-btn-check">
        <button class="btn btn-sm btn-primary re-op-not" data-op="not" title="${_T('#cond-group-not-tip')}"> ${_T(['#cond-group-op-not','NOT'])} </button>
      </div>
      <div class="btn-group btn-group-sm cond-group-control re-logic-op tb-btn-radio" role="group">
        <button class="btn btn-primary checked" data-op="and" title="${_T('#cond-group-and-tip')}"> ${_T(['#cond-group-op-and','AND'])} </button>
        <button class="btn btn-primary" data-op="or" title="${_T('#cond-group-or-tip')}"> ${_T(['#cond-group-op-or','OR'])} </button>
        <button class="btn btn-primary" data-op="xor" title="${_T('#cond-group-xor-tip')}"> ${_T(['#cond-group-op-xor','XOR'])} </button>
        <button class="btn btn-primary noroot" data-op="nul" title="${_T('#cond-group-nul-tip')}"> ${_T(['#cond-group-op-nul','NUL'])} </button>
      </div>
      <div class="btn-group btn-group-sm cond-group-control tb-btn-check">
        <button class="btn btn-sm btn-primary re-disable" title="${_T('#cond-group-disable-tip')}"> ${_T(['#cond-button-disable','DISABLE'])} </button>
      </div>
      <div class="cond-group-title">
        <span class="re-title"></span>
        <button class="btn bi-btn bi-btn-white re-edittitle" title="${_T('Edit group name')}"><i class="bi bi-pencil"></i></button>
        <button class="btn bi-btn bi-btn-white re-collapse ms-2 noroot" title="${_T('Collapse group')}"><i class="bi bi-arrows-collapse"></i></button>
        <button class="btn bi-btn bi-btn-white re-exportgroup ms-2 noroot" title="${_T('Export to Rule')}"><i class="bi bi-box-arrow-up-right"></i></button>
        <span class="re-titlemessage"></span>
      </div>
    </div>
  </div>
  <div class="error-container"></div>
  <div class="cond-group-body">
    <div class="cond-list"></div>
    <div class="cond-group-actions">
      <button class="btn btn-sm btn-primary re-addcond" title="${_T('Add condition to this group')}"><i class="bi bi-plus-lg me-1"></i>${_T(['#cond-button-addcond','Add Condition'])}</button>
      <button class="btn btn-sm btn-primary bi-btn-white re-addgroup" title="${_T('Add subgroup to this group')}"><i class="bi bi-folder-plus me-1"></i>${_T(['#cond-button-addgroup','Add Group'])}</button>
      <button class="btn btn-sm btn-primary re-importrule" title="${_T('Import Rule as group')}"><i class="bi bi-box-arrow-in-up-left me-1"></i>${_T(['#cond-button-importrule','Import Rule'])}</button>
    </div>
  </div>
</div>` );
        $el.attr('id', grpid);
        $( 'span.re-title', $el ).text( grpid );
        $( 'div.cond-group-conditions input[type="radio"]', $el ).attr('name', grpid);

        if ( this.options.constraints && this.options.stateless ) {
            $( '.nostateless', $el ).remove();
        }

        Common.attachContextMenu( $el,
            this.showContextMenu.bind( this ),
            this.handleContextMenuClick.bind( this )
        );

        $( 'button.re-addcond', $el ).on( 'click.reactor', this.handleAddConditionClick.bind(this) );
        $( 'button.re-addgroup', $el ).on( 'click.reactor', this.handleAddGroupClick.bind(this) );
        $( 'button.re-importrule', $el ).on( 'click.reactor', this.handleImportRuleClick.bind(this) );
        $( 'button.re-delgroup', $el ).on( 'click.reactor', this.handleDeleteGroupClick.bind(this) );
        $( 'button.re-condmore', $el ).on( 'click.reactor', this.handleExpandOptionsClick.bind(this) );
        $( 'button.re-condcopy', $el ).on( 'click.reactor', this.handleConditionCloneClick.bind(this) );
        $( 'span.re-title,button.re-edittitle', $el ).on( 'click.reactor', this.handleCondTitleClick.bind(this) );
        $( 'button.re-collapse', $el ).on( 'click.reactor', this.handleGroupExpandClick.bind(this) );
        $( 'button.re-exportgroup', $el ).on( 'click.reactor', this.handleGroupExportClick.bind(this) );
        $( '.cond-group-control > button', $el ).on( 'click.reactor', this.handleGroupControlClick.bind(this) );
        $( '.cond-list', $el ).addClass("re-sortable").sortable({
            // helper: 'clone',
            helper: () => {
                return $( '<div class="ui-sortable-helper"></div>' );
            },
            cursorAt: { left: 64, top: 16 },  // our ui-sortable-helper is 128x32
            handle: '.draghandle',
            cancel: '', /* so draghandle can be button */
            items: '> *:not([id="root"])',
            classes: {
                "ui-sortable-placeholder": "",
                "ui-sortable-helper": ""
            },
            placeholder: "re-insertionpt",
            opacity: 0.67,
            connectWith: 'div.cond-list.re-sortable',
            containment: this.$editor,
            /* https://stackoverflow.com/questions/15724617/jQuery-dragmove-but-leave-the-original-if-ctrl-key-is-pressed
            start: function( ev, ui ) {
                if ( ev.ctrlKey ) {
                    $clone = ui.item.clone().insertBefore( ui.item );
                    $clone.css({position:"static"});
                }
            },
            */
            receive: this.handleNodeReceive.bind(this), /* between cond-lists */
            update: this.handleNodeUpdate.bind(this)    /* within one cond-list */
        });
        return $el;
    }

    /* (Re)draw the group. If container and depth are not given, the group is redrawn in place. */
    /* ??? is everything reindexed before initial draw? If so, we can eliminate depth parameter
     * and use the embedded property of the group. */
    async redrawGroup( grp, $container ) {
        let $el;
        if ( ! $container ) {
            $el = $( `#${idSelector(grp.id)}.cond-group`, this.$editor );
            $( 'div.cond-list', $el ).empty();
        } else {
            $el = this.getGroupTemplate( grp.id );
            $el.appendTo( $container );
        }
        $el.addClass( 'level' + grp.__depth ).addClass( 'levelmod' + (grp.__depth % 4) );
        if ( 0 === grp.__depth ) {
            /* For root group, remove all elements with class noroot */
            $( '.noroot', $el ).remove();
            if ( "undefined" !== typeof this.options.fixedName ) {
                $( 'button.re-edittitle', $el ).hide().off( 'click.reactor' );
                $( 'span.re-title', $el ).off( 'click.reactor' )
                    .text( this.options.fixedName );
            } else {
                $( 'span.re-title', $el ).text( grp.name || grp.id )
                    .attr( 'title', _T('Click to change group name') );
            }
        } else {
            $( 'span.re-title', $el ).text( grp.name || grp.id )
                .attr( 'title', _T('Click to change group name') );
        }

        $( 'div.cond-group-conditions .tb-btn-radio button', $el ).removeClass( "checked" );
        $( 'div.cond-group-conditions .re-logic-op button[data-op="' + ( grp.op || (grp.data || {}).op || "and" ) + '"]', $el )
            .addClass( "checked" );
        if ( grp.invert ) {
            $( 'div.cond-group-conditions button.re-op-not', $el ).addClass( "checked" );
        } else {
            delete grp.invert;
        }
        if ( grp.disabled ) {
            $( 'div.cond-group-conditions button.re-disable', $el ).addClass( "checked" );
        } else {
            delete grp.disabled;
        }

        if ( grp.options ) {
            if ( this.options.stateless ) {
                console.log("*** STATELESS",grp.id,"REMOVING OPTIONS",grp.options);
                delete grp.options;
                this.signalModified();
            } else if ( Common.hasAnyProperty( grp.options ) ) {
                $( 'button.re-condmore', $el ).addClass( 'attn' );
            }
        }

        $container = $( 'div.cond-list', $el );

        let lx = ( grp.conditions || [] ).length;
        for ( let ix=0; ix<lx; ix++ ) {
            let cond = grp.conditions[ix];
            if ( "group" !== ( cond.type || "group" ) ) {
                if ( cond.options && this.options.stateless ) {
                    console.log("*** STATELESS",cond.id,"REMOVING OPTIONS",cond.options);
                    delete cond.options;
                    this.signalModified();
                }
                let $row = this.getConditionTemplate( cond.id ).appendTo( $container );
                let $sel = $('select.re-condtype', $row);
                if ( 0 === $('option[value="' + cond.type + '"]', $sel).length ) {
                    /* Condition type not on menu, probably a deprecated form. Insert it. */
                    $sel.append('<option value="' + cond.type + '">' +
                        (condTypeName[cond.type] === undefined ? cond.type + ' (deprecated)' : condTypeName[cond.type] ) +
                        '</option>');
                }
                $sel.val( cond.type );
                await this.setConditionForType( cond, $row );
            } else {
                /* Group! */
                await this.redrawGroup( cond, $container );
            }
        }
    }

    async redraw() {
        if ( 0 === $( 'link#rule-editor-styles' ).length ) {
            $( '<link id="rule-editor-styles" rel="stylesheet" href="lib/css/rule-editor.css"></link>' )
                .appendTo( 'head' );
        }
        this.reindex();
        this.$editor.empty();
        // console.log("DRAW",this.data);
        await this.redrawGroup( this.data, this.$editor );

        /* Refresh all sortables */
        $( '.re-sortable', this.$editor ).sortable( "refresh" );

        // The outermost group doesn't get a context menu.
        Common.detachContextMenu( $( 'div.cond-container.cond-group.level0' ) );

        /* When the in-rule expressions editor signals modified, update expression menus everywhere */
        try {
            const expr_editor = this.$editor.closest( 'div.re-tab-container' ).data( 'tabobject' ).getEditor( 'expr_editor' );
            const self = this;
            this.subscribe( expr_editor, ( msg ) => {
                if ( "modified" === msg.type ) {
                    const $me = $( 'select.exprmenu', self.$tab );
                    console.log("expr_editor event", msg, "found", $me.length, "exprmenus to update");
                    self.makeExprMenu( "" ).then( ($mm) => {
                        $me.each( (ix, obj) => {
                            const $mx = $( obj );
                            //console.log("update",$mx,"with",$mm);
                            const selval = $mx.val() || "";
                            /* Replace menu contents */
                            $mx.empty().append( $mm.clone().children() );  // clone!
                            /* Restore user's prior selection (which may have been deleted!) */
                            Common.menuSelectDefaultInsert( $mx, selval );
                        });
                    }).catch( (err) => {
                        console.error("Failed to update exprmenu", err);
                        console.error(err);
                    });
                }
            });
        } catch ( err ) {
            console.error( err );
        }

        this.updateSaveControls();
    }
}
