/**
* 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;