enketo-core
Version:
Extensible Enketo form engine
1,434 lines (1,278 loc) • 54.7 kB
JavaScript
import MergeXML from 'mergexml/mergexml';
import config from 'enketo/config';
import bindJsEvaluator from 'enketo/xpath-evaluator-binding';
import { findMarkerComment } from './dom';
import { readCookie, parseFunctionFromExpression, stripQuotes } from './utils';
import {
getSiblingElementsAndSelf,
getXPath,
getRepeatIndex,
hasPreviousCommentSiblingWithContent,
hasPreviousSiblingElementSameName,
} from './dom-utils';
import FormLogicError from './form-logic-error';
import types from './types';
import event from './event';
import { Nodeset } from './nodeset';
import './extend';
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';
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 = new Map();
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) => {
if (instance == null || !instance.xml) {
return;
}
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) {
// 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) => {
const src = instance.getAttribute('src');
const errorMessage =
src == null
? `External instance "${instance.id}" is empty.`
: `Can't find ${src.replace(/.*\//, '')}.`;
that.loadErrors.push(errorMessage);
});
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);
};
/**
* Finds 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;
}
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);
};
/**
* Merges an XML instance string into the XML Model
*
* @param {string} recordStr - The XML record as string
*/
FormModel.prototype.mergeXml = function (recordStr) {
const that = this;
if (!recordStr) {
return;
}
const modelInstanceEl = this.xml.querySelector('instance');
let 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, '');
const 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.
*/
const 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) {
if (instanceNode.children.length === 0) {
// Select all text nodes (excluding repeat COMMENT nodes!)
that.evaluate(
'./text()',
'nodes-ordered',
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-ordered',
path,
0,
true
).forEach((node) => {
node.textContent = '';
});
}
}
});
const merger = new MergeXML({
join: false,
});
const 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);
}
const mergeResultDoc = merger.dom;
/**
* 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);
// adopt the merged instance childnode
modelInstanceChildEl = this.xml.adoptNode(mergeResultDoc.documentElement);
// 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 = '';
const namespace = instanceIdEl.namespaceURI;
// add deprecatedID node if necessary
if (!deprecatedIdEl) {
const nsPrefix = namespace
? this.getNamespacePrefix(namespace)
: '';
const nsDeclaration = namespace
? `xmlns:${nsPrefix}="${namespace}"`
: '';
deprecatedIdEl = parser.parseFromString(
`<${
nsPrefix ? `${nsPrefix}:` : ''
}deprecatedID ${nsDeclaration}/>`,
'text/xml'
).documentElement;
this.xml.adoptNode(deprecatedIdEl);
metaEl = this.xml.querySelector('instance > * > 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 {Node} node
*/
FormModel.prototype.getRepeatCommentNode = function (
repeatPath,
repeatSeriesIndex
) {
return findMarkerComment(
this.xml.documentElement,
this.getRepeatCommentText(repeatPath),
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.getRepeatCommentNode(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
) {
if (
config.repeatOrdinals === true &&
!repeat.getAttributeNS(ENKETO_XFORMS_NS, 'ordinal')
) {
let lastUsedOrdinal;
let newOrdinal;
const enkNs = this.getNamespacePrefix(ENKETO_XFORMS_NS);
firstRepeatInSeries = firstRepeatInSeries || repeat;
// 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.getRepeatCommentNode(
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;
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);
}
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-ordered', 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-ordered',
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 '';
}
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-ordered',
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-ordered', null, null, true).length) {
return prefix;
}
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) =>
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;
let 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;
let context;
let doc;
let resTypeNum;
let resultTypes;
let result;
let collection;
let response;
let repeats;
let cacheKey;
let original;
let 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
const cachedExpr = this.convertedExpressions.get(cacheKey);
if (cachedExpr === undefined) {
expr = expr.trim();
expr = this.replaceInstanceFn(expr);
expr = this.replaceVersionFn(expr);
expr = this.replaceCurrentFn(expr, getXPath(context, 'instance', true));
// shiftRoot should come after replac