MetadataJson.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License
 * at:
 *
 *   http://opensource.org/licenses/ecl2.txt
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations under
 * the License.
 *
 */

package org.opencastproject.metadata.dublincore;

import static org.apache.commons.lang3.exception.ExceptionUtils.getMessage;

import org.opencastproject.mediapackage.MediaPackageElementFlavor;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DurationFormatUtils;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.Map;
import java.util.Objects;
import java.util.TimeZone;

public final class MetadataJson {
  private static final Logger logger = LoggerFactory.getLogger(MetadataJson.class);

  /* Keys for the different properties of the metadata JSON Object */
  private static final String JSON_KEY_ID = "id";
  private static final String JSON_KEY_LABEL = "label";
  private static final String JSON_KEY_READONLY = "readOnly";
  private static final String JSON_KEY_REQUIRED = "required";
  private static final String JSON_KEY_TYPE = "type";
  private static final String JSON_KEY_VALUE = "value";
  private static final String JSON_KEY_COLLECTION = "collection";
  private static final String JSON_KEY_TRANSLATABLE = "translatable";
  private static final String JSON_KEY_DELIMITER = "delimiter";
  private static final String JSON_KEY_DIFFERENT_VALUES = "differentValues";
  private static final String JSON_KEY_LISTPROVIDER = "listprovider";
  private static final String KEY_METADATA_TITLE = "title";
  private static final String KEY_METADATA_FLAVOR = "flavor";
  private static final String KEY_METADATA_FIELDS = "fields";
  private static final String KEY_METADATA_LOCKED = "locked";


  /* Keys for the different properties of the metadata JSON Object */
  private static final String KEY_METADATA_ID = "id";
  private static final String KEY_METADATA_VALUE = "value";

  private static final String PATTERN_DURATION = "HH:mm:ss";

  /**
   * Turn a map into a {@link JSONObject} object
   *
   * @param map the source map
   * @return a new {@link JSONObject} generated with the map values
   */
  private static JsonObject mapToJson(final Map<String, String> map) {
    Objects.requireNonNull(map);
    JsonObject json = new JsonObject();
    for (Map.Entry<String, String> entry : map.entrySet()) {
      json.addProperty(entry.getKey(), safeString(entry.getValue()));
    }
    return json;
  }

  public enum JsonType {
    BOOLEAN, DATE, NUMBER, TEXT, MIXED_TEXT, ORDERED_TEXT, TEXT_LONG, TIME
  }

  private MetadataJson() {
  }

  private static SimpleDateFormat getSimpleDateFormatter(final String pattern) {
    final SimpleDateFormat dateFormat;
    if (StringUtils.isNotBlank(pattern)) {
      dateFormat = new SimpleDateFormat(pattern);
    } else {
      dateFormat = new SimpleDateFormat();
    }
    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
    return dateFormat;
  }

