UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

406 lines (374 loc) 13.6 kB
/** * XML types * * @module types */ import 'openrosa-xpath-evaluator/src/date-extensions'; import { isNumber } from './utils'; import { time } from './format'; /** * @namespace types */ const types = { /** * @namespace */ 'string': { /** * @param {string} x - value * @return {string} converted value */ convert( x ) { return x.replace( /^\s+$/, '' ); }, //max length of type string is 255 chars.Convert( truncate ) silently ? /** * @return {boolean} always `true` */ validate() { return true; } }, /** * @namespace */ 'select': { /** * @return {boolean} always `true` */ validate() { return true; } }, /** * @namespace */ 'select1': { /** * @return {boolean} always `true` */ validate() { return true; } }, /** * @namespace */ 'decimal': { /** * @param {number|string} x - value * @return {number} converted value */ convert( x ) { const num = Number( x ); if ( isNaN( num ) || num === Number.POSITIVE_INFINITY || num === Number.NEGATIVE_INFINITY ) { // Comply with XML schema decimal type that has no special values. '' is our only option. return ''; } return num; }, /** * @param {number|string} x - value * @return {boolean} whether value is valid */ validate( x ) { const num = Number( x ); return !isNaN( num ) && num !== Number.POSITIVE_INFINITY && num !== Number.NEGATIVE_INFINITY; } }, /** * @namespace */ 'int': { /** * @param {number|string} x - value * @return {number} converted value */ convert( x ) { const num = Number( x ); if ( isNaN( num ) || num === Number.POSITIVE_INFINITY || num === Number.NEGATIVE_INFINITY ) { // Comply with XML schema int type that has no special values. '' is our only option. return ''; } return ( num >= 0 ) ? Math.floor( num ) : -Math.floor( Math.abs( num ) ); }, /** * @param {number|string} x - value * @return {boolean} whether value is valid */ validate( x ) { const num = Number( x ); return !isNaN( num ) && num !== Number.POSITIVE_INFINITY && num !== Number.NEGATIVE_INFINITY && Math.round( num ) === num && num.toString() === x.toString(); } }, /** * @namespace */ 'date': { /** * @param {string} x - value * @return {boolean} whether value is valid */ validate( x ) { const pattern = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/; const segments = pattern.exec( x ); if ( segments && segments.length === 4 ) { const year = Number( segments[ 1 ] ); const month = Number( segments[ 2 ] ) - 1; const day = Number( segments[ 3 ] ); const date = new Date( year, month, day ); // Do not approve automatic JavaScript conversion of invalid dates such as 2017-12-32 return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day; } return false; }, /** * @param {number|string} x - value * @return {string} converted value */ convert( x ) { if ( isNumber( x ) ) { // The XPath expression "2012-01-01" + 2 returns a number of days in XPath. const date = new Date( x * 24 * 60 * 60 * 1000 ); return date.toString() === 'Invalid Date' ? '' : `${date.getFullYear().toString().padStart( 4, '0' )}-${( date.getMonth() + 1 ).toString().padStart( 2, '0' )}-${date.getDate().toString().padStart( 2, '0' )}`; } else { // For both dates and datetimes // If it's a datetime, we can quite safely assume it's in the local timezone, and therefore we can simply chop off // the time component. if ( /[0-9]T[0-9]/.test( x ) ) { x = x.split( 'T' )[ 0 ]; } return this.validate( x ) ? x : ''; } } }, /** * @namespace */ 'datetime': { /** * @param {string} x - value * @return {boolean} whether value is valid */ validate( x ) { const parts = x.split( 'T' ); if ( parts.length === 2 ) { return types.date.validate( parts[ 0 ] ) && types.time.validate( parts[ 1 ], false ); } return types.date.validate( parts[ 0 ] ); }, /** * @param {number|string} x - value * @return {string} converted value */ convert( x ) { let date = 'Invalid Date'; const parts = x.split( 'T' ); if ( isNumber( x ) ) { // The XPath expression "2012-01-01T01:02:03+01:00" + 2 returns a number of days in XPath. date = new Date( x * 24 * 60 * 60 * 1000 ); } else if ( /[0-9]T[0-9]/.test( x ) && parts.length === 2 ) { const convertedDate = types.date.convert( parts[ 0 ] ); // The milliseconds are optional for datetime (and shouldn't be added) const convertedTime = types.time.convert( parts[ 1 ], false ); if ( convertedDate && convertedTime ) { return `${convertedDate}T${convertedTime}`; } } else { const convertedDate = types.date.convert( parts[ 0 ] ); if ( convertedDate ) { return `${convertedDate}T00:00:00.000${( new Date() ).getTimezoneOffsetAsTime()}`; } } return date.toString() !== 'Invalid Date' ? date.toISOLocalString() : ''; } }, /** * @namespace */ 'time': { // Note that it's okay if the validate function is stricter than the spec, // (for timezone offset), as long as the convertor automatically converts // to a valid time. /** * @param {string} x - value * @param {boolean} [requireMillis] - whether milliseconds are required * @return {boolean} whether value is valid */ validate( x, requireMillis ) { let m = x.match( /^(\d\d):(\d\d):(\d\d)\.\d\d\d(\+|-)(\d\d):(\d\d)$/ ); requireMillis = typeof requireMillis !== 'boolean' ? true : requireMillis; if ( !m && !requireMillis ) { m = x.match( /^(\d\d):(\d\d):(\d\d)(\+|-)(\d\d):(\d\d)$/ ); } if ( !m ) { return false; } // no need to convert to numbers since we know they are number strings return m[ 1 ] < 24 && m[ 1 ] >= 0 && m[ 2 ] < 60 && m[ 2 ] >= 0 && m[ 3 ] < 60 && m[ 3 ] >= 0 && m[ 5 ] < 24 && m[ 5 ] >= 0 && // this could be tighter m[ 6 ] < 60 && m[ 6 ] >= 0; // this is probably either 0 or 30 }, /** * @param {string} x - value * @param {boolean} [requireMillis] - whether milliseconds are required * @return {string} converted value */ convert( x, requireMillis ) { let date; const o = {}; let parts; let time; let secs; let tz; let offset; const timeAppearsCorrect = /^[0-9]{1,2}:[0-9]{1,2}(:[0-9.]*)?/; requireMillis = typeof requireMillis !== 'boolean' ? true : requireMillis; if ( !timeAppearsCorrect.test( x ) ) { // An XPath expression would return a datetime string since there is no way to request a timeValue. // We can test this by trying to convert to a date. date = new Date( x ); if ( date.toString() !== 'Invalid Date' ) { x = `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}.${date.getMilliseconds()}${date.getTimezoneOffsetAsTime()}`; } else { return ''; } } parts = x.toString().split( /(\+|-|Z)/ ); // We're using a 'capturing group' here, so the + or - is included!. if ( parts.length < 1 ) { return ''; } time = parts[ 0 ].split( ':' ); tz = parts[ 2 ] ? [ parts[ 1 ] ].concat( parts[ 2 ].split( ':' ) ) : ( parts[ 1 ] === 'Z' ? [ '+', '00', '00' ] : [] ); o.hours = time[ 0 ].padStart( 2, '0' ); o.minutes = time[ 1 ].padStart( 2, '0' ); secs = time[ 2 ] ? time[ 2 ].split( '.' ) : [ '00' ]; o.seconds = secs[ 0 ]; o.milliseconds = secs[ 1 ] || ( requireMillis ? '000' : undefined ); if ( tz.length === 0 ) { offset = new Date().getTimezoneOffsetAsTime(); } else { offset = `${tz[0] + tz[1].padStart( 2, '0' )}:${tz[2] ? tz[2].padStart( 2, '0' ) : '00'}`; } x = `${o.hours}:${o.minutes}:${o.seconds}${o.milliseconds ? `.${o.milliseconds}` : ''}${offset}`; return this.validate( x, requireMillis ) ? x : ''; }, /** * converts "11:30 AM", and "11:30 ", and "11:30 上午" to: "11:30" * converts "11:30 PM", and "11:30 下午" to: "23:30" * * @param {string} x - value * @return {string} converted value */ convertMeridian( x ) { x = x.trim(); if ( time.hasMeridian( x ) ) { const parts = x.split( ' ' ); const timeParts = parts[ 0 ].split( ':' ); if ( parts.length > 0 ) { // This will only work for latin numbers but that should be fine because that's what the widget supports. if ( parts[ 1 ] === time.pmNotation ) { timeParts[ 0 ] = ( ( Number( timeParts[ 0 ] ) % 12 ) + 12 ).toString().padStart( 2, '0' ); } else if ( parts[ 1 ] === time.amNotation ) { timeParts[ 0 ] = ( Number( timeParts[ 0 ] ) % 12 ).toString().padStart( 2, '0' ); } x = timeParts.join( ':' ); } } return x; } }, /** * @namespace */ 'barcode': { /** * @return {boolean} always `true` */ validate() { return true; } }, /** * @namespace */ 'geopoint': { /** * @param {string} x - value * @return {boolean} whether value is valid */ validate( x ) { const coords = x.toString().trim().split( ' ' ); // Note that longitudes from -180 to 180 are problematic when recording points close to the international // dateline. They are therefore set from -360 to 360 (circumventing Earth twice, I think) which is // an arbitrary limit. https://github.com/kobotoolbox/enketo-express/issues/1033 return ( coords[ 0 ] !== '' && coords[ 0 ] >= -90 && coords[ 0 ] <= 90 ) && ( coords[ 1 ] !== '' && coords[ 1 ] >= -360 && coords[ 1 ] <= 360 ) && ( typeof coords[ 2 ] === 'undefined' || !isNaN( coords[ 2 ] ) ) && ( typeof coords[ 3 ] === 'undefined' || ( !isNaN( coords[ 3 ] ) && coords[ 3 ] >= 0 ) ); }, /** * @param {string} x - value * @return {string} converted value */ convert( x ) { return x.toString().trim(); } }, /** * @namespace */ 'geotrace': { /** * @param {string} x - value * @return {boolean} whether value is valid */ validate( x ) { const geopoints = x.toString().split( ';' ); return geopoints.length >= 2 && geopoints.every( geopoint => types.geopoint.validate( geopoint ) ); }, /** * @param {string} x - value * @return {string} converted value */ convert( x ) { return x.toString().trim(); } }, /** * @namespace */ 'geoshape': { /** * @param {string} x - value * @return {boolean} whether value is valid */ validate( x ) { const geopoints = x.toString().split( ';' ); return geopoints.length >= 4 && ( geopoints[ 0 ] === geopoints[ geopoints.length - 1 ] ) && geopoints.every( geopoint => types.geopoint.validate( geopoint ) ); }, /** * @param {string} x - value * @return {string} converted value */ convert( x ) { return x.toString().trim(); } }, /** * @namespace */ 'binary': { /** * @return {boolean} always `true` */ validate() { return true; } } }; export default types;