global.IntersectionObserver = function(){};
const utils = require( '../build/utils-cjs-bundle' );
const { JSDOM } = require( 'jsdom' );
const { getBrowser } = require( './headless-browser' );
const libxslt = require( 'libxslt' );
const libxmljs = libxslt.libxmljs;
const path = require( 'path' );
const sheets = require( 'enketo-transformer' ).sheets;
const xslModelSheet = libxslt.parse( sheets.xslModel );
const appearanceRules = require( './appearances' );
/**
* @typedef Result
* @property {Array<string>} warnings - List of warnings.
* @property {Array<string>} errors - List of errors.
*/
/**
* @class XForm
*/
class XForm {
/**
* @constructs
*
* @param {string} xformStr - XForm content.
* @param {module:validator~ValidateResult} [options] - Validation options.
*/
constructor( xformStr, options = {} ) {
this.options = options;
if ( !xformStr || !xformStr.trim() ) {
throw 'Empty form.';
}
this.xformStr = xformStr;
const dom = this._getDom();
this.doc = dom.window.document;
this.loadBrowserPage = getBrowser( )
.then( browser =>{
this.browser = browser;
return browser.newPage();
} )
.then( page => {
return page.addScriptTag( { path: path.join( __dirname, '../build/FormModel-bundle.js' ) } )
.then( () => page );
} );
}
/*
init(){
return this.loadBrowserPage
.then( page => page.evaluateHandle( ( ) => new DOMParser() ) )
.then( parser => {
console.log( parser, parser );
console.log( 'doc', parser.parseFromString( this.xformStr ) );
} );
.then( page => page.evaluateHandle( xformStr => {
const doc = new DOMParser().parseFromString( xformStr, 'text/xml' );
console.log( 'doc', xformStr, doc );
return doc;//new XMLSerializer().serializeToString( doc );
}, this.xformStr ) )
.then( result => result.$( '*' ) )
.then( value => {
console.log( 'DOMParser parsing result',value );
console.log( 'DOMParser parsing result',value.querySelector( '*' ) );
//this.doc = result.asElement();
} );
// TODO: check DOMParser result for XML parse errors and throw those using cleanXMLDOMParserError
}*/
/**
* @type {Array<Node>}
*/
get instances() {
this._instances = this._instances || [ ...this.doc.querySelectorAll( 'model > instance' ) ];
return this._instances;
}
/**
* @type {Array<Node>}
*/
get binds() {
this._binds = this._binds || [ ...this.doc.querySelectorAll( 'bind' ) ];
return this._binds;
}
/**
* @type {Array<Node>}
*/
get bindsWithCalc() {
this._bindsWithCalc = this._bindsWithCalc || [ ...this.doc.querySelectorAll( 'bind[calculate]' ) ];
return this._bindsWithCalc;
}
/**
* @type {Array<Node>}
*/
get formControls() {
// doc.evaluate does not support namespaces at all (nsResolver is not used) in JSDom, hence this clever not() trick
// to use querySelectorAll instead.
this._formControls = this._formControls || [ ...this.doc.querySelectorAll( '*|body *:not(item):not(label):not(hint):not(value):not(itemset):not(output):not(repeat):not(group):not(setvalue)' ) ];
return this._formControls;
}
/**
* @type {Array<Node>}
*/
get groups() {
// doc.evaluate does not support namespaces at all (nsResolver is not used) in JSDom
this._groups = this._groups || [ ...this.doc.querySelectorAll( '*|body group' ) ];
return this._groups;
}
/**
* @type {Array<Node>}
*/
get repeats() {
// doc.evaluate does not support namespaces at all (nsResolver is not used) in JSDom
this._repeats = this._repeats || [ ...this.doc.querySelectorAll( '*|body repeat' ) ];
return this._repeats;
}
/**
* @type {Array<Node>}
*/
get setvalues() {
this._setvalues = this._setvalues || [ ...this.doc.querySelectorAll( 'setvalue', ) ];
return this._setvalues;
}
/**
* @type {Array<Node>}
*/
get items() {
// doc.evaluate does not support namespaces at all (nsResolver is not used) in JSDom
this._items = this._items || [ ...this.doc.querySelectorAll( '*|body item, *|body itemset' ) ];
return this._items;
}
/**
* Object of known namespaces uses in ODK XForms, with prefixes as used in this validator.
*
* @type {object}
*/
get NAMESPACES() {
return {
'': 'http://www.w3.org/2002/xforms',
h: 'http://www.w3.org/1999/xhtml',
oc: 'http://openclinica.org/xforms',
odk: 'http://www.opendatakit.org/xforms',
enk: 'http://enketo.org/xforms',
orx: 'http://openrosa.org/xforms',
xsd: 'http://www.w3.org/2001/XMLSchema',
};
}
exit(){
return this.loadBrowserPage
.then( page => page.close() );
}
/**
* Returns a `<bind>` element with the provided nodeset attribute value.
*
* @param {string} nodeset - nodeset attribute value
* @return {Node} bind element matching the nodeset value
*/
getBind( nodeset ) {
return this.doc.querySelector( `bind[nodeset="${nodeset}"]` );
}
/**
* Returns a `<setvalue>` element with the provided ref attribute value.
*
* @param {string} ref - ref attribute value
* @return {Node} setvalue element matching the nodeset value
*/
getSetvalue( ref ) {
return this.doc.querySelector( `setvalue[ref="${ref}"]` );
}
/**
* Returns namespace prefix for given namespace.
*
* @param {string} ns - One of predefined {@link XForm#NAMESPACES|NAMESPACES}.
* @return {string} namespace prefix.
*/
nsPrefixResolver( ns ) {
let prefix = null;
if ( !ns ) {
return prefix;
}
Object.entries( this.NAMESPACES ).some( obj => {
if ( obj[ 1 ] === ns ) {
prefix = obj[ 0 ];
return true;
}
} );
return prefix;
}
/**
* Parses the Model
*
* The reason this is not included in the constructor is to separate different types of errors,
* and keep the constructor just for XML parse errors.
*/
parseModel() {
// Be careful here, the pkg module to create binaries is surprisingly sophisticated, but the paths cannot be dynamic.
//const scriptContent = fs.readFileSync( path.join( __dirname, '../build/FormModel-bundle.js' ), { encoding: 'utf-8' } );
// This window is not to be confused with this.dom.window which contains the XForm.
//const window = this._getWindow( scriptContent );
// Disable the jsdom evaluator
// window.document.evaluate = undefined;
let page;
return this.loadBrowserPage
.then( p => {
page = p;
// Get a serialized model with namespaces in locations that Enketo can deal with.
const modelStr = this._extractModelStr().root().get( '*' ).toString( false );
const externalArr = this._getExternalDataArray();
// DEBUG
/*
page.on( 'console', msg => {
for ( let i = 0; i < msg.args().length; ++i )
console.log( `${i}: ${msg.args()[i]}` );
} );
*/
return page.evaluateHandle( ( modelStr, externalArr, ocExtensions ) => {
const parser = new DOMParser();
const external = externalArr.map( instance => {
instance.xml = parser.parseFromString( '<something/>', 'text/xml' );
return instance;
} );
// Instantiate an Enketo Core Form Model
const model = new window.FormModel( { modelStr, external } );
// Add custom XPath functions
if ( ocExtensions ) {
model.bindJsEvaluator = () => {
model.xml.jsEvaluate = window.ocXPathEvaluator.evaluate;
};
}
return model;
}, modelStr, externalArr, !!this.options.openclinica );
} )
.then( modelHandle => {
this.modelHandle = modelHandle;
return page.evaluateHandle( model => model.init(), modelHandle );
} )
.then( loadErrorsHandle => loadErrorsHandle.jsonValue() )
.then( loadErrors => {
if ( loadErrors.length ) {
throw loadErrors;
}
return page;
} )
.catch( e => {
throw e;
} );
}
/**
* Evaluates an XPath expression on the XForm's primary instance.
*
* @param {string} expr - The expression to evaluate.
* @param {string} [type] - One of boolean, string, number, node, nodes.
* @param {string} [contextPath] - Query selector.
* @param {boolean} tryNative - Whether it is safe to try the native evaluator (no date comparisons or calculations)
* @return {Array<Element>} an array of elements.
*/
enketoEvaluate( expr, type = 'string', contextPath = null, tryNative = false ) {
const newExpr = this._stripJrChoiceName( expr );
const getPage = this.modelHandle ? this.loadBrowserPage : this.parseModel();
return getPage
.then( page => page.evaluateHandle( ( model, newExpr, type, contextPath, tryNative ) => model.evaluate( newExpr, type, contextPath, null, tryNative ), this.modelHandle, newExpr, type, contextPath, tryNative ) )
.then ( resultHandle => resultHandle.jsonValue() )
.catch( e => {
throw this._cleanXPathException( e );
} );
}
/**
* Obtains a node from the model from its simple path.
*
* @param {string} path - simple path to node
* @return {Element|null} the result element or null if not found
*/
nodeExists( path ){
return this.enketoEvaluate( path, 'node', null, true )
.then( element => {
return !!element;
} );
}
/**
* Checks if the structure is valid.
*
* @return {Result} Result object with warnings and errors.
*/
checkStructure() {
const errors = [];
const warnings = [];
const rootEl = this.doc.documentElement;
const rootElNodeName = rootEl.nodeName;
if ( !( /^[A-z]+:html$/.test( rootElNodeName ) ) ) {
errors.push( 'Root element should be <html>.' );
}
if ( rootEl.namespaceURI !== this.NAMESPACES.h ) {
errors.push( 'Root element has incorrect namespace.' );
}
let headEl;
let bodyEl;
for ( let el of rootEl.children ) {
if ( /^[A-z]+:head$/.test( el.nodeName ) ) {
headEl = el;
} else if ( /^[A-z]+:body$/.test( el.nodeName ) ) {
bodyEl = el;
}
}
if ( !headEl ) {
errors.push( 'No head element found as child of <html>.' );
}
if ( headEl && headEl.namespaceURI !== this.NAMESPACES.h ) {
errors.push( 'Head element has incorrect namespace.' );
}
if ( !bodyEl ) {
errors.push( 'No body element found as child of <html>.' );
}
if ( bodyEl && bodyEl.namespaceURI !== this.NAMESPACES.h ) {
errors.push( 'Body element has incorrect namespace.' );
}
// These are the elements we expect to have a label though we're going slightly beyond spec requirement here.
this.formControls.forEach( control => {
// The selector ":scope > label" fails with namespaced elements such as odk:rank
if ( ![ ...control.childNodes ].some( el => el.nodeName === 'label' ) ) {
const nodeName = this._nodeName( control,'ref' ) || '?';
errors.push( `Question "${nodeName}" has no label.` );
}
} );
this.items.forEach( item => {
if ( ![ ...item.childNodes ].some( el => el.nodeName === 'label' ) ){
const nodeName = this._nodeName( item.parentElement, 'ref' ) || '?';
errors.push( `Select option for question "${nodeName}" has no label.` );
}
if ( ![ ...item.childNodes ].some( el => el.nodeName === 'value' ) ){
const nodeName = this._nodeName( item.parentElement, 'ref' ) || '?';
errors.push( `Select option for question "${nodeName}" has no value.` );
}
} );
let modelEl;
if ( headEl ) {
for ( let el of headEl.children ) {
if ( /^([A-z]+:)?model$/.test( el.nodeName ) ) {
modelEl = el;
break;
}
}
if ( !modelEl ) {
errors.push( 'No model element found as child of <head>.' );
}
if ( modelEl && modelEl.namespaceURI !== this.NAMESPACES[ '' ] ) {
errors.push( 'Model element has incorrect namespace.' );
}
}
let primInstanceEl;
if ( modelEl ) {
for ( let el of modelEl.children ) {
if ( /^([A-z]+:)?instance$/.test( el.nodeName ) ) {
primInstanceEl = el;
break;
}
}
if ( !primInstanceEl ) {
errors.push( 'No primary instance element found as first instance child of <model>.' );
}
if ( primInstanceEl && primInstanceEl.namespaceURI !== this.NAMESPACES[ '' ] ) {
errors.push( 'Primary instance element has incorrect namespace.' );
}
}
if ( primInstanceEl ) {
const children = primInstanceEl.children;
if ( children.length === 0 ) {
errors.push( 'Primary instance element has no child.' );
} else if ( children.length > 1 ) {
errors.push( 'Primary instance element has more than 1 child.' );
}
if ( children && !children[ 0 ].id ) {
errors.push( `Data root node <${children[0].nodeName}> has no id attribute.` );
}
if ( children && children[ 0 ] ) {
const dataNodeNames = [];
const dataNodes = children[ 0 ].querySelectorAll( '*' );
dataNodes.forEach( el => {
const nodeName = el.nodeName;
const index = dataNodeNames.indexOf( nodeName );
// Save XPath determination for when necessary, to not negatively affect performance.
if ( index !== -1 && utils.getXPath( dataNodes[ index ], 'instance' ) !== utils.getXPath( el, 'instance' ) ) {
warnings.push( `Duplicate question or group name "${nodeName}" found. Unique names are recommended` );
}
dataNodeNames.push( nodeName );
} );
}
}
if ( this.repeats.length ) {
const repeatPaths = [];
this.repeats.reverse().forEach( repeat => {
const nodeset = repeat.getAttribute( 'nodeset' );
// This check will fail if relative nodesets are used (not supported in Enketo any more).
if ( repeatPaths.some( repeatPath => repeatPath.startsWith( nodeset + '/' ) ) ) {
warnings.push( `Repeat "${this._nodeName( nodeset )}" contains a nested repeat. This not recommended.` );
}
repeatPaths.push( nodeset );
} );
}
if ( this.groups.length ){
this.groups.forEach( group => {
const ref = group.getAttribute( 'ref' );
if ( group.getAttribute( 'intent' ) ){
errors.push( `Group "${this._nodeName( ref )}" has an unsupported "intent" attribute to launch an external app.` );
}
} );
}
// ODK Build bug
if ( bodyEl && bodyEl.querySelector( 'group:not([ref])' ) ) {
warnings.push( 'Found <group> without ref attribute. This might be fine as long as the group has no relevant logic.' );
}
// ODK Build output
if ( bodyEl && bodyEl.querySelector( 'group:not([ref]) > repeat' ) ) {
warnings.push( 'Found <repeat> that has a parent <group> without a ref attribute. ' +
'If the repeat has relevant logic, this will make the form very slow.' );
}
return { warnings, errors };
}
/**
* Checks if binds are valid.
*
* @return {Result} Result object with warnings and errors.
*/
checkBinds() {
const warnings = [];
const errors = [];
// Check for use of form controls with calculations that are not readonly
this.bindsWithCalc
.filter( this._withFormControl.bind( this ) )
.filter( bind => {
const readonly = bind.getAttribute( 'readonly' );
// TODO: the check for true() should be probably be done in XPath,
// using XPath boolean conversion rules.
return !readonly || readonly.trim() !== 'true()';
} )
.map( bind => this._nodeName( bind ) )
.forEach( nodeName => errors.push( `Question "${nodeName}" has a calculation that is not set to readonly.` ) );
return { warnings, errors };
}
/**
* Checks if appearances are valid.
*
* @return {Result} Result object with warnings and errors.
*/
checkAppearances() {
const warnings = [];
const errors = [];
this.formControls.concat( this.groups ).concat( this.repeats )
.forEach( control => {
let appearanceVal = control.getAttribute( 'appearance' );
if ( !appearanceVal || !appearanceVal.trim() ) {
return;
}
const controlNsPrefix = this.nsPrefixResolver( control.namespaceURI );
const controlName = controlNsPrefix && /:/.test( control.nodeName ) ? controlNsPrefix + ':' + control.nodeName.split( ':' )[ 1 ] : control.nodeName;
const pathAttr = controlName === 'repeat' ? 'nodeset' : 'ref';
const ref = control.getAttribute( pathAttr );
const friendlyControlName = controlName === 'repeat' || controlName === 'group' ? controlName : 'question';
if ( !ref ) {
errors.push( `A ${friendlyControlName} found in body that has no ${pathAttr} attribute (${control.nodeName}).` );
return;
}
const nodeName = this._nodeName( ref ); // in model!
const bindEl = this.getBind( ref );
let dataType = bindEl ? bindEl.getAttribute( 'type' ) : 'string';
// Convert ns prefix to properly evaluate XML Schema datatypes regardless of namespace prefix used in XForm.
const typeValNs = /:/.test( dataType ) ? bindEl.lookupNamespaceURI( dataType.split( ':' )[ 0 ] ) : null;
dataType = typeValNs ? `${this.nsPrefixResolver( typeValNs )}:${dataType.split( ':' )[1]}` : dataType;
// Special error for use of ex;
if ( appearanceVal.trim().startsWith( 'ex:' ) ){
errors.push( `Appearance "ex:" to launch an external app for ${friendlyControlName} "${nodeName}" is not supported.` );
return;
}
// Special search() error to avoid splitting space-separated parameters causing many unhelpful errors
const searchMatches = appearanceVal.match( /search\(.+\)/ );
if ( searchMatches ){
appearanceVal = appearanceVal.replace( searchMatches[0], '' );
errors.push( `Appearance "search" for ${friendlyControlName} "${nodeName}" is not supported.` );
}
const appearances = appearanceVal.trim() ? appearanceVal.split( ' ' ) : [];
appearances.forEach( appearance => {
let rules = appearanceRules[ appearance ] || [];
if ( typeof rules === 'string' ) {
rules = appearanceRules[ rules ];
}
if ( typeof rules === 'object' && !Array.isArray( rules ) ){
rules = [ rules ];
}
if ( !Array.isArray( rules ) ){
console.error( 'Appearance rules not in expected format.' );
}
if ( rules.length === 0 ) {
warnings.push( `Appearance "${appearance}" for ${friendlyControlName} "${nodeName}" is not supported.` );
return;
}
const allowedControls = rules.map( rule => rule.controls || [] ).flat();
if ( allowedControls.length && !allowedControls.includes( controlName ) ) {
warnings.push( `Appearance "${appearance}" for "${nodeName}" is not valid for type ${control.nodeName}.` );
return;
}
const allowedTypes = rules.map( rule => rule.types || [] ).flat();
if ( allowedTypes.length && !allowedTypes.includes( dataType ) ) {
// Only check types if controls check passed.
// TODO check namespaced types when it becomes applicable (for XML Schema types).
warnings.push( `Appearance "${appearance}" for ${friendlyControlName} "${nodeName}" is not valid for this data type (${dataType}).` );
return;
}
// Find rule that allows this appearance.
// For now it is safe to just take the first matching control if one exist and otherwise the first matching type.
const applicableRule = rules.find( rule => ( rule.controls || [] ).includes( controlName ) )
|| rules.find( rule => ( rule.types || [] ).includes( dataType ) )
|| rules[0];
if ( applicableRule && applicableRule.appearances && !applicableRule.appearances.some( appearanceMatch => appearances.includes( appearanceMatch ) ) ) {
warnings.push( `Appearance "${appearance}" for ${friendlyControlName} "${nodeName}" requires any of these appearances: "${this._join( applicableRule.appearances )}".` );
return;
}
if ( applicableRule && applicableRule.appearancesConflict && applicableRule.appearancesConflict.some( appearanceMatch => appearances.includes( appearanceMatch ) ) ) {
warnings.push( `Appearance "${appearance}" for ${friendlyControlName} "${nodeName}" cannot be used in combination with any of these appearances: "${this._join( applicableRule.appearancesConflict )}".` );
return;
}
if ( applicableRule && applicableRule.preferred ) {
warnings.push( `Appearance "${appearance}" for ${friendlyControlName} "${nodeName}" is deprecated, use "${applicableRule.preferred}" instead.` );
}
// Possibilities for future additions:
// - check accept/mediaType
// - check conflicting combinations of appearances
} );
} );
return { warnings, errors };
}
/**
* Checks special OpenClinica rules.
*
* @return {Result} Result object with warnings and errors.
*/
checkOpenClinicaRules() {
const warnings = [];
const errors = [];
const CLINICALDATA_REF = /instance\(\s*(["'])((?:(?!\1)clinicaldata))\1\s*\)/;
// Check for use of external data in instance "clinicaldata"
this.binds
.filter( this._withoutFormControl.bind( this ) )
.filter( bind => {
const path = bind.getAttribute( 'nodeset' );
const setvalue = this.getSetvalue( path );
const calculation = bind.getAttribute( 'calculate' );
const value = setvalue && setvalue.getAttribute( 'value' );
return ( CLINICALDATA_REF.test( calculation ) || CLINICALDATA_REF.test( value ) ) &&
bind.getAttributeNS( this.NAMESPACES.oc, 'external' ) !== 'clinicaldata';
} )
.map( bind => this._nodeName( bind ) )
.forEach( nodeName => errors.push( `Found calculation for question "${nodeName}" that refers to ` +
'external clinicaldata without the required "external" attribute in the correct namespace.' ) );
this.binds
.filter( bind => bind.getAttributeNS( this.NAMESPACES.oc, 'external' ) === 'clinicaldata' )
.filter( bind => {
const path = bind.getAttribute( 'nodeset' );
const setvalue = this.getSetvalue( path );
const calculation = bind.getAttribute( 'calculate' );
const value = setvalue && setvalue.getAttribute( 'value' );
return ( !calculation && !value ) ||
( calculation && !CLINICALDATA_REF.test( calculation ) ) ||
( value && !CLINICALDATA_REF.test( value ) ) ;
} )
.map( bind => this._nodeName( bind ) )
.forEach( nodeName => errors.push( `Found bind with external attribute with "clinicaldata" value for question "${nodeName}" that does not ` +
'have a calculation referring to instance("clinicaldata").' ) );
const externalSignatureQuestions = this.binds
.filter( bind => bind.getAttributeNS( this.NAMESPACES.oc, 'external' ) === 'signature' );
if ( externalSignatureQuestions.length > 1 ){
errors.push( 'Consent forms can only include one signature item.' );
}
externalSignatureQuestions
.forEach( bind => {
const path = bind.getAttribute( 'nodeset' );
const select = this.doc.querySelector( `select[ref="${path}"]` );
const appearanceVal = select ? select.getAttribute( 'appearance' ) : '';
const options = select ? select.querySelectorAll( 'item' ) : [];
const valueEl = options[0] ? options[0].querySelector( 'value' ) : null;
if( !select || options.length !== 1 ||
( appearanceVal && appearanceVal.trim().split( ' ' ).includes( 'minimal' ) ) ){
errors.push( 'Signature items must be of type "select_multiple" with one option.' );
} else if ( valueEl && valueEl.textContent !== '1' ){
errors.push( 'Signature items must have choice name set to "1"' );
}
} );
this.binds
.forEach( bind => {
const question = this._nodeName( bind );
const missingAttributes = [];
for ( const prop in bind.attributes ){
const attribute = bind.attributes[prop];
if ( attribute.namespaceURI === this.NAMESPACES.oc && attribute.localName !== 'constraint-type' && attribute.localName.startsWith( 'constraint' ) ){
const constraintName = attribute.localName;
const match = constraintName.match( /^constraint(.*)$/ );
const msg = constraintName.endsWith( 'Msg' ) ? 'Msg' : '';
const id = msg ? match[1].substring( 0, match[1].length - 3 ) : match[1];
if ( !utils.isNumber( id ) || id < 1 || id > 20 ){
errors.push( `Found unsupported oc:constraint${id}${msg} for question "${question}". Only numbers 1 to 20 are supported.` );
} else {
// Only check valid attributes for matching Msg attributes and vice versa
const matchingAttribute = `constraint${id}${msg ? '' : 'Msg'}`;
const foundIndex = missingAttributes.findIndex( arr => arr[0] === matchingAttribute );
if ( foundIndex === -1 ){
// presume missing until found
missingAttributes.push( [ matchingAttribute, constraintName ] );
} else {
missingAttributes.splice( foundIndex, 1 );
}
}
}
}
missingAttributes.forEach( arr => errors.push( `Missing matching oc:${arr[0]} for oc:${arr[1]} for question "${question}".` ) );
} );
// check for use of last-saved feature
this.instances
.forEach( instance => {
const src = instance.getAttribute( 'src' );
if ( /\s?jr:\/\/instance\/last-saved/.test( src ) ){
errors.push( 'The form includes the use of the "last-saved" feature. This feature is not supported.' );
}
} );
return { warnings, errors };
}
/**
* Returns some dummy external data that can be used to instantiate a Form instance that requires external data.
*
* @return {Array<{id: string}>} external data object with dummy content
*/
_getExternalDataArray() {
return [ ...this.doc.querySelectorAll( 'instance[id][src]' ) ].map( instance => ( { id:instance.id } ) );
}
/**
* Strips jr:choice-name function.
*
* Since this is such a weird function that queries the body of the XForm,
* and cannot be evaluated in XPath, we just strip it out.
*
* @param {string} expr - The initial expression.
* @return {string} expression after stripping.
*/
_stripJrChoiceName( expr ) {
utils.parseFunctionFromExpression( expr, 'jr:choice-name' ).forEach( choiceFn => {
expr = expr.replace( choiceFn[ 0 ], '"a"' );
} );
return expr;
}
/**
* Inefficient method that ensures that the namespaces are included in their expected locations,
* so Enketo Core knows how to handle them.
*
* @return {string|Document} The XML content to apply the stylesheet to given as a string or a libxmljs document.
*/
_extractModelStr() {
// First remove all jr:template="" attributes, because older forms won't have an additional first repeat instance.
// https://github.com/enketo/enketo-validate/issues/73
// This is of course a very bad way of doing this relying on a jr prefix, but likely no problem for anyone.
this.xformStr = this.xformStr.replace( /jr:template=""/g, '' );
let doc = libxmljs.parseXml( this.xformStr );
return xslModelSheet.apply( doc );
}
/**
* Returns a JSDOM instance of the XForm.
*
* @return {JSDOM} JSDOM instance of the XForm
*/
_getDom() {
try {
return new JSDOM( this.xformStr, {
contentType: 'text/xml'
} );
} catch ( e ) {
throw this._cleanXmlDomParserError( e );
}
}
/**
* Determines whether a `<bind>` element has corresponding input form control.
*
* @param {Element} bind - The XForm <bind> element.
* @return {boolean} whether the provided bind has a matching form control
*/
_withFormControl( bind ) {
const nodeset = bind.getAttribute( 'nodeset' );
// We are not checking for <group> and <repeat>,
// as the purpose of this function is to identify calculations without form control
return !!this.doc.querySelector( `input[ref="${nodeset}"], select[ref="${nodeset}"], ` +
`select1[ref="${nodeset}"], trigger[ref="${nodeset}"]` );
}
/**
* A reverse method of {@link XForm#_withFormControl|_withFormControl}
*
* @param {Element} bind - The XForm <bind> element.
* @return {boolean} whether the provided bind has no matching form control
*/
_withoutFormControl( bind ) {
return !this._withFormControl( bind );
}
/**
* Returns the model node name that a provided element refers to.
*
* @param {Element|string} thing - The XForm element or path.
* @param {string} attribute - The attribute that contains the path.
* @return {string|null} the node name.
*/
_nodeName( thing, attribute = 'nodeset' ) {
let path;
if ( typeof thing === 'string' ){
path = thing;
} else {
path = thing.getAttribute( attribute );
}
return path ? path.substring( path.lastIndexOf( '/' ) + 1 ) : null;
}
/**
* Returns a cleaned-up XmlDomParser error string unless in debug mode.
*
* @param {Error} error - Error object
* @return {Error|string} cleaned up error message or original error object
*/
_cleanXmlDomParserError( error ) {
if ( this.options.debug ) {
return error;
}
let parts = error.message.split( '\n' );
return parts[ 0 ] + ' ' + parts.splice( 1, 4 ).join( ', ' );
}
/**
* Returns cleaned-up XPath Exception error string unless in debug mode.
*
* @param {Error} error - Error object
* @return {Error|string} cleaned up error message or original error object
*/
_cleanXPathException( error ) {
if ( this.options.debug ) {
return error;
}
let parts = [ error.message.split( '\n' )[ 0 ], error.name, error.code ]
.filter( part => !!part );
parts[ 0 ] = parts[ 0 ]
.replace( /Function "{}(.*)"/g, 'Function "$1"' )
.replace( /\/model\/instance\[1\]/g, '' )
.replace( /\(line: undefined, character: undefined\)/g, '' );
// '. ,' => ','
return parts.join( ', ' ).replace( /\.\s*,/g, ',' );
}
/**
* Joins an array of strings into a readable string.
*
* @param {Array<string>} arr - array of strings
*/
_join( arr ) {
const words = Array.from( arr );
const last = words.length > 1 ? `, and ${words.pop()}` : '';
return `${words.join( ', ' )}${last}`;
}
}
module.exports = {
XForm: XForm
};