package org.projectbuendia.client.ui.chart;
import com.google.common.collect.ImmutableList;
import com.mitchellbosecke.pebble.extension.AbstractExtension;
import com.mitchellbosecke.pebble.extension.Filter;
import com.mitchellbosecke.pebble.extension.Function;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.Interval;
import org.joda.time.LocalDate;
import org.joda.time.ReadableInstant;
import org.joda.time.format.DateTimeFormat;
import org.projectbuendia.client.models.ObsPoint;
import org.projectbuendia.client.models.ObsValue;
import org.projectbuendia.client.utils.Logger;
import org.projectbuendia.client.utils.Utils;
import java.lang.reflect.InvocationTargetException;
import java.text.Format;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.SortedSet;
import javax.annotation.Nullable;
/**
* Custom filters and functions for our Pebble templates. These should be written to avoid throwing
* exceptions as much as possible (as that crashes the rendering of the entire patient chart);
* it's better to return something that reveals useful information about the problem in the output.
*/
public class PebbleExtension extends AbstractExtension {
private static final Logger LOG = Logger.create();
static Map<String, Filter> filters = new HashMap<>();
static {
filters.put("min", new MinFilter());
filters.put("max", new MaxFilter());
filters.put("avg", new AvgFilter());
filters.put("js", new JsFilter());
filters.put("values", new ValuesFilter());
filters.put("format_value", new FormatValueFilter());
filters.put("format_values", new FormatValuesFilter());
filters.put("format_date", new FormatDateFilter());
filters.put("format_time", new FormatTimeFilter());
filters.put("line_break_html", new LineBreakHtmlFilter());
filters.put("tosafechars", new toSafeCharsFilter());
}
static Map<String, Function> functions = new HashMap<>();
static {
functions.put("get_latest_point", new GetLatestPointFunction());
functions.put("get_all_points", new GetAllPointsFunction());
functions.put("get_order_execution_count", new GetOrderExecutionCountFunction());
functions.put("intervals_overlap", new IntervalsOverlapFunction());
}
public static final String TYPE_ERROR = "?";
@Override public Map<String, Filter> getFilters() {
return filters;
}
@Override public Map<String, Function> getFunctions() {
return functions;
}
abstract static class ZeroArgFilter implements Filter {
@Override public List<String> getArgumentNames() {
return ImmutableList.of();
}
}
static class MinFilter extends ZeroArgFilter {
@Override public @Nullable Object apply(Object input, Map<String, Object> args) {
if (input instanceof Collection) {
return ((Collection) input).isEmpty() ? null : Collections.min((Collection) input);
} else return null;
}
}
static class MaxFilter extends ZeroArgFilter {
@Override public @Nullable Object apply(Object input, Map<String, Object> args) {
if (input instanceof Collection) {
return ((Collection) input).isEmpty() ? null : Collections.max((Collection) input);
} else return null;
}
}
/** Computes the average of a set of numbers or numeric ObsValues. */
static class AvgFilter extends ZeroArgFilter {
@Override public @Nullable Object apply(Object input, Map<String, Object> args) {
double sum = 0;
int count = 0;
if (input instanceof Collection) {
for (Object item : (Collection) input) {
if (item instanceof ObsValue) {
Double number = ((ObsValue) item).number;
if (number != null) {
sum += number;
count += 1;
}
} else if (item instanceof Number) {
sum += ((Number) item).doubleValue();
count += 1;
}
}
}
return count == 0 ? null : sum/count;
}
}
/** Converts a Java null, boolean, integer, double, string, or DateTime to a JS expression. */
static class JsFilter extends ZeroArgFilter {
@Override public Object apply(Object input, Map<String, Object> args) {
if (input == null) {
return "null";
} else if (input instanceof Boolean) {
return ((Boolean) input) ? "true" : "false";
} else if (input instanceof Integer || input instanceof Double) {
return "" + input;
} else if (input instanceof String) {
String s = (String) input;
return "'" + s.replace("\\", "\\\\").replace("\n", "\\n").replace("'", "\\'") + "'";
} else if (input instanceof ReadableInstant) {
return "new Date(" + ((ReadableInstant) input).getMillis() + ")";
} else {
return "null";
}
}
}
/** points | values -> a list of the ObsValues in the given list of ObsPoints */
static class ValuesFilter extends ZeroArgFilter {
@Override public Object apply(Object input, Map<String, Object> args) {
List<ObsValue> values = new ArrayList<>();
// The input is a tuple, so we must ensure that values has the same number of elements.
if (input instanceof ObsPoint[]) {
for (ObsPoint point : (ObsPoint[]) input) {
values.add(point == null ? null : point.value);
}
} else if (input instanceof Collection) {
for (Object item : (Collection) input) {
values.add((item instanceof ObsPoint) ? ((ObsPoint) item).value : null);
}
}
return values;
}
}
/** Formats a single value. */
static class FormatValueFilter implements Filter {
@Override
public List<String> getArgumentNames() {
return ImmutableList.of("format");
}
@Override
public Object apply(Object input, Map<String, Object> args) {
if (input instanceof ObsValue) {
return formatValues(ImmutableList.of((ObsValue) input), asFormat(args.get("format")));
}
return TYPE_ERROR;
}
}
/**
* Formats a tuple of values corresponding to the concepts listed in the "concept" column
* in the profile. This is for formatting values of different concepts together in one
* string (e.g. systolic / diastolic blood pressure), not a series of values over time.
*/
static class FormatValuesFilter implements Filter {
@Override
public List<String> getArgumentNames() {
return ImmutableList.of("format");
}
@Override
public Object apply(Object input, Map<String, Object> args) {
List<ObsValue> values = new ArrayList<>();
// The input is a tuple, so we must ensure that values has the same number of elements.
if (input instanceof Object[]) {
for (Object item : (Object[]) input) {
values.add(item instanceof ObsValue ? (ObsValue) item : null);
}
} else if (input instanceof Collection) {
for (Object item : (Collection) input) {
values.add(item instanceof ObsValue ? (ObsValue) item : null);
}
} else if (input instanceof ObsValue) {
values.add((ObsValue) input);
}
return formatValues(values, asFormat(args.get("format")));
}
}
static Format asFormat(Object arg) {
return arg instanceof Format ? (Format) arg : arg == null ? null : new ObsFormat("" + arg);
}
static String formatValues(List<ObsValue> values, Format format) {
if (format == null) return ""; // we use null to represent an empty format
// ObsFormat expects an array of Obs instances with a 1-based index.
ObsValue[] array = new ObsValue[values.size() + 1];
// ExtendedMessageFormat has a bad bug: it silently fails to pass along null values
// to sub-formatters. To work around this, replace all nulls with a sentinel object.
// (See the ObsOutputFormat.format() method, which checks for UNOBSERVED.)
for (int i = 0; i < values.size(); i++) {
ObsValue value = values.get(i);
array[i + 1] = value == null ? ObsFormat.UNOBSERVED : value;
}
try {
return format.format(array);
} catch (Throwable e) {
while ((e instanceof InvocationTargetException ||
e.getCause() instanceof InvocationTargetException) && e.getCause() != e) {
e = e.getCause();
}
LOG.e(e, "Could not apply format " + format);
return "" + format; // make the problem visible on the page to aid fixes
}
}
/** Formats a LocalDate. (For times, use format_time, not format_date.) */
static class FormatDateFilter implements Filter {
@Override public List<String> getArgumentNames() {
return ImmutableList.of("pattern");
}
@Override public Object apply(Object input, Map<String, Object> args) {
String pattern = "" + args.get("pattern");
if (input instanceof LocalDate) {
return DateTimeFormat.forPattern(pattern).print(new LocalDate(input));
} else return TYPE_ERROR;
}
}
static class toSafeCharsFilter implements Filter {
@Override public List<String> getArgumentNames() {
return ImmutableList.of("input");
}
@Override public Object apply(Object input, Map<String, Object> args) {
return Utils.removeUnsafeChars(("" + input));
}
}
/** Formats an Instant or DateTime. (For dates, use format_date, not format_time.) */
static class FormatTimeFilter implements Filter {
@Override public List<String> getArgumentNames() {
return ImmutableList.of("pattern");
}
@Override public Object apply(Object input, Map<String, Object> args) {
String pattern = "" + args.get("pattern");
if (input instanceof ReadableInstant) {
return DateTimeFormat.forPattern(pattern).print(
new DateTime(input, DateTimeZone.getDefault())); //Convert to specific time zone
} else return TYPE_ERROR;
}
}
/** line_break_html(text) -> HTML for the given text with newlines replaced by <br> */
static class LineBreakHtmlFilter extends ZeroArgFilter {
@Override public Object apply(Object input, Map<String, Object> args) {
return ("" + input).replace("&", "&").replace("<", "<").replace("\n", "<br>");
}
}
/** get_all_points(row, column) -> all ObsPoints for concept 1 in a given cell, in time order */
static class GetAllPointsFunction implements Function {
@Override public List<String> getArgumentNames() {
return ImmutableList.of("row", "column");
}
@Override public Object execute(Map<String, Object> args) {
// TODO/robustness: Check types before casting.
Row row = (Row) args.get("row");
Column column = (Column) args.get("column");
return column.pointSetByConceptUuid.get(row.item.conceptUuids[0]);
}
}
/** get_latest_point(row, column) -> the latest ObsPoint for concept 1 in a given cell, or null */
static class GetLatestPointFunction implements Function {
@Override public List<String> getArgumentNames() {
return ImmutableList.of("row", "column");
}
@Override public @Nullable Object execute(Map<String, Object> args) {
// TODO/robustness: Check types before casting.
Row row = (Row) args.get("row");
Column column = (Column) args.get("column");
SortedSet<ObsPoint> obsSet = column.pointSetByConceptUuid.get(row.item.conceptUuids[0]);
return obsSet.isEmpty() ? null : obsSet.last();
}
}
static class GetOrderExecutionCountFunction implements Function {
@Override public List<String> getArgumentNames() {
return ImmutableList.of("order_uuid", "column");
}
@Override public Object execute(Map<String, Object> args) {
// TODO/robustness: Check types before casting.
String orderUuid = (String) args.get("order_uuid");
Column column = (Column) args.get("column");
Integer count = column.executionCountsByOrderUuid.get(orderUuid);
return count == null ? 0 : count;
}
}
static class IntervalsOverlapFunction implements Function {
@Override public List<String> getArgumentNames() {
return ImmutableList.of("a", "b");
}
@Override public Object execute(Map<String, Object> args) {
// TODO/robustness: Check types before casting.
Interval a = (Interval) args.get("a");
Interval b = (Interval) args.get("b");
return a.overlaps(b);
}
}
}