  private static <T> JsonElement valueToJson(final T rawValue, final MetadataField.Type type, final String pattern) {
    switch (type) {
      case BOOLEAN:
        return rawValue == null ? new JsonPrimitive("") : new JsonPrimitive(rawValue.toString());

      case DATE: {
        if (rawValue == null) {
          return new JsonPrimitive("");
        }
        SimpleDateFormat dateFormat = getSimpleDateFormatter(pattern);
        return new JsonPrimitive(dateFormat.format((Date) rawValue));
      }

      case DURATION: {
        if (rawValue == null) {
          return new JsonPrimitive("");
        }
        long returnValue = 0L;
        String value = (String) rawValue;
        DCMIPeriod period = EncodingSchemeUtils.decodePeriod(value);

        if (period != null && period.hasStart() && period.hasEnd()) {
          returnValue = period.getEnd().getTime() - period.getStart().getTime();
        } else {
          try {
            returnValue = Long.parseLong(value);
          } catch (NumberFormatException e) {
            logger.debug("Unable to parse duration '{}' as either period or millisecond duration.", value);
          }
        }
        return new JsonPrimitive(DurationFormatUtils.formatDuration(returnValue, PATTERN_DURATION));
      }

      case ITERABLE_TEXT:
      case MIXED_TEXT: {
        JsonArray jsonArray = new JsonArray();

        if (rawValue == null) {
          return jsonArray;
        }

        if (rawValue instanceof String) {
          for (String entry : ((String) rawValue).split(",")) {
            if (StringUtils.isNotBlank(entry)) {
              jsonArray.add(safeString(entry));
            }
          }
        } else {
          for (Object val : (Iterable<?>) rawValue) {
            if (val != null) {
              jsonArray.add(safeString(val));
            }
          }
        }
        return jsonArray;
      }

      case ORDERED_TEXT:
      case TEXT_LONG:
      case TEXT:
        return rawValue == null ? new JsonPrimitive("") : new JsonPrimitive(rawValue.toString());

      case LONG:
        return rawValue == null ? new JsonPrimitive("") : new JsonPrimitive(rawValue.toString());

      case START_DATE:
      case START_TIME: {
        if (rawValue == null) {
          return new JsonPrimitive("");
        }
        String value = (String) rawValue;

        if (StringUtils.isBlank(value)) {
          return new JsonPrimitive("");
        }

        // Try to parse the metadata as DCIM metadata.
        final DCMIPeriod p = EncodingSchemeUtils.decodePeriod(value);
        final SimpleDateFormat dateFormat = getSimpleDateFormatter(pattern);
        if (p != null) {
          return new JsonPrimitive(dateFormat.format(p.getStart()));
        }

        // Not DCIM metadata so it might already be formatted (given from the front and is being returned there
        try {
          dateFormat.parse(value);
          return new JsonPrimitive(value);
        } catch (Exception e) {
          logger.error(
              "Unable to parse temporal metadata '{}' as either DCIM data or a formatted date using pattern {} "
                  + "because:",
              value,
              pattern,
              e);
          throw new IllegalArgumentException(e);
        }
      }

      default:
        throw new IllegalArgumentException("invalid metadata field of type '" + type + "'");
    }
  }

  private static JsonType jsonType(final MetadataField f, final boolean withOrderedText) {
    switch (f.getType()) {
      case BOOLEAN:
        return JsonType.BOOLEAN;
      case DATE:
      case START_DATE:
        return JsonType.DATE;
      case DURATION:
      case ITERABLE_TEXT:
      case TEXT:
        return JsonType.TEXT;
      case MIXED_TEXT:
        return JsonType.MIXED_TEXT;
      case ORDERED_TEXT:
        return withOrderedText ? JsonType.ORDERED_TEXT : JsonType.TEXT;
      case LONG:
        return JsonType.NUMBER;
      case START_TIME:
        return JsonType.TIME;
      case TEXT_LONG:
        return JsonType.TEXT_LONG;
      default:
        throw new IllegalArgumentException("invalid field type '" + f.getType() + "'");
    }
  }

