enketo-core
Version:
Extensible Enketo form engine
484 lines (452 loc) • 14.6 kB
JavaScript
/**
* XML types
*
* @module types
*/
import {
getTimezoneOffsetAsTime,
toISOLocalString,
} from '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')}`;
}
// 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${getTimezoneOffsetAsTime(
new Date()
)}`;
}
}
return date.toString() !== 'Invalid Date'
? toISOLocalString(date)
: '';
},
},
/**
* @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()}${getTimezoneOffsetAsTime(
date
)}`;
} 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 = getTimezoneOffsetAsTime(new Date());
} 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;