js/form-model.js

import MergeXML from 'mergexml/mergexml';
import { readCookie, parseFunctionFromExpression, stripQuotes } from './utils';
import { getSiblingElementsAndSelf, getXPath, getRepeatIndex, hasPreviousCommentSiblingWithContent, hasPreviousSiblingElementSameName } from './dom-utils';
import FormLogicError from './form-logic-error';
import config from 'enketo/config';
import types from './types';
import event from './event';
import { Nodeset } from './nodeset';
import bindJsEvaluator from 'enketo/xpath-evaluator-binding';

const REPEAT_COMMENT_PREFIX = 'repeat:/';
const INSTANCE = /instance\(\s*(["'])((?:(?!\1)[A-z0-9.\-_]+))\1\s*\)/g;
const OPENROSA = /(decimal-date-time\(|pow\(|indexed-repeat\(|format-date\(|coalesce\(|join\(|max\(|min\(|random\(|substr\(|int\(|uuid\(|regex\(|now\(|today\(|date\(|if\(|boolean-from-string\(|checklist\(|selected\(|selected-at\(|round\(|area\(|position\([^)])/;
const OPENROSA_XFORMS_NS = 'http://openrosa.org/xforms';
const JAVAROSA_XFORMS_NS = 'http://openrosa.org/javarosa';
const ENKETO_XFORMS_NS = 'http://enketo.org/xforms';
const ODK_XFORMS_NS = 'http://www.opendatakit.org/xforms';

import './extend';

const parser = new DOMParser();

/**
 * Class dealing with the XML Model of a form
 *
 * @class
 * @param {FormDataObj} data - data object
 * @param {object=} options - FormModel options
 * @param {string=} options.full - Whether to initialize the full model or only the primary instance.
 */
const FormModel = function( data, options ) {

    if ( typeof data === 'string' ) {
        data = {
            modelStr: data
        };
    }

    data.external = data.external || [];
    data.submitted = ( typeof data.submitted !== 'undefined' ) ? data.submitted : true;
    options = options || {};
    options.full = ( typeof options.full !== 'undefined' ) ? options.full : true;

    this.events = document.createElement( 'div' );
    this.convertedExpressions = {};
    this.templates = {};
    this.loadErrors = [];

    this.data = data;
    this.options = options;
    this.namespaces = {};
};

/**
 * Getter and setter functions
 */
FormModel.prototype = {
    /**
     * @type {string}
     */
    get version() {
        return this.evaluate( '/*/@version', 'string', null, null, true );
    },
    /**
     * @type {string}
     */
    get instanceID() {
        return this.getMetaNode( 'instanceID' ).getVal();
    },
    /**
     * @type {string}
     */
    get deprecatedID() {
        return this.getMetaNode( 'deprecatedID' ).getVal() || '';
    },
    /**
     * @type {string}
     */
    get instanceName() {
        return this.getMetaNode( 'instanceName' ).getVal();
    },
};

/**
 * Initializes FormModel
 *
 * @return {Array<string>} list of initialization errors
 */
FormModel.prototype.init = function() {
    let id;
    let i;
    let instanceDoc;
    let secondaryInstanceChildren;
    const that = this;

    /**
     * Default namespaces (on a primary instance, instance child, model) would create a problem using the **native** XPath evaluator.
     * It wouldn't find any regular /path/to/nodes. The solution is to ignore these by renaming these attributes to data-xmlns.
     *
     * If the regex is later deemed too aggressive, it could target the model, primary instance and primary instance child only, after creating an XML Document.
     */
    this.data.modelStr = this.data.modelStr.replace( /\s(xmlns=("|')[^\s>]+("|'))/g, ' data-$1' );

    if ( !this.options.full ) {
        // Strip all secondary instances from string before parsing
        // This regex works because the model never includes itext in Enketo
        this.data.modelStr = this.data.modelStr.replace( /^(<model\s*><instance((?!<instance).)+<\/instance\s*>\s*)(<instance.+<\/instance\s*>)*/, '$1' );
    }

    // Create the model
    try {
        id = 'model';
        // The default model
        this.xml = parser.parseFromString( this.data.modelStr, 'text/xml' );
        this.throwParserErrors( this.xml, this.data.modelStr );

        // Add external data to model
        this.data.external.forEach( instance => {
            id = instance.id ? `instance "${instance.id}"` : 'instance "unknown"';
            instanceDoc = that.getSecondaryInstance( instance.id );
            // remove any existing content that is just an XLSForm hack to pass ODK Validate
            secondaryInstanceChildren = instanceDoc.children;
            for ( i = secondaryInstanceChildren.length - 1; i >= 0; i-- ) {
                instanceDoc.removeChild( secondaryInstanceChildren[ i ] );
            }
            let rootEl;
            if ( instance.xml instanceof XMLDocument ) {
                if ( window.navigator.userAgent.indexOf( 'Trident/' ) >= 0 ) {
                    // IE does not support importNode
                    rootEl = that.importNode( instance.xml.documentElement, true );
                } else {
                    // Create a clone of the root node
                    rootEl = that.xml.importNode( instance.xml.documentElement, true );
                }
            }
            if ( rootEl ) {
                instanceDoc.appendChild( rootEl );
            }
        } );

        // TODO: in the future, we should search for jr://instance/session and
        // populate that one. This is just moving in that direction to implement preloads.
        this.createSession( '__session', this.data.session );
    } catch ( e ) {
        console.error( 'parseXML error' );
        this.loadErrors.push( `Error trying to parse XML ${id}. ${e.message}` );
    }

    // Initialize/process the model
    if ( this.xml ) {
        try {
            this.hasInstance = !!this.xml.querySelector( 'model > instance' );
            this.rootElement = this.xml.querySelector( 'instance > *' ) || this.xml.documentElement;
            this.setNamespaces();

            // Determine whether it is possible that this form uses incorrect absolute/path/to/repeat/node syntax when
            // it actually was supposed to use a relative ../node path (old issue with older pyxform-generated forms).
            // In the future, if there are more use cases for odk:xforms-version, we'll probably have to use a semver-parser
            // to do a comparison. In this case, the presence of the attribute is sufficient, as we know no older versions
            // than odk:xforms-version="1.0.0" exist. Previous versions had no number.
            this.noRepeatRefErrorExpected = this.evaluate( `/model/@${this.getNamespacePrefix( ODK_XFORMS_NS )}:xforms-version`, 'boolean', null, null, true );

            // Check if instanceID is present
            if ( !this.getMetaNode( 'instanceID' ).getElement() ) {
                that.loadErrors.push( 'Invalid primary instance. Missing instanceID node.' );
            }

            // Check if all secondary instances with an external source have been populated
            Array.prototype.slice.call( this.xml.querySelectorAll( 'model > instance[src]:empty' ) ).forEach( instance => {
                that.loadErrors.push( `External instance "${instance.id}" is empty.` );
            } );

            this.trimValues();
            this.extractTemplates();
        } catch ( e ) {
            console.error( e );
            this.loadErrors.push( `${e.name}: ${e.message}` );
        }
        // Merge an existing instance into the model, AFTER templates have been removed
        try {
            id = 'record';
            if ( this.data.instanceStr ) {
                this.mergeXml( this.data.instanceStr );
            }

            // Set the two most important meta fields before any field 'dataupdate' event fires.
            // The first dataupdate event will fire in response to the instance-first-load event.
            this.setInstanceIdAndDeprecatedId();

            if ( !this.data.instanceStr ){
                // Only dispatch for newly created records
                this.events.dispatchEvent( event.InstanceFirstLoad() );
            }

        } catch ( e ) {
            console.error( e );
            this.loadErrors.push( `Error trying to parse XML ${id}. ${e.message}` );
        }
    }

    return this.loadErrors;
};

/**
 * @param {Document} xmlDoc - XML Document
 * @param {string} xmlStr - XML string
 */
FormModel.prototype.throwParserErrors = ( xmlDoc, xmlStr ) => {
    if ( !xmlDoc || xmlDoc.querySelector( 'parsererror' ) ) {
        throw new Error( `Invalid XML: ${xmlStr}` );
    }
};

/**
 * @param {string} id - Instance ID
 * @param {object} [sessObj] - session object
 */
FormModel.prototype.createSession = function( id, sessObj ) {
    let instance;
    let session;
    const model = this.xml.querySelector( 'model' );
    const fixedProps = [ 'deviceid', 'username', 'email', 'phonenumber', 'simserial', 'subscriberid' ];
    if ( !model ) {
        return;
    }

    sessObj = ( typeof sessObj === 'object' ) ? sessObj : {};
    instance = model.querySelector( `instance#${CSS.escape( id )}` );

    if ( !instance ) {
        instance = parser.parseFromString( `<instance id="${id}"/>`, 'text/xml' ).documentElement;
        this.xml.adoptNode( instance );
        model.appendChild( instance );
    }

    // fixed: /sesssion/context properties
    fixedProps.forEach( prop => {
        sessObj[ prop ] = sessObj[ prop ] || readCookie( `__enketo_meta_${prop}` ) || `${prop} not found`;
    } );

    session = parser.parseFromString( `<session><context>${fixedProps.map( prop => `<${prop}>${sessObj[ prop ]}</${prop}>` ).join( '' )}</context></session>`, 'text/xml' ).documentElement;

    // TODO: custom properties could be added to /session/user/data or to /session/data

    this.xml.adoptNode( session );
    instance.appendChild( session );
};

/**
 * For some unknown reason we cannot use doc.getElementById(id) or doc.querySelector('#'+id)
 * in IE11. This function is a replacement for this specifically to find a secondary instance.
 *
 * @param  {string} id - DOM element id.
 * @return {Element|undefined} secondary instance XML element
 */
FormModel.prototype.getSecondaryInstance = function( id ) {
    let instanceEl;

    [ ...this.xml.querySelectorAll( 'model > instance' ) ].some( el => {
        const idAttr = el.getAttribute( 'id' );
        if ( idAttr === id ) {
            instanceEl = el;

            return true;
        } else {
            return false;
        }
    } );

    return instanceEl;
};

/**
 * Returns a new Nodeset instance
 *
 * @param {string|null} [selector] - simple path to node
 * @param {string|number|null} [index] - index of node
 * @param {NodesetFilter|null} [filter] - filter to apply
 * @return {Nodeset} Nodeset instance
 */
FormModel.prototype.node = function( selector, index, filter ) {
    return new Nodeset( selector, index, filter, this );
};

/**
 * Alternative adoptNode on IE11 (http://stackoverflow.com/questions/1811116/ie-support-for-dom-importnode)
 * TODO: remove to be replaced by separate IE11-only polyfill file/service
 *
 * @param {Element} node - Node to be imported
 * @param {Array<Node>} allChildren - All children of imported Node
 */
FormModel.prototype.importNode = function( node, allChildren ) {
    let i;
    let il;
    switch ( node.nodeType ) {
        case document.ELEMENT_NODE: {
            const newNode = document.createElementNS( node.namespaceURI, node.nodeName );
            if ( node.attributes && node.attributes.length > 0 ) {
                for ( i = 0, il = node.attributes.length; i < il; i++ ) {
                    const attr = node.attributes[ i ];
                    if ( attr.namespaceURI ) {
                        newNode.setAttributeNS( attr.namespaceURI, attr.nodeName, node.getAttributeNS( attr.namespaceURI, attr.localName ) );
                    } else {
                        newNode.setAttribute( attr.nodeName, node.getAttribute( attr.nodeName ) );
                    }
                }
            }
            if ( allChildren && node.children.length ) {
                for ( i = 0, il = node.children.length; i < il; i++ ) {
                    newNode.appendChild( this.importNode( node.children[ i ], allChildren ) );
                }
            }
            if ( !node.children.length && node.textContent ) {
                newNode.textContent = node.textContent;
            }

            return newNode;
        }
        case document.TEXT_NODE:
        case document.CDATA_SECTION_NODE:
        case document.COMMENT_NODE:
            return document.createTextNode( node.nodeValue );
    }
};
/**
 * Merges an XML instance string into the XML Model
 *
 * @param {string} recordStr - The XML record as string
 */
FormModel.prototype.mergeXml = function( recordStr ) {
    let modelInstanceChildStr;
    let merger;
    let modelInstanceEl;
    let modelInstanceChildEl;
    let mergeResultDoc;
    const that = this;
    let templateEls;
    let record;

    if ( !recordStr ) {
        return;
    }

    modelInstanceEl = this.xml.querySelector( 'instance' );
    modelInstanceChildEl = this.xml.querySelector( 'instance > *' ); // do not use firstChild as it may find a #textNode

    if ( !modelInstanceChildEl ) {
        throw new Error( 'Model is corrupt. It does not contain a childnode of instance' );
    }

    /**
     * A Namespace merge problem occurs when ODK decides to invent a new namespace for a submission
     * that is different from the XForm model namespace... So we just remove this nonsense.
     */
    recordStr = recordStr.replace( /\s(xmlns=("|')[^\s>]+("|'))/g, '' );
    /**
     * Comments aren't merging in document order (which would be impossible also).
     * This may mess up repeat functionality, so until we actually need
     * comments, we simply remove them (multiline comments are probably not removed, but we don't care about them).
     */
    recordStr = recordStr.replace( /<!--[^>]*-->/g, '' );
    record = parser.parseFromString( recordStr, 'text/xml' );

    /**
     * Normally records will not contain the special "jr:template" attribute. However, we should still be able to deal with
     * this if they do, including the old hacked non-namespaced "template" attribute.
     * https://github.com/enketo/enketo-core/issues/376
     *
     * The solution if these are found is to delete the node.
     *
     * Since the record is not a FormModel instance we revert to a very aggressive querySelectorAll that selects all
     * nodes with a template attribute name IN ANY NAMESPACE.
     */

    templateEls = record.querySelectorAll( '[*|template]' );

    for ( let i = 0; i < templateEls.length; i++ ) {
        templateEls[ i ].remove();
    }

    /**
     * To comply with quirky behaviour of repeats in XForms, we manually create the correct number of repeat instances
     * before merging. This resolves these two issues:
     *  a) Multiple repeat instances in record are added out of order when merged into a record that contains fewer
     *     repeat instances, see https://github.com/kobotoolbox/enketo-express/issues/223
     *  b) If a repeat node is missing from a repeat instance (e.g. the 2nd) in a record, and that repeat instance is not
     *     in the model, that node will be missing in the result.
     */
    // TODO: ES6 for (var node of record.querySelectorAll('*')){}
    Array.prototype.slice.call( record.querySelectorAll( '*' ) )
        .forEach( node => {
            let path;
            let repeatIndex = 0;
            let positionedPath;
            let repeatParts;
            try {
                path = getXPath( node, 'instance', false );
                // If this is a templated repeat (check templates)
                // or a repeat without templates
                if ( typeof that.templates[ path ] !== 'undefined' || getRepeatIndex( node ) > 0 ) {
                    positionedPath = getXPath( node, 'instance', true );
                    if ( !that.evaluate( positionedPath, 'node', null, null, true ) ) {
                        repeatParts = positionedPath.match( /([^[]+)\[(\d+)\]\//g );
                        // If the positionedPath has a non-0 repeat index followed by (at least) 1 node, avoid cloning out of order.
                        if ( repeatParts && repeatParts.length > 0 ) {
                            // TODO: Does this work for triple-nested repeats. I don't really care though.
                            // repeatIndex of immediate parent repeat of deepest nested repeat in positionedPath
                            repeatIndex = repeatParts[ repeatParts.length - 1 ].match( /\[(\d+)\]/ )[ 1 ] - 1;
                        }
                        that.addRepeat( path, repeatIndex, true );
                    }
                }
            } catch ( e ) {
                console.warn( 'Ignored error:', e );
            }
        } );

    /**
     * Any default values in the model, may have been emptied in the record.
     * MergeXML will keep those default values, which would be bad, so we manually clear defaults before merging.
     */
    // first find all empty leaf nodes in record
    Array.prototype.slice.call( record.querySelectorAll( '*' ) )
        .filter( recordNode => {
            const val = recordNode.textContent;

            return recordNode.children.length === 0 && val.trim().length === 0;
        } )
        .forEach( leafNode => {
            const path = getXPath( leafNode, 'instance', true );
            const instanceNode = that.node( path, 0 ).getElement();
            if ( instanceNode ) {
                // TODO: after dropping support for IE11, we can also use instanceNode.children.length
                if ( that.evaluate( './*', 'nodes', path, 0, true ).length === 0 ) {
                    // Select all text nodes (excluding repeat COMMENT nodes!)
                    that.evaluate( './text()', 'nodes', path, 0, true ).forEach( node => {
                        node.textContent = '';
                    } );
                } else {
                    // If the node in the default instance is a group (empty in record, so appears to be a leaf node
                    // but isn't), empty all true leaf node descendants.
                    that.evaluate( './/*[not(*)]', 'nodes', path, 0, true ).forEach( node => {
                        node.textContent = '';
                    } );
                }
            }
        } );

    merger = new MergeXML( {
        join: false
    } );

    modelInstanceChildStr = ( new XMLSerializer() ).serializeToString( modelInstanceChildEl );
    recordStr = ( new XMLSerializer() ).serializeToString( record );

    // first the model, to preserve DOM order of that of the default instance
    merger.AddSource( modelInstanceChildStr );
    // then merge the record into the model
    merger.AddSource( recordStr );

    if ( merger.error.code ) {
        throw new Error( merger.error.text );
    }

    /**
     * Beware: merge.Get(0) returns an ActiveXObject in IE11. We turn this
     * into a proper XML document by parsing the XML string instead.
     */
    mergeResultDoc = parser.parseFromString( merger.Get( 1 ), 'text/xml' );

    /**
     * To properly show 0 repeats, if the form definition contains multiple default instances
     * and the record contains none, we have to iterate trough the templates object, and
     * 1. check for each template path, whether the record contained more than 0 of these nodes
     * 2. remove all nodes on that path if the answer was no.
     *
     * Since this requires complex handcoded XForms it is unlikely to ever be needed, so I left this
     * functionality out.
     */

    // Remove the primary instance childnode from the original model
    this.xml.querySelector( 'instance' ).removeChild( modelInstanceChildEl );
    // checking if IE
    if ( window.navigator.userAgent.indexOf( 'Trident/' ) >= 0 ) {
        // IE does not support adoptNode
        modelInstanceChildEl = this.importNode( mergeResultDoc.documentElement, true );
    } else {
        // adopt the merged instance childnode
        modelInstanceChildEl = this.xml.adoptNode( mergeResultDoc.documentElement, true );
    }
    // append the adopted node to the primary instance
    modelInstanceEl.appendChild( modelInstanceChildEl );
    // reset the rootElement
    this.rootElement = modelInstanceChildEl;

};

/**
 * Trims values of all Form elements
 */
FormModel.prototype.trimValues = function() {
    this.node( null, null, {
        noEmpty: true
    } ).getElements().forEach( element => {
        element.textContent = element.textContent.trim();
    } );
};

/**
 * Sets instance ID and deprecated ID
 */
FormModel.prototype.setInstanceIdAndDeprecatedId = function() {
    let instanceIdObj;
    let instanceIdEl;
    let deprecatedIdEl;
    let metaEl;
    let instanceIdExistingVal;

    instanceIdObj = this.getMetaNode( 'instanceID' );
    instanceIdEl = instanceIdObj.getElement();
    instanceIdExistingVal = instanceIdObj.getVal();

    if ( !instanceIdEl ){
        console.warn( 'Model has no instanceID element' );

        return;
    }

    if ( this.data.instanceStr && this.data.submitted ) {
        deprecatedIdEl = this.getMetaNode( 'deprecatedID' ).getElement();

        // set the instanceID value to empty
        instanceIdEl.textContent = '';

        // add deprecatedID node if necessary
        if ( !deprecatedIdEl ) {
            deprecatedIdEl = parser.parseFromString( '<deprecatedID/>', 'text/xml' ).documentElement;
            this.xml.adoptNode( deprecatedIdEl );
            metaEl = this.xml.querySelector( '* > meta' );
            metaEl.appendChild( deprecatedIdEl );
        }
    }

    if ( !instanceIdObj.getVal() ) {
        instanceIdObj.setVal( this.evaluate( 'concat("uuid:", uuid())', 'string' ) );
    }

    // after setting instanceID, give deprecatedID element the old value of the instanceId
    // ensure dataupdate event fires by using setVal
    if ( deprecatedIdEl ) {
        this.getMetaNode( 'deprecatedID' ).setVal( instanceIdExistingVal );
    }
};

/**
 * Creates a custom XPath Evaluator to be used for XPath Expresssions that contain custom
 * OpenRosa functions or for browsers that do not have a native evaluator.
 *
 * @type {Function}
 */
FormModel.prototype.bindJsEvaluator = bindJsEvaluator;

/**
 * @param {string} localName - node name without namespace
 * @return {Element} node
 */
FormModel.prototype.getMetaNode = function( localName ) {
    const orPrefix = this.getNamespacePrefix( OPENROSA_XFORMS_NS );
    let n = this.node( `/*/${orPrefix}:meta/${orPrefix}:${localName}` );

    if ( !n.getElement() ) {
        n = this.node( `/*/meta/${localName}` );
    }

    return n;
};

/**
 * @param {string} path - path to repeat
 * @return {string} repeat comment text
 */
FormModel.prototype.getRepeatCommentText = path => {
    path = path.trim();

    return REPEAT_COMMENT_PREFIX + path;
};

/**
 * @param {string} repeatPath - path to repeat
 * @return {string} selector
 */
FormModel.prototype.getRepeatCommentSelector = function( repeatPath ) {
    return `//comment()[self::comment()="${this.getRepeatCommentText( repeatPath )}"]`;
};

/**
 * @param {string} repeatPath - path to repeat
 * @param {number} repeatSeriesIndex - index of repeat series
 * @return {Element} node
 */
FormModel.prototype.getRepeatCommentEl = function( repeatPath, repeatSeriesIndex ) {
    return this.evaluate( this.getRepeatCommentSelector( repeatPath ), 'nodes', null, null, true )[ repeatSeriesIndex ];
};

/**
 * Adds a <repeat>able instance node in a particular series of a repeat.
 *
 * @param  {string} repeatPath - absolute path of a repeat
 * @param  {number} repeatSeriesIndex - index of the repeat series that gets a new repeat (this is always 0 for non-nested repeats)
 * @param  {boolean} merge - whether this operation is part of a merge operation (won't send dataupdate event, clears all values and
 *                           will not add ordinal attributes as these should be provided in the record)
 */
FormModel.prototype.addRepeat = function( repeatPath, repeatSeriesIndex, merge ) {
    let templateClone;
    const that = this;

    if ( !this.templates[ repeatPath ] ) {
        // This allows the model itself without requiring the controller to cal call .extractFakeTemplates()
        // to extract non-jr:templates by assuming that addRepeat would only called for a repeat.
        this.extractFakeTemplates( [ repeatPath ] );
    }

    const template = this.templates[ repeatPath ];
    const repeatSeries = this.getRepeatSeries( repeatPath, repeatSeriesIndex );
    const insertAfterNode = repeatSeries.length ? repeatSeries[ repeatSeries.length - 1 ] : this.getRepeatCommentEl( repeatPath, repeatSeriesIndex );

    // if not exists and not a merge operation
    if ( !merge ) {
        repeatSeries.forEach( el => {
            that.addOrdinalAttribute( el, repeatSeries[ 0 ] );
        } );
    }

    /**
     * If templatenodes and insertAfterNode(s) have been identified
     */
    if ( template && insertAfterNode ) {
        templateClone = template.cloneNode( true );
        insertAfterNode.after( templateClone );

        this.removeOrdinalAttributes( templateClone );
        // We should not automatically add ordinal attributes for an existing record as the ordinal values cannot be determined.
        // They should be provided in the instanceStr (record).
        if ( !merge ) {
            this.addOrdinalAttribute( templateClone, repeatSeries[ 0 ] );
        }

        // If part of a merge operation (during form load) where the values will be populated from the record, defaults are not desired.
        if ( merge ) {
            Array.prototype.slice.call( templateClone.querySelectorAll( '*' ) )
                .filter( node => node.children.length === 0 )
                .forEach( node => { node.textContent = ''; } );
        }

        // Note: the addrepeat eventhandler in Form.js takes care of initializing branches etc, so no need to fire an event here.
    } else {
        console.error( 'Could not find template node and/or node to insert the clone after' );
    }
};

/**
 * @param {Element} repeat - Set ordinal attribue to this node
 * @param {Element} firstRepeatInSeries - Used to know what the next ordinal attribute value should be. Defaults to `repeat` node.
 */
FormModel.prototype.addOrdinalAttribute = function( repeat, firstRepeatInSeries ) {
    let lastUsedOrdinal;
    let newOrdinal;
    const enkNs = this.getNamespacePrefix( ENKETO_XFORMS_NS );
    firstRepeatInSeries = firstRepeatInSeries || repeat;
    if ( config.repeatOrdinals === true && !repeat.getAttributeNS( ENKETO_XFORMS_NS, 'ordinal' ) ) {
        // getAttributeNs and setAttributeNs results in duplicate namespace declarations on each repeat node in IE11 when serializing the model.
        // However, the regular getAttribute and setAttribute do not work properly in IE11.
        lastUsedOrdinal = firstRepeatInSeries.getAttributeNS( ENKETO_XFORMS_NS, 'last-used-ordinal' ) || 0;
        newOrdinal = Number( lastUsedOrdinal ) + 1;
        firstRepeatInSeries.setAttributeNS( ENKETO_XFORMS_NS, `${enkNs}:last-used-ordinal`, newOrdinal );

        repeat.setAttributeNS( ENKETO_XFORMS_NS, `${enkNs}:ordinal`, newOrdinal );
    }
};

/**
 * Removes all ordinal attriubetes from all applicable nodes
 *
 * @param {Element} el - Target node
 */
FormModel.prototype.removeOrdinalAttributes = el => {
    if ( config.repeatOrdinals === true ) {
        // Find all nested repeats first (this is only used for repeats that have no template).
        // The querySelector is actually too unspecific as it matches all ordinal attributes in ANY namespace.
        // However the proper [enk\\:ordinal] doesn't work if setAttributeNS was used to add the attribute.
        const repeats = Array.prototype.slice.call( el.querySelectorAll( '[*|ordinal]' ) );
        repeats.push( el );
        for ( let i = 0; i < repeats.length; i++ ) {
            repeats[ i ].removeAttributeNS( ENKETO_XFORMS_NS, 'last-used-ordinal' );
            repeats[ i ].removeAttributeNS( ENKETO_XFORMS_NS, 'ordinal' );
        }
    }
};

/**
 * Obtains a single series of repeat element;
 *
 * @param {string} repeatPath - The absolute path of the repeat.
 * @param {number} repeatSeriesIndex - The index of the series of that repeat.
 * @return {Array<Element>} Array of all repeat elements in a series.
 */
FormModel.prototype.getRepeatSeries = function( repeatPath, repeatSeriesIndex ) {
    let pathSegments;
    let nodeName;
    let checkEl;
    const repeatCommentEl = this.getRepeatCommentEl( repeatPath, repeatSeriesIndex );
    const result = [];

    // RepeatCommentEl is null if the requested repeatseries is a nested repeat and its ancestor repeat
    // has 0 instances.
    if ( repeatCommentEl ) {
        pathSegments = repeatCommentEl.textContent.substr( REPEAT_COMMENT_PREFIX.length ).split( '/' );
        nodeName = pathSegments[ pathSegments.length - 1 ];
        checkEl = repeatCommentEl.nextSibling;

        // then add all subsequent repeats
        while ( checkEl ) {
            // Ignore any sibling text and comment nodes (e.g. whitespace with a newline character)
            // also deal with repeats that have non-repeat siblings in between them, event though that would be a bug.
            if ( checkEl.nodeName && checkEl.nodeName === nodeName ) {
                result.push( checkEl );
            }
            checkEl = checkEl.nextSibling;
        }
    }

    return result;
};

/**
 * Determines the index of a repeated node amongst all nodes with the same XPath selector
 *
 * @param {Element} element - Target node
 * @return {number} Determined index
 */
FormModel.prototype.determineIndex = function( element ) {
    if ( element ) {
        const nodeName = element.nodeName;
        const path = getXPath( element, 'instance' );
        const family = Array.prototype.slice.call( this.xml.querySelectorAll( nodeName.replace( /\./g, '\\.' ) ) )
            .filter( node => path === getXPath( node, 'instance' ) );

        return family.length === 1 ? null : family.indexOf( element );
    } else {
        console.error( 'no node, or multiple nodes, provided to determineIndex function' );

        return -1;
    }
};

/**
 * Extracts all templates from the model and stores them in a Javascript object.
 */
FormModel.prototype.extractTemplates = function() {
    const that = this;

    // in reverse document order to properly deal with nested repeat templates
    this.getTemplateNodes().reverse().forEach( templateEl => {
        const xPath = getXPath( templateEl, 'instance' );
        that.addTemplate( xPath, templateEl );
        /*
         * Nested repeats that have a template attribute are correctly added to the templates object.
         * The template of the repeat ancestor of the nested repeat contains the correct comment.
         * However, since the ancestor repeat (template)
         */
        templateEl.remove();
    } );
};

/**
 * @param {Array<string>} repeatPaths - repeat paths
 */
FormModel.prototype.extractFakeTemplates = function( repeatPaths ) {
    const that = this;
    let repeat;

    repeatPaths.forEach( repeatPath => {
        // Filter by elements that are the first in a series. This means that multiple instances of nested repeats
        // all get a comment insertion point.
        repeat = that.evaluate( repeatPath, 'node', null, null, true );
        if ( repeat ) {
            that.addTemplate( repeatPath, repeat, true );
        }
    } );
};

/**
 * @param {string} repeatPath - path to repeat
 */
FormModel.prototype.addRepeatComments = function( repeatPath ) {
    const comment = this.getRepeatCommentText( repeatPath );

    // Find all repeat series.
    this.evaluate( repeatPath, 'nodes', null, null, true ).forEach( repeat => {
        if ( !hasPreviousSiblingElementSameName( repeat ) && !hasPreviousCommentSiblingWithContent( repeat, comment ) ) {
            // Add a comment to the primary instance that serves as an insertion point for each repeat series,
            repeat.before( document.createComment( comment ) );
        }
    } );
};

/**
 * @param {string} repeatPath - path to repeat
 * @param {Element} repeat - Target node
 * @param {boolean} empty - whether to empty values before adding the template
 */
FormModel.prototype.addTemplate = function( repeatPath, repeat, empty ) {
    this.addRepeatComments( repeatPath );

    if ( !this.templates[ repeatPath ] ) {
        const clone = repeat.cloneNode( true );
        clone.removeAttribute( 'template' );
        clone.removeAttribute( 'jr:template' );
        if ( empty ) {
            Array.prototype.slice.call( clone.querySelectorAll( '*' ) )
                .filter( node => node.children.length === 0 )
                .forEach( node => {
                    node.textContent = '';
                } );
        }
        // Add to templates object.
        this.templates[ repeatPath ] = clone;
    }
};

/**
 * @return {Array<Element>} template nodes list
 */
FormModel.prototype.getTemplateNodes = function() {
    const jrPrefix = this.getNamespacePrefix( JAVAROSA_XFORMS_NS );

    return this.evaluate( `/model/instance[1]/*//*[@${jrPrefix}:template]`, 'nodes', null, null, true );
};

/**
 * Obtains a cleaned up string of the data instance
 *
 * @return {string} XML string
 */
FormModel.prototype.getStr = function() {
    let dataStr = ( new XMLSerializer() ).serializeToString( this.xml.querySelector( 'instance > *' ) || this.xml.documentElement, 'text/xml' );
    // restore default namespaces
    dataStr = dataStr.replace( /\s(data-)(xmlns=("|')[^\s>]+("|'))/g, ' $2' );
    // remove repeat comments
    dataStr = dataStr.replace( new RegExp( `<!--${REPEAT_COMMENT_PREFIX}\\/[^>]+-->`, 'g' ), '' );
    // If not IE, strip duplicate namespace declarations. IE doesn't manage to add a namespace declaration to the root element.
    if ( navigator.userAgent.indexOf( 'Trident/' ) === -1 ) {
        dataStr = this.removeDuplicateEnketoNsDeclarations( dataStr );
    }

    return dataStr;
};

/**
 * @param {string} xmlStr - XML string
 * @return {string} XML string without duplicates
 */
FormModel.prototype.removeDuplicateEnketoNsDeclarations = function( xmlStr ) {
    let i = 0;
    const declarationExp = new RegExp( `( xmlns:${this.getNamespacePrefix( ENKETO_XFORMS_NS )}="${ENKETO_XFORMS_NS}")`, 'g' );

    return xmlStr.replace( declarationExp, match => {
        i++;
        if ( i > 1 ) {
            return '';
        } else {
            return match;
        }
    } );
};

/**
 * There is a huge historic issue (stemming from JavaRosa) that has resulted in the usage of incorrect formulae
 * on nodes inside repeat nodes.
 * Those formulae use absolute paths when relative paths should have been used. See more here:
 * http://opendatakit.github.io/odk-xform-spec/#a-big-deviation-with-xforms
 *
 * Tools such as pyxform also build forms in this incorrect manner. See https://github.com/modilabs/pyxform/issues/91
 * It will take time to correct this so makeBugCompliant() aims to mimic the incorrect
 * behaviour by injecting the 1-based [position] of repeats into the XPath expressions. The resulting expression
 * will then be evaluated in a way users expect (as if the paths were relative) without having to mess up
 * the XPath Evaluator.
 *
 * E.g. '/data/rep_a/node_a' could become '/data/rep_a[2]/node_a' if the context is inside
 * the second rep_a repeat.
 *
 * This function should be removed when we can reasonbly expect not many 'old XForms' to be in use any more.
 *
 * Already it should leave proper XPaths untouched.
 *
 * @param {string} expr - The XPath expression
 * @param {string} selector - Selector of the (context) node on which expression is evaluated
 * @param {number} index - Index of the instance node with that selector
 */
FormModel.prototype.makeBugCompliant = function( expr, selector, index ) {
    if ( this.noRepeatRefErrorExpected ) {
        return expr;
    }

    let target = this.node( selector, index ).getElement();

    // target is null for nested repeats if no repeats exist
    if ( !target ) {
        return expr;
    }

    const parents = [ target ];

    while ( target && target.parentElement && target.nodeName.toLowerCase() !== 'instance' ) {
        target = target.parentElement;
        parents.push( target );
    }

    // traverse collection in reverse
    parents.forEach( element => {
        // escape any dots in the node name
        const nodeName = element.nodeName.replace( /\./g, '\\.' );
        const siblingsAndSelf = getSiblingElementsAndSelf( element, `${nodeName}:not([template])` );

        // if the node is a repeat node that has been cloned at least once (i.e. if it has siblings with the same nodeName)
        if ( siblingsAndSelf.length > 1 ) {
            const parentSelector = getXPath( element, 'instance' );
            const parentIndex = siblingsAndSelf.indexOf( element );
            // Add position to segments that do not have an XPath predicate.
            expr = expr.replace( new RegExp( `${parentSelector}/`, 'g' ), `${parentSelector}[${parentIndex + 1}]/` );
        }
    } );

    return expr;
};

/**
 * Set namespaces for all nodes
 */
FormModel.prototype.setNamespaces = function() {
    /**
     * Passing through all nodes would be very slow with an XForms model that contains lots of nodes such as large secondary instances.
     * (The namespace XPath axis is not support in native browser XPath evaluators unfortunately).
     *
     * For now it has therefore been restricted to only look at the top-level node in the primary instance and in the secondary instances.
     * We can always expand that later.
     */
    const start = this.hasInstance ? '/model/instance' : '';
    const nodes = this.evaluate( `${start}/*`, 'nodes', null, null, true );
    const that = this;
    let prefix;

    nodes.forEach( node => {
        if ( node.hasAttributes() ) {
            Array.from( node.attributes ).forEach( attribute => {
                if ( attribute.name.indexOf( 'xmlns:' ) === 0 ) {
                    that.namespaces[ attribute.name.substring( 6 ) ] = attribute.value;
                }
            } );
        }
        // add required namespaces to resolver and document if they are missing
        [
            [ 'orx', OPENROSA_XFORMS_NS, false ],
            [ 'jr', JAVAROSA_XFORMS_NS, false ],
            [ 'enk', ENKETO_XFORMS_NS, config.repeatOrdinals === true ],
            [ 'odk', ODK_XFORMS_NS, false ]
        ].forEach( arr => {
            if ( !that.getNamespacePrefix( arr[ 1 ] ) ) {
                prefix = ( !that.namespaces[ arr[ 0 ] ] ) ? arr[ 0 ] : `__${arr[ 0 ]}`;
                // add to resolver
                that.namespaces[ prefix ] = arr[ 1 ];
                // add to document
                if ( arr[ 2 ] ) {
                    node.setAttributeNS( 'http://www.w3.org/2000/xmlns/', `xmlns:${prefix}`, arr[ 1 ] );
                }
            }
        } );
    } );
};

/**
 * @param {string} namespace - Target namespace
 * @return {string|undefined} Namespace prefix
 */
FormModel.prototype.getNamespacePrefix = function( namespace ) {
    const found = Object.entries( this.namespaces ).find( arr => arr[ 1 ] === namespace );

    return found ? found[ 0 ] : undefined;
};

/**
 * Returns a namespace resolver with single `lookupNamespaceURI` method
 *
 * @return {{lookupNamespaceURI: Function}} namespace resolver
 */
FormModel.prototype.getNsResolver = function() {
    const namespaces = ( typeof this.namespaces === 'undefined' ) ? {} : this.namespaces;

    return {
        lookupNamespaceURI( prefix ) {
            return namespaces[ prefix ] || null;
        }
    };
};


/**
 * Shift root to first instance for all absolute paths not starting with /model
 *
 * @param {string} expr - Original expression
 * @return {string} New expression
 */
FormModel.prototype.shiftRoot = function( expr ) {
    const LITERALS = /"([^"]*)(")|'([^']*)(')/g;
    if ( this.hasInstance ) {
        // Encode all string literals in order to exclude them, without creating a monsterly regex
        expr = expr.replace( LITERALS, ( m, p1, p2, p3, p4 ) => {
            const encoded = typeof p1 !== 'undefined' ? encodeURIComponent( p1 ) : encodeURIComponent( p3 );
            const quote = p2 || p4;

            return quote + encoded + quote;
        } );
        // Insert /model/instance[1]
        expr = expr.replace( /^(\/(?!model\/)[^/][^/\s,"']*\/)/g, '/model/instance[1]$1' );
        expr = expr.replace( /([^a-zA-Z0-9.\])/*_-])(\/(?!model\/)[^/][^/\s,"']*\/)/g, '$1/model/instance[1]$2' );
        // Decode string literals
        expr = expr.replace( LITERALS, ( m, p1, p2, p3, p4 ) => {
            const decoded = typeof p1 !== 'undefined' ? decodeURIComponent( p1 ) : decodeURIComponent( p3 );
            const quote = p2 || p4;

            return quote + decoded + quote;
        } );
    }

    return expr;
};

/**
 * Replace instance('id') with an absolute path
 * Doing this here instead of adding an instance() function to the XPath evaluator, means we can keep using
 * the much faster native evaluator in most cases!
 *
 * @param {string} expr - Original expression
 * @return {string} New expression
 */
FormModel.prototype.replaceInstanceFn = function( expr ) {
    let prefix;
    const that = this;

    // TODO: would be more consistent to use utils.parseFunctionFromExpression() and utils.stripQuotes
    return expr.replace( INSTANCE, ( match, quote, id ) => {
        prefix = `/model/instance[@id="${id}"]`;
        // check if referred instance exists in model
        if ( that.evaluate( prefix, 'nodes', null, null, true ).length ) {
            return prefix;
        } else {
            throw new FormLogicError( `instance "${id}" does not exist in model` );
        }
    } );
};

/**
 * Replaces current() with /absolute/path/to/node to ensure the context is shifted to the primary instance
 *
 * Doing this here instead of adding a current() function to the XPath evaluator, means we can keep using
 * the much faster native evaluator in most cases!
 *
 * Root will be shifted later, and repeat positions are already injected into context selector.
 *
 * @param {string} expr - Original expression
 * @param {string} contextSelector - Context selector
 * @return {string} New expression
 */
FormModel.prototype.replaceCurrentFn = ( expr, contextSelector ) => {
    return expr.replace( /current\(\)/g, `${contextSelector}` );
};

/**
 * Replaces indexed-repeat(node, path, position, path, position, etc) substrings by converting them
 * to their native XPath equivalents using [position() = x] predicates
 *
 * @param {string} expr - The XPath expression
 * @param {string} selector - context path
 * @param {number} index - index of context node
 * @return {string} Converted XPath expression
 */
FormModel.prototype.replaceIndexedRepeatFn = function( expr, selector, index ) {
    const that = this;
    const indexedRepeats = parseFunctionFromExpression( expr, 'indexed-repeat' );

    indexedRepeats.forEach( indexedRepeat => {
        let i, positionedPath;
        let position;
        const params = indexedRepeat[ 1 ];

        if ( params.length % 2 === 1 ) {

            positionedPath = params[ 0 ];

            for ( i = params.length - 1; i > 1; i -= 2 ) {
                // The position will become an XPath predicate. The context for an XPath predicate, is not the same
                // as the context for the complete expression, so we have to evaluate the position separately. Otherwise
                // relative paths would break.
                position = !isNaN( params[ i ] ) ? params[ i ] : that.evaluate( params[ i ], 'number', selector, index, true );
                positionedPath = positionedPath.replace( params[ i - 1 ], `${params[ i - 1 ]}[position() = ${position}]` );
            }

            expr = expr.replace( indexedRepeat[ 0 ], positionedPath );

        } else {
            throw new FormLogicError( `indexed repeat with incorrect number of parameters found: ${indexedRepeat[ 0 ]}` );
        }
    } );

    return expr;
};

/**
 * @param {string} expr - The XPath expression
 * @return {string} Converted XPath expression
 */
FormModel.prototype.replaceVersionFn = function( expr ) {
    const that = this;
    let version;
    const versions = parseFunctionFromExpression( expr, 'version' );

    versions.forEach( versionPart => {
        version = version || that.evaluate( '/*/@version', 'string', null, 0, true );
        // ignore arguments
        expr = expr.replace( versionPart[ 0 ], `"${version}"` );
    } );

    return expr;
};

/**
 * @param {string} expr - The XPath expression
 * @param {string} selector - context path
 * @param {number} index - index of context node
 * @return {string} Converted XPath expression
 */
FormModel.prototype.replacePullDataFn = function( expr, selector, index ) {
    let pullDataResult;
    const that = this;
    const replacements = this.convertPullDataFn( expr, selector, index );

    for ( const pullData in replacements ) {
        if ( Object.prototype.hasOwnProperty.call( replacements, pullData ) ) {
            // We evaluate this here, so we can use the native evaluator safely. This speeds up pulldata() by about a factor *740*!
            pullDataResult = that.evaluate( replacements[ pullData ], 'string', selector, index, true );
            expr = expr.replace( pullData, `"${pullDataResult}"` );
        }
    }

    return expr;
};

/**
 * @param {string} expr - The XPath expression
 * @param {string} selector - context path
 * @param {number} index - index of context node
 * @return {string} Converted XPath expression
 */
FormModel.prototype.convertPullDataFn = function( expr, selector, index ) {
    const that = this;
    const pullDatas = parseFunctionFromExpression( expr, 'pulldata' );
    const replacements = {};

    if ( !pullDatas.length ) {
        return replacements;
    }

    pullDatas.forEach( pullData => {
        let searchValue;
        let searchXPath;
        const params = pullData[ 1 ];

        if ( params.length === 4 ) {

            // strip quotes
            params[ 1 ] = stripQuotes( params[ 1 ] );
            params[ 2 ] = stripQuotes( params[ 2 ] );

            // TODO: the 2nd and 3rd parameter could probably also be expressions.

            // The 4th argument will become an XPath predicate. The context for an XPath predicate, is not the same
            // as the context for the complete expression, so we have to evaluate the position separately. Otherwise
            // relative paths would break.
            searchValue = `'${that.evaluate( params[ 3 ], 'string', selector, index, true )}'`;
            searchXPath = `instance(${params[ 0 ]})/root/item[${params[ 2 ]} = ${searchValue}]/${params[ 1 ]}`;

            replacements[ pullData[ 0 ] ] = searchXPath;

        } else {
            throw new FormLogicError( `pulldata with incorrect number of parameters found: ${pullData[ 0 ]}` );
        }
    } );

    return replacements;
};

/**
 * Evaluates an XPath Expression using XPathJS_javarosa (not native XPath 1.0 evaluator)
 *
 * This function does not seem to work properly for nodeset resulttypes otherwise:
 * muliple nodes can be accessed by returned node.snapshotItem(i)(.textContent)
 * a single node can be accessed by returned node(.textContent)
 *
 * @param {string} expr - The expression to evaluate
 * @param {string} [resTypeStr] - "boolean", "string", "number", "node", "nodes" (best to always supply this)
 * @param {string} [selector] - Query selector which will be use to provide the context to the evaluator
 * @param {number} [index] - 0-based index of selector in document
 * @param {boolean} [tryNative] - Whether an attempt to try the Native Evaluator is safe (ie. whether it is
 *                                certain that there are no date comparisons)
 * @return {number|string|boolean|Array<Element>} The result
 */
FormModel.prototype.evaluate = function( expr, resTypeStr, selector, index, tryNative ) {
    let j, context, doc, resTypeNum, resultTypes, result, collection, response, repeats, cacheKey, original, cacheable;

    // console.debug( 'evaluating expr: ' + expr + ' with context selector: ' + selector + ', 0-based index: ' +
    //    index + ' and result type: ' + resTypeStr );
    original = expr;
    tryNative = tryNative || false;
    resTypeStr = resTypeStr || 'any';
    index = index || 0;
    doc = this.xml;
    repeats = null;

    if ( selector ) {
        collection = this.node( selector ).getElements();
        repeats = collection.length;
        context = collection[ index ];
    } else {
        // either the first data child of the first instance or the first child (for loaded instances without a model)
        context = this.rootElement;
    }

    if ( !context ) {
        console.error( 'no context element found', selector, index );
    }

    // cache key includes the number of repeated context nodes,
    // to force a new cache item if the number of repeated changes to > 0
    // TODO: these cache keys can get quite large. Would it be beneficial to get the md5 of the key?
    cacheKey = [ expr, selector, index, repeats ].join( '|' );

    // These functions need to come before makeBugCompliant.
    // An expression transformation with indexed-repeat or pulldata cannot be cached because in
    // "indexed-repeat(node, repeat nodeset, index)" the index parameter could be an expression.
    expr = this.replaceIndexedRepeatFn( expr, selector, index );
    expr = this.replacePullDataFn( expr, selector, index );
    cacheable = ( original === expr );

    // if no cached conversion exists
    if ( !this.convertedExpressions[ cacheKey ] ) {
        expr = expr.trim();
        expr = this.replaceInstanceFn( expr );
        expr = this.replaceVersionFn( expr );
        expr = this.replaceCurrentFn( expr, getXPath( context, 'instance', true ) );
        // shiftRoot should come after replaceCurrentFn
        expr = this.shiftRoot( expr );
        // path corrections for repeated nodes: http://opendatakit.github.io/odk-xform-spec/#a-big-deviation-with-xforms
        if ( repeats && repeats > 1 ) {
            expr = this.makeBugCompliant( expr, selector, index );
        }
        // decode
        expr = expr.replace( /&lt;/g, '<' );
        expr = expr.replace( /&gt;/g, '>' );
        expr = expr.replace( /&quot;/g, '"' );
        if ( cacheable ) {
            this.convertedExpressions[ cacheKey ] = expr;
        }
    } else {
        expr = this.convertedExpressions[ cacheKey ];
    }

    resultTypes = {
        0: [ 'any', 'ANY_TYPE' ],
        1: [ 'number', 'NUMBER_TYPE', 'numberValue' ],
        2: [ 'string', 'STRING_TYPE', 'stringValue' ],
        3: [ 'boolean', 'BOOLEAN_TYPE', 'booleanValue' ],
        7: [ 'nodes', 'ORDERED_NODE_SNAPSHOT_TYPE' ],
        9: [ 'node', 'FIRST_ORDERED_NODE_TYPE', 'singleNodeValue' ]
    };

    // translate typeStr to number according to DOM level 3 XPath constants
    for ( resTypeNum in resultTypes ) {
        if ( Object.prototype.hasOwnProperty.call( resultTypes, resTypeNum ) ) {
            resTypeNum = Number( resTypeNum );
            if ( resultTypes[ resTypeNum ][ 0 ] === resTypeStr ) {
                break;
            } else {
                resTypeNum = 0;
            }
        }
    }

    // try native to see if that works... (will not work if the expr contains custom OpenRosa functions)
    if ( tryNative && typeof doc.evaluate !== 'undefined' && !OPENROSA.test( expr ) ) {
        try {
            // console.log( 'trying the blazing fast native XPath Evaluator for', expr, index );
            result = doc.evaluate( expr, context, this.getNsResolver(), resTypeNum, null );
        } catch ( e ) {
            //console.log( '%cWell native XPath evaluation did not work... No worries, worth a shot, the expression probably ' +
            //    'contained unknown OpenRosa functions or errors:', expr );
        }
    }

    // if that didn't work, try the slow XPathJS evaluator
    if ( !result ) {
        try {
            if ( typeof doc.jsEvaluate === 'undefined' ) {
                this.bindJsEvaluator();
            }
            // console.log( 'trying the slow enketo-xpathjs "openrosa" evaluator for', expr, index );
            result = doc.jsEvaluate( expr, context, this.getNsResolver(), resTypeNum, null );
        } catch ( e ) {
            throw new FormLogicError( `Could not evaluate: ${expr}, message: ${e.message}` );
        }
    }

    // get desired value from the result object
    if ( result ) {
        // for type = any, see if a valid string, number or boolean is returned
        if ( resTypeNum === 0 ) {
            for ( resTypeNum in resultTypes ) {
                if ( Object.prototype.hasOwnProperty.call( resultTypes, resTypeNum ) ) {
                    resTypeNum = Number( resTypeNum );
                    if ( resTypeNum === Number( result.resultType ) && resTypeNum > 0 && resTypeNum < 4 ) {
                        response = result[ resultTypes[ resTypeNum ][ 2 ] ];
                        break;
                    }
                }
            }
            if ( !response ) {
                console.error( `Expression: ${expr} did not return any boolean, string or number value as expected` );
            }
        } else if ( resTypeNum === 7 ) {
            // response is an array of Elements
            response = [];
            for ( j = 0; j < result.snapshotLength; j++ ) {
                response.push( result.snapshotItem( j ) );
            }
        } else {
            response = result[ resultTypes[ resTypeNum ][ 2 ] ];
        }

        return response;
    }
};


/**
 * Placeholder function meant to be overwritten
 */
FormModel.prototype.getUpdateEventData = () => /*node, type*/ {};

/**
 * Placeholder function meant to be overwritten
 */
FormModel.prototype.getRemovalEventData = () => /* node */ {};

/**
 * Exposed {@link module:types|types} to facilitate extending with custom types
 *
 * @type {object}
 */
FormModel.prototype.types = types;

export { FormModel };