/*
* Copyright (c) 2017 OBiBa. All rights reserved.
*
* This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.obiba.magma.js.methods;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.Function;
import org.mozilla.javascript.NativeObject;
import org.mozilla.javascript.RegExpProxy;
import org.mozilla.javascript.ScriptRuntime;
import org.mozilla.javascript.Scriptable;
import org.obiba.magma.Value;
import org.obiba.magma.ValueType;
import org.obiba.magma.js.MagmaJsEvaluationRuntimeException;
import org.obiba.magma.js.Rhino;
import org.obiba.magma.js.ScriptableValue;
import org.obiba.magma.type.BooleanType;
import org.obiba.magma.type.DateTimeType;
import org.obiba.magma.type.DateType;
import org.obiba.magma.type.IntegerType;
import org.obiba.magma.type.LocaleType;
import org.obiba.magma.type.TextType;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
/**
* Methods of the {@code ScriptableValue} javascript class that returns {@code ScriptableValue} of {@code BooleanType}
*/
@SuppressWarnings({ "UnusedParameters", "IfMayBeConditional", "UnusedDeclaration", "StaticMethodOnlyUsedInOneClass" })
public class TextMethods {
private TextMethods() {
}
/**
* <pre>
* $('TextVar').trim()
* </pre>
*/
public static ScriptableValue trim(Context ctx, Scriptable thisObj, @Nullable Object[] args,
@Nullable Function funObj) {
ValueFunction trimFunction = new ValueFunction() {
@SuppressWarnings("ConstantConditions")
@Override
public Value apply(Value input) {
if(input == null || input.isNull()) return TextType.get().nullValue();
return TextType.get().valueOf(input.toString().trim());
}
@Override
public ValueType getValueType() {
return TextType.get();
}
};
return transformValue((ScriptableValue) thisObj, trimFunction);
}
/**
* <pre>
* $('TextVar').upperCase()
* $('TextVar').upperCase('fr')
* </pre>
*/
public static ScriptableValue upperCase(Context ctx, Scriptable thisObj, @Nullable Object[] args,
@Nullable Function funObj) {
final Locale locale = getLocaleArgument(args);
ValueFunction caseFunction = new ValueFunction() {
@Override
@SuppressWarnings("ConstantConditions")
public Value apply(Value input) {
if(input == null || input.isNull()) return TextType.get().nullValue();
String stringValue = input.toString();
stringValue = locale == null ? stringValue.toUpperCase() : stringValue.toUpperCase(locale);
return TextType.get().valueOf(stringValue);
}
@Override
public ValueType getValueType() {
return TextType.get();
}
};
return transformValue((ScriptableValue) thisObj, caseFunction);
}
/**
* <pre>
* $('TextVar').lowerCase()
* $('TextVar').lowerCase('fr')
* </pre>
*/
public static ScriptableValue lowerCase(Context ctx, Scriptable thisObj, @Nullable Object[] args,
@Nullable Function funObj) {
final Locale locale = getLocaleArgument(args);
ValueFunction caseFunction = new ValueFunction() {
@Override
@SuppressWarnings("ConstantConditions")
public Value apply(Value input) {
if(input == null || input.isNull()) return TextType.get().nullValue();
String stringValue = input.toString();
stringValue = locale == null ? stringValue.toLowerCase() : stringValue.toLowerCase(locale);
return TextType.get().valueOf(stringValue);
}
@Override
public ValueType getValueType() {
return TextType.get();
}
};
return transformValue((ScriptableValue) thisObj, caseFunction);
}
@Nullable
private static Locale getLocaleArgument(@Nullable Object... args) {
if(args != null && args.length > 0) {
Object localeArg = args[0];
Value localeValue = localeArg instanceof ScriptableValue
? ((ScriptableValue) localeArg).getValue()
: LocaleType.get().valueOf(localeArg);
return localeValue.isNull() ? null : (Locale) localeValue.getValue();
}
return null;
}
/**
* <pre>
* $('TextVar').capitalize()
* $('TextVar').capitalize(':;_.,(')
* </pre>
*/
public static ScriptableValue capitalize(Context ctx, Scriptable thisObj, @Nullable Object[] args,
@Nullable Function funObj) {
return transformValue((ScriptableValue) thisObj, new CapitalizeFunction(getDelim(args)));
}
private static class CapitalizeFunction implements ValueFunction {
private final String delim;
private CapitalizeFunction(String delim) {
this.delim = delim;
}
@Override
public Value apply(Value input) {
if(input == null || input.isNull()) {
return TextType.get().nullValue();
}
@SuppressWarnings("ConstantConditions")
char[] buffer = input.toString().toCharArray();
boolean capitalizeNext = true;
for(int i = 0; i < buffer.length; i++) {
char ch = buffer[i];
if(isDelimiter(ch, delim)) {
capitalizeNext = true;
} else if(capitalizeNext) {
buffer[i] = Character.toTitleCase(ch);
capitalizeNext = false;
}
}
return TextType.get().valueOf(new String(buffer));
}
@Override
public ValueType getValueType() {
return TextType.get();
}
private boolean isDelimiter(char ch, @Nullable String delimiters) {
if(delimiters == null || delimiters.isEmpty()) {
return Character.isWhitespace(ch);
}
for(char delimiter : delimiters.toCharArray()) {
if(ch == delimiter) {
return true;
}
}
return false;
}
}
@Nullable
private static String getDelim(@Nullable Object... args) {
if(args == null) return null;
StringBuilder sb = new StringBuilder();
for(Object arg : args) {
if(arg != null) sb.append(arg.toString());
}
return sb.toString();
}
/**
* <pre>
* $('TextVar').replace('regex', '$1')
* </pre>
*/
public static ScriptableValue replace(final Context ctx, final Scriptable thisObj, final Object[] args,
@Nullable Function funObj) {
ValueFunction replaceFunction = new ValueFunction() {
@Override
public Value apply(Value input) {
String stringValue = input == null || input.isNull() ? null : input.toString();
// Delegate to Javascript's String.replace method
String result = (String) ScriptRuntime.checkRegExpProxy(ctx)
.action(ctx, thisObj, ScriptRuntime.toObject(ctx, thisObj, stringValue), args, RegExpProxy.RA_REPLACE);
return TextType.get().valueOf(result);
}
@Override
public ValueType getValueType() {
return TextType.get();
}
};
return transformValue((ScriptableValue) thisObj, replaceFunction);
}
/**
* <pre>
* $('TextVar').matches('regex1', 'regex2', ...)
* </pre>
*/
public static ScriptableValue matches(final Context ctx, final Scriptable thisObj, final Object[] args,
@Nullable Function funObj) {
ValueFunction matchesFunction = new ValueFunction() {
@Override
public Value apply(Value input) {
String stringValue = input == null || input.isNull() ? null : input.toString();
// Delegate to Javascript's String.replace method
boolean matches = false;
if(stringValue != null) {
for(Object arg : args) {
Object result = ScriptRuntime.checkRegExpProxy(ctx)
.action(ctx, thisObj, ScriptRuntime.toObject(ctx, thisObj, stringValue), new Object[] { arg },
RegExpProxy.RA_MATCH);
if(result != null) {
matches = true;
}
}
}
return BooleanType.get().valueOf(matches);
}
@Override
public ValueType getValueType() {
return BooleanType.get();
}
};
return transformValue((ScriptableValue) thisObj, matchesFunction);
}
/**
* Returns a new {@link ScriptableValue} of {@link TextType} combining the String value of this value with the String
* values of the parameters parameters.
* <p/>
* <pre>
* $('TextVar').concat($('TextVar'))
* $('Var').concat($('Var'))
* $('Var').concat('SomeValue')
* </pre>
*/
public static ScriptableValue concat(Context ctx, Scriptable thisObj, final Object[] args,
@Nullable Function funObj) {
ValueFunction concatFunction = new ValueFunction() {
@Override
public Value apply(Value input) {
String stringValue = input == null || input.isNull() ? null : input.toString();
StringBuilder sb = new StringBuilder();
sb.append(stringValue);
if(args != null) {
for(Object arg : args) {
if(arg instanceof ScriptableValue) {
arg = arg.toString();
}
sb.append(arg);
}
}
return TextType.get().valueOf(sb.toString());
}
@Override
public ValueType getValueType() {
return TextType.get();
}
};
return transformValue((ScriptableValue) thisObj, concatFunction);
}
/**
* Categorise values of a variable. That is, lookup the current value in an association table and return the
* associated value. When the current value is not found in the association table, the method returns a null value.
* <p/>
* <pre>
* $('SMOKE').map({'NO':0, 'YES':1, 'DNK':8888, 'PNA':9999})
*
* $('SMOKE_ONSET').map(
* {'AGE':$('SMOKE_ONSET_AGE'),
* 'YEAR':$('SMOKE_ONSET_YEAR').minus($('BIRTH_DATE').year()),
* 'DNK':8888,
* 'PNA':9999})
*
* // Works for sequences also (FRENCH,ENGLISH --> 0,1)
* $('LANGUAGES_SPOKEN').map({'FRENCH':0, 'ENGLISH':1});
*
* // Specification of default value
* $('LANGUAGES_SPOKEN').map({'FRENCH':0, 'ENGLISH':1}, 99);
*
* // Specification of default value and null value mapping
* $('LANGUAGES_SPOKEN').map({'FRENCH':0, 'ENGLISH':1}, 99, 88);
*
* // Can execute function to calculate lookup value
* $('BMI_DIAG').map(
* {'OVERW': function(value) {
* // 'OVERW' is passed in as the method's parameter
* var computedValue = 2*2; // some complex computation...
* return comuptedValue;
* },
* 'NORMW': 0
* }
*
* // Can execute function to calculate default value
* $('LANGUAGES_SPOKEN').map({'FRENCH':0, 'ENGLISH':1}, function(v){ return 99; });
*
* </pre>
*
* @param ctx
* @param thisObj
* @param args
* @param funObj
* @return
*/
public static ScriptableValue map(Context ctx, Scriptable thisObj, Object[] args, Function funObj) {
if(args == null || args.length < 1 || !(args[0] instanceof NativeObject)) {
throw new MagmaJsEvaluationRuntimeException("illegal arguments to map()");
}
ScriptableValue sv = (ScriptableValue) thisObj;
Scriptable valueMap = (Scriptable) args[0];
// This could be determined by looking at the mapped values (if all ints, then 'integer', else 'text', etc.)
ValueType returnType = TextType.get();
Object defaultArg = args.length < 2 ? null : args[1];
Object nullArg = args.length < 3 ? null : args[2] == null ? returnType.nullValue() : args[2];
Value currentValue = sv.getValue();
if(currentValue.isSequence()) {
if(currentValue.isNull()) {
return new ScriptableValue(thisObj, returnType.nullSequence());
}
Collection<Value> newValues = new ArrayList<>();
//noinspection ConstantConditions
for(Value value : currentValue.asSequence().getValue()) {
newValues.add(lookupValue(ctx, thisObj, value, returnType, valueMap, defaultArg, nullArg));
}
return new ScriptableValue(thisObj, returnType.sequenceOf(newValues));
}
return new ScriptableValue(thisObj,
lookupValue(ctx, thisObj, currentValue, returnType, valueMap, defaultArg, nullArg));
}
/**
* Tries to convert value of any type to a Date value given a date format.
* <pre>
* $('VAR').date('MM/dd/yy')
* </pre>
*
* @param ctx
* @param thisObj
* @param args
* @param funObj
* @return
*/
public static ScriptableValue date(Context ctx, Scriptable thisObj, Object[] args, Function funObj) {
ScriptableValue sv = (ScriptableValue) thisObj;
// Return the ValueType name
if(args.length == 0 || args[0] == null) {
throw new MagmaJsEvaluationRuntimeException("date format is missing to date()");
}
return getDateValue(thisObj, DateType.get(), sv.getValue(), args[0].toString());
}
/**
* Tries to convert value of any type to a DateTime value given a date format.
* <pre>
* $('VAR').date('MM/dd/yy HH:mm')
* </pre>
*
* @param ctx
* @param thisObj
* @param args
* @param funObj
* @return
*/
public static ScriptableValue datetime(Context ctx, Scriptable thisObj, Object[] args, Function funObj) {
ScriptableValue sv = (ScriptableValue) thisObj;
// Return the ValueType name
if(args.length == 0 || args[0] == null) {
throw new MagmaJsEvaluationRuntimeException("date time format is missing to datetime()");
}
return getDateValue(thisObj, DateTimeType.get(), sv.getValue(), args[0].toString());
}
private static ScriptableValue getDateValue(Scriptable thisObj, final ValueType dateType, Value value,
String formatArg) {
final SimpleDateFormat format = new SimpleDateFormat(formatArg);
ValueFunction dateFunction = new ValueFunction() {
@Override
public Value apply(Value input) {
if(input == null || input.isNull()) return dateType.nullValue();
String inputStr = input.toString();
if(inputStr == null || inputStr.trim().isEmpty()) return dateType.nullValue();
try {
Date date = format.parse(inputStr);
return dateType.valueOf(date);
} catch(ParseException e) {
throw new MagmaJsEvaluationRuntimeException(
"date/datetime format '" + format.toPattern() + "' fails to parse: '" + inputStr + "'");
}
}
@Override
public ValueType getValueType() {
return dateType;
}
};
return transformValue((ScriptableValue) thisObj, dateFunction);
}
/**
* Returns the default value to use when the lookup value is not found in the map. This method is used by the map()
* method.
*
* @param returnType
* @param defaultArg
* @param value Currently evaluated value
* @return
*/
private static Value defaultValue(Context ctx, Scriptable thisObj, ValueType returnType, Object defaultArg, Value value) {
if(defaultArg == null) {
// No default value was specified. Return null.
return returnType.nullValue();
}
if(defaultArg instanceof ScriptableValue) {
return ((ScriptableValue) defaultArg).getValue();
}
Object newValue = defaultArg;
if(defaultArg instanceof Function)
newValue = callValueFunction(ctx, thisObj, (Function) defaultArg,
value == null ? new Object[] {} : new Object[] { new ScriptableValue(thisObj, value) });
return returnType.valueOf(Rhino.fixRhinoNumber(newValue));
}
/**
* Call the function with arguments in the given context and return the primitive value.
*
* @param ctx
* @param thisObj
* @param valueFunction
* @param args
* @return
*/
private static Object callValueFunction(Context ctx, Scriptable thisObj, Function valueFunction, Object[] args) {
Object evaluatedValue = valueFunction.call(ctx, thisObj, thisObj, args);
if (evaluatedValue instanceof ScriptableValue) {
Value value = ((ScriptableValue) evaluatedValue).getValue();
return value.isNull() ? null : value.getValue();
}
return evaluatedValue;
}
/**
* Returns the value to use when the lookup value is null. This method is used by the map() method.
*
* @param returnType
* @param nullArg
* @return
*/
private static Value nullValue(Context ctx, Scriptable thisObj, ValueType returnType, Object defaultArg, Object nullArg) {
if(nullArg == null) {
// No value for null was specified. Return what is defined as default value.
return defaultValue(ctx, thisObj, returnType, defaultArg, null);
}
if(nullArg instanceof ScriptableValue) {
return ((ScriptableValue) nullArg).getValue();
}
Object newValue = nullArg;
if(nullArg instanceof Function)
newValue = callValueFunction(ctx, thisObj, (Function) nullArg, new Object[] {});
return returnType.valueOf(Rhino.fixRhinoNumber(newValue));
}
/**
* Lookup {@code value} in {@code valueMap} and return the mapped value of type {@code returnType}
*
* @param ctx
* @param thisObj
* @param value
* @param returnType
* @param valueMap
* @return
*/
@SuppressWarnings("PMD.ExcessiveParameterList")
private static Value lookupValue(Context ctx, Scriptable thisObj, Value value, ValueType returnType,
Scriptable valueMap, Object defaultArg, Object nullArg) {
if(value.isNull()) return nullValue(ctx, thisObj, returnType, defaultArg, nullArg);
// MAGMA-163: lookup using string and index-based keys
String asName = value.toString();
Object newValue = valueMap.get(asName, null);
if(newValue == NativeObject.NOT_FOUND) {
// Not found, try converting the input to an Integer and use an indexed-lookup if it works
Integer index = asJsIndex(value);
if(index != null) {
newValue = valueMap.get(index, null);
}
}
if(newValue == null) return returnType.nullValue();
if(newValue == NativeObject.NOT_FOUND) return defaultValue(ctx, thisObj, returnType, defaultArg, value);
if(newValue instanceof Function)
newValue = callValueFunction(ctx, thisObj, (Function) newValue, new Object[] { new ScriptableValue(thisObj, value) });
return returnType.valueOf(Rhino.fixRhinoNumber(newValue));
}
/**
* Try to convert the input value as a index usable as a integer-based lookup
*
* @param value
* @return
*/
@Nullable
private static Integer asJsIndex(Value value) {
if(value.isNull()) return null;
Number asNumber = null;
if(value.getValueType() == IntegerType.get()) {
asNumber = (Number) value.getValue();
} else {
try {
// Try a conversion. Throws a runtime exception when it fails
asNumber = (Number) IntegerType.get().convert(value).getValue();
} catch(RuntimeException e) {
// ignored
}
}
if(asNumber != null) {
return asNumber.intValue();
}
return null;
}
/**
* Transform a value or values from a value sequence using the provided function.
*
* @param sv
* @param valueFunction
* @return
*/
private static ScriptableValue transformValue(ScriptableValue sv,
@NotNull ValueFunction valueFunction) {
Value value = sv.getValue();
if(value.isNull()) {
return value.isSequence()
? new ScriptableValue(sv, valueFunction.getValueType().nullSequence())
: new ScriptableValue(sv, valueFunction.apply(value));
}
if(value.isSequence()) {
return new ScriptableValue(sv, valueFunction.getValueType()
.sequenceOf(Lists.newArrayList(Iterables.transform(value.asSequence().getValue(), valueFunction))));
}
return new ScriptableValue(sv, valueFunction.apply(value));
}
}