  private static Object valueFromJson(final Object value, final MetadataField field) {
    switch (field.getType()) {
      case BOOLEAN: {
        if (value instanceof Boolean) {
          return value;
        }
        final String stringValue = value.toString();
        if (StringUtils.isBlank(stringValue)) {
          return null;
        }
        return Boolean.parseBoolean(stringValue);
      }
      case DATE: {
        final SimpleDateFormat dateFormat = getSimpleDateFormatter(field.getPattern());
        try {
          final String date = (String) value;

          if (StringUtils.isBlank(date)) {
            return null;
          }

          return dateFormat.parse(date);
        } catch (final java.text.ParseException e) {
          logger.error("Not able to parse date {}: {}", value, e.getMessage());
          return null;
        }
      }
      case DURATION: {
        if (!(value instanceof String)) {
          logger.warn("The given value for duration can not be parsed.");
          return "";
        }

        final String duration = (String) value;
        final String[] durationParts = duration.split(":");
        if (durationParts.length < 3) {
          return null;
        }
        final long hours = Long.parseLong(durationParts[0]);
        final long minutes = Long.parseLong(durationParts[1]);
        final long seconds = Long.parseLong(durationParts[2]);

        final long returnValue = ((hours * 60 + minutes) * 60 + seconds) * 1000;

        return Long.toString(returnValue);
      }
      case ITERABLE_TEXT: {
        final JSONArray array = (JSONArray) value;
        if (array == null) {
          return null;
        }
        final String[] arrayOut = new String[array.size()];
        for (int i = 0; i < array.size(); i++) {
          arrayOut[i] = (String) array.get(i);
        }
        return Arrays.asList(arrayOut);
      }
      case MIXED_TEXT: {
        final JSONParser parser = new JSONParser();
        final JSONArray array;
        if (value instanceof String) {
          try {
            array = (JSONArray) parser.parse((String) value);
          } catch (final ParseException e) {
            throw new IllegalArgumentException("Unable to parse Mixed Iterable value into a JSONArray:", e);
          }
        } else {
          array = (JSONArray) value;
        }

        if (array == null) {
          return new ArrayList<>();
        }
        final String[] arrayOut = new String[array.size()];
        for (int i = 0; i < array.size(); i++) {
          arrayOut[i] = (String) array.get(i);
        }
        return Arrays.asList(arrayOut);
      }
      case TEXT:
      case TEXT_LONG:
      case ORDERED_TEXT: {
        if (value == null) {
          return "";
        }
        if (!(value instanceof String)) {
          logger.warn("Value cannot be parsed as String. Expecting type 'String', but received type '{}'.",
              value.getClass().getName());
          return null;
        }
        return value;
      }
      case LONG: {
        if (!(value instanceof String)) {
          logger.warn("The given value for Long can not be parsed.");
          return 0L;
        }
        final String longString = (String) value;
        return Long.parseLong(longString);
      }
      case START_DATE:
      case START_TIME: {
        final String date = (String) value;

        if (StringUtils.isBlank(date)) {
          return "";
        }

        try {
          final SimpleDateFormat dateFormat = getSimpleDateFormatter(field.getPattern());
          dateFormat.parse(date);
        } catch (final java.text.ParseException e) {
          logger.error("Not able to parse date string {}: {}", value, getMessage(e));
          return null;
        }

        return date;
      }
      default:
        throw new IllegalArgumentException("invalid field type '" + field.getType() + "'");
    }
  }

  public static JsonObject fieldToJson(final MetadataField f, final boolean withOrderedText,
      final boolean withListprovider) {
    Objects.requireNonNull(f);

    JsonObject json = new JsonObject();

    json.addProperty(JSON_KEY_ID, safeString(f.getOutputID()));
    json.addProperty(JSON_KEY_LABEL, safeString(f.getLabel()));
    json.add(JSON_KEY_VALUE, valueToJson(f.getValue(), f.getType(), f.getPattern()));
    json.addProperty(JSON_KEY_TYPE, safeString(jsonType(f, withOrderedText).toString().toLowerCase()));
    json.addProperty(JSON_KEY_READONLY, f.isReadOnly());
    json.addProperty(JSON_KEY_REQUIRED, f.isRequired());

    if (f.getCollection() != null) {
      json.add(JSON_KEY_COLLECTION, mapToJson(f.getCollection()));
    } else if (f.getCollectionID() != null) {
      json.addProperty(JSON_KEY_COLLECTION, f.getCollectionID());
    }

    if (f.isTranslatable() != null) {
      json.addProperty(JSON_KEY_TRANSLATABLE, f.isTranslatable());
    }
    if (f.getDelimiter() != null) {
      json.addProperty(JSON_KEY_DELIMITER, f.getDelimiter());
    }
    if (f.hasDifferentValues() != null) {
      json.addProperty(JSON_KEY_DIFFERENT_VALUES, f.hasDifferentValues());
    }

    if (f.getListprovider() != null && withListprovider) {
      json.addProperty(JSON_KEY_LISTPROVIDER, f.getListprovider());
    }

    return json;
  }

  public static String safeString(Object input) {
    return input != null ? input.toString() : "";
  }

  public static MetadataField copyWithDifferentJsonValue(final MetadataField t, final String v) {
    final MetadataField copy = new MetadataField(t);
    copy.setValue(valueFromJson(v, copy));
    return copy;
  }

  public static JsonArray collectionToJson(final DublinCoreMetadataCollection collection,
      final boolean withOrderedText, final boolean withListprovider) {
    JsonArray jsonArray = new JsonArray();
    for (MetadataField field : collection.getFields()) {
      JsonObject fieldJson = fieldToJson(field, withOrderedText, withListprovider);
      jsonArray.add(fieldJson);
    }
    return jsonArray;
  }

  public static JSONArray extractSingleCollectionfromListJson(JSONArray json) {
    if (json == null || json.size() != 1) {
      throw new IllegalArgumentException("Input has to be a JSONArray with one entry");
    }

    return (JSONArray) ((JSONObject) json.get(0)).get(KEY_METADATA_FIELDS);
  }

  public static void fillCollectionFromJson(final DublinCoreMetadataCollection collection, final Object json) {
    if (!(json instanceof  JSONArray)) {
      throw new IllegalArgumentException("couldn't fill metadata collection, didn't get an array");
    }

    final JSONArray metadataJson = (JSONArray) json;
    for (final JSONObject item : (Iterable<JSONObject>) metadataJson) {
      final String fieldId = (String) item.get(KEY_METADATA_ID);

      if (fieldId == null) {
        continue;
      }
      final Object value = item.get(KEY_METADATA_VALUE);
      if (value == null) {
        continue;
      }

      final MetadataField target = collection.getOutputFields().get(fieldId);
      if (target == null) {
        continue;
      }

      final Object o = valueFromJson(value, target);
      target.setValue(o);
    }
  }

  public static void fillListFromJson(final MetadataList metadataList, final JSONArray json) {
    for (final JSONObject item : (Iterable<JSONObject>) json) {
      final MediaPackageElementFlavor flavor = MediaPackageElementFlavor
              .parseFlavor((String) item.get(KEY_METADATA_FLAVOR));
      final String title = (String) item.get(KEY_METADATA_TITLE);
      if (title == null) {
        continue;
      }

      final JSONArray value = (JSONArray) item.get(KEY_METADATA_FIELDS);
      if (value == null) {
        continue;
      }

      final DublinCoreMetadataCollection collection = metadataList.getMetadataByFlavor(flavor.toString());
      if (collection == null) {
        continue;
      }
      MetadataJson.fillCollectionFromJson(collection, value);
    }
  }

  public static JsonArray listToJson(final MetadataList metadataList, final boolean withOrderedText,
      final boolean withListprovider) {
    JsonArray catalogs = new JsonArray();

    for (Map.Entry<String, MetadataList.TitledMetadataCollection> metadata
        : metadataList.getMetadataList().entrySet()) {
      JsonObject catalogJson = new JsonObject();

      DublinCoreMetadataCollection metadataCollection = metadata.getValue().getCollection();

      if (!MetadataList.Locked.NONE.equals(metadataList.getLocked())) {
        catalogJson.addProperty(KEY_METADATA_LOCKED, metadataList.getLocked().getValue());
        metadataCollection = metadataCollection.readOnlyCopy();
      }

      catalogJson.addProperty(KEY_METADATA_FLAVOR, metadata.getKey());
      catalogJson.addProperty(KEY_METADATA_TITLE, metadata.getValue().getTitle());
      catalogJson.add(KEY_METADATA_FIELDS, collectionToJson(metadataCollection, withOrderedText, withListprovider));

      catalogs.add(catalogJson);
    }

    return catalogs;
  }
}