DublinCoreMetadataUtil.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.index.service.catalog.adapter;

import org.opencastproject.mediapackage.EName;
import org.opencastproject.metadata.dublincore.DCMIPeriod;
import org.opencastproject.metadata.dublincore.DublinCore;
import org.opencastproject.metadata.dublincore.DublinCoreCatalog;
import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
import org.opencastproject.metadata.dublincore.DublinCoreValue;
import org.opencastproject.metadata.dublincore.EncodingSchemeUtils;
import org.opencastproject.metadata.dublincore.MetadataField;
import org.opencastproject.metadata.dublincore.MetadataField.Type;
import org.opencastproject.metadata.dublincore.Precision;

import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;

/**
 * @author bbru
 *
 */
public final class DublinCoreMetadataUtil {

  private static final Logger logger = LoggerFactory.getLogger(DublinCoreMetadataUtil.class);

  private DublinCoreMetadataUtil() {
  }

  /**
   * Update a {@link DublinCoreCatalog} with the values contained within a {@link DublinCoreMetadataCollection}
   *
   * @param dc
   *          The {@link DublinCoreCatalog} to update the values within.
   * @param metadata
   *          The {@link DublinCoreMetadataCollection} data definitions and values to update the catalog with.
   */
  public static void updateDublincoreCatalog(DublinCoreCatalog dc, DublinCoreMetadataCollection metadata) {
    for (MetadataField field : metadata.getOutputFields().values()) {
      // Check this regardless of whether the field was actually touched by the caller: an untouched required
      // field can still be blank, e.g. because it uses its default value or was never submitted at all.
      if (field.isRequired() && isBlankValue(field.getValue())) {
        throw new IllegalArgumentException(String.format(
                "The event metadata field with id '%s' and the metadata type '%s' is required and can not be empty!.",
                field.getInputID(), field.getType()));
      }
      if (field.isUpdated() && field.getValue() != null) {
        final String namespace = field.getNamespace() == null ? DublinCore.TERMS_NS_URI : field.getNamespace();
        final EName ename = new EName(namespace, field.getInputID());
        if (field.getType() == MetadataField.Type.START_DATE) {
          setStartDate(dc, field, ename);
        } else if (field.getType() == MetadataField.Type.DURATION) {
          // WARN: the duration change assumes the catalog's start date is already up to date.
          setDuration(dc, field, ename);
        } else if (field.getType() == Type.DATE) {
          // Skip over metadata field tagged with key "created".
          // DC created should only be modified by changing the start date, see MH-12250
          if (! DublinCore.PROPERTY_CREATED.equals(ename)) {
            setDate(dc, field, ename);
          }
        } else if (field.getType() == MetadataField.Type.MIXED_TEXT || field.getType() == Type.ITERABLE_TEXT) {
          setIterableString(dc, field, ename);
        } else {
          dc.set(ename, field.getValue().toString());
        }
      }
    }
  }

  /**
   * @param value
   *          The value of a {@link MetadataField}.
   * @return True if the value is missing, i.e. null, a blank string or an empty collection of values.
   */
  private static boolean isBlankValue(final Object value) {
    if (value == null) {
      return true;
    }
    if (value instanceof String) {
      return StringUtils.isBlank((String) value);
    }
    if (value instanceof Collection) {
      return ((Collection<?>) value).isEmpty();
    }
    return false;
  }

  /**
   * Set the value of an iterable string (each element as an separate entry)
   *
   * @param dc    The dublin core catalog to add the iterable string values to (or remove it if empty)
   * @param field The {@link MetadataField} with the values to update.
   * @param ename The {@link EName} of the property in the {@link DublinCoreCatalog} to update.
   */
  private static void setIterableString(DublinCoreCatalog dc, MetadataField field, final EName ename) {
    if (field.getValue() != null) {
      dc.remove(ename);
      if (field.getValue() instanceof String) {
        String valueString = (String) field.getValue();
        dc.set(ename, valueString);
      } else {
        @SuppressWarnings("unchecked")
        Iterable<String> valueIterable = (Iterable<String>) field.getValue();
        for (String valueString : valueIterable) {
          if (StringUtils.isNotBlank(valueString)) {
            dc.add(ename, valueString);
          }
        }
      }
    }
  }

  private static Optional<DCMIPeriod> getPeriodFromCatalog(DublinCoreCatalog dc, EName ename) {
    List<DublinCoreValue> periodStrings = dc.get(ename);
    Optional<DCMIPeriod> p = Optional.<DCMIPeriod> empty();
    for (DublinCoreValue periodString : periodStrings) {
      p = Optional.ofNullable(EncodingSchemeUtils.decodePeriod(periodString.getValue()));
    }
    if (p.isEmpty()) {
      // fall back to created date with zero duration
      // opencast keep the event start date and created date in sync
      // if the start date isn't set, we can grab the created value
      DublinCoreValue createdDCValue = dc.getFirstVal(DublinCore.PROPERTY_CREATED);
      if (createdDCValue != null) {
        Date createdDate = EncodingSchemeUtils.decodeDate(createdDCValue.getValue());
        p = Optional.ofNullable(new DCMIPeriod(createdDate, createdDate));
      }
    }
    return p;
  }

  /**
   * @param period
   *          The dublin core period for this event.
   * @return The milliseconds between the start and end time for this event.
   */
  static Long getDuration(Optional<DCMIPeriod> period) {
    Long duration = 0L;
    if (period.isPresent() && period.get().hasStart() && period.get().hasEnd()) {
      return period.get().getEnd().getTime() - period.get().getStart().getTime();
    }
    return duration;
  }

  /**
   * Sets a date object with the correct formatting into the dublin core catalog.
   *
   * @param dc
   *          The dublin core catalog to insert the date.
   * @param field
   *          The field with the date value and pattern ready to format.
   * @param ename
   *          The unique id for the dublin core property.
   */
  private static void setDate(DublinCoreCatalog dc, MetadataField field, EName ename) {
    if (field.getValue() instanceof Date && field.getPattern() == null) {
      throw new IllegalArgumentException("There needs to be a pattern property set for " + field.getInputID() + ":"
              + field.getOutputID() + ":" + field.getValue() + " metadata field to store and retrieve the result.");
    }
    if (field.getValue() instanceof Date) {
      SimpleDateFormat sdf = new SimpleDateFormat(field.getPattern());
      dc.set(ename, sdf.format((Date) field.getValue()));
    } else {
      dc.set(ename, field.getValue().toString());
    }
  }

  /**
   * Sets the start date in a dublin core catalog to the right value and keeps the start time and duration the same.
   *
   * @param dc
   *          The dublin core catalog to adjust
   * @param field
   *          The metadata field that contains the start date.
   * @param ename
   *          The EName in the catalog to identify the property that has the dublin core period.
   */
  static void setStartDate(DublinCoreCatalog dc, MetadataField field, EName ename) {
    if (field.getValue() == null
            || (field.getValue() instanceof String && StringUtils.isBlank(field.getValue().toString()))) {
      logger.debug("No value was set for metadata field with dublin core id '{}' and json id '{}'", field.getInputID(),
              field.getOutputID());
      return;
    }
    try {
      // Get the current date
      SimpleDateFormat dateFormat = MetadataField.getSimpleDateFormatter(field.getPattern());
      Date startDate = dateFormat.parse((String) field.getValue());
      // Get the current period
      Optional<DCMIPeriod> period = getPeriodFromCatalog(dc, ename);
      // Get the current duration
      Long duration = getDuration(period);
      // Get the current end date based on new date and duration.
      DateTime endDate = new DateTime(startDate.getTime() + duration);
      dc.set(ename, EncodingSchemeUtils.encodePeriod(new DCMIPeriod(startDate, endDate.toDate()),
              Precision.Second));
      // ensure that DC created is start date, see MH-12250
      setDate(dc, field, DublinCore.PROPERTY_CREATED);
    } catch (ParseException e) {
      logger.error("Not able to parse date {} to update the dublin core because:", field.getValue(), e);
    }
  }

  /**
   * Gets the current hour, minute and second from a dublin core period if available.
   *
   * @param period
   *          The current period from dublin core.
   * @return A new DateTime with the current hour, minute and second.
   */
  private static DateTime getCurrentStartDateTime(Optional<DCMIPeriod> period) {
    DateTime currentStartTime = new DateTime();
    currentStartTime = currentStartTime.withZone(DateTimeZone.UTC);
    currentStartTime = currentStartTime.withYear(2001);
    currentStartTime = currentStartTime.withMonthOfYear(1);
    currentStartTime = currentStartTime.withDayOfMonth(1);
    currentStartTime = currentStartTime.withHourOfDay(0);
    currentStartTime = currentStartTime.withMinuteOfHour(0);
    currentStartTime = currentStartTime.withSecondOfMinute(0);

    if (period.isPresent() && period.get().hasStart()) {
      DateTime fromDC = new DateTime(period.get().getStart().getTime());
      fromDC = fromDC.withZone(DateTimeZone.UTC);
      currentStartTime = currentStartTime.withZone(DateTimeZone.UTC);
      currentStartTime = currentStartTime.withYear(fromDC.getYear());
      currentStartTime = currentStartTime.withMonthOfYear(fromDC.getMonthOfYear());
      currentStartTime = currentStartTime.withDayOfMonth(fromDC.getDayOfMonth());
      currentStartTime = currentStartTime.withHourOfDay(fromDC.getHourOfDay());
      currentStartTime = currentStartTime.withMinuteOfHour(fromDC.getMinuteOfHour());
      currentStartTime = currentStartTime.withSecondOfMinute(fromDC.getSecondOfMinute());
    }
    return currentStartTime;
  }

  /**
   * Sets the duration in a dublin core catalog to the right value and keeps the start date and start time the same.
   *
   * @param dc
   *          The dublin core catalog to adjust
   * @param field
   *          The metadata field that contains the duration.
   * @param ename
   *          The EName in the catalog to identify the property that has the dublin core period.
   */
  static void setDuration(DublinCoreCatalog dc, MetadataField field, EName ename) {
    if (field.getValue() == null) {
      logger.error("No value was set for metadata field with dublin core id '{}' and json id '{}'", field.getInputID(),
              field.getOutputID());
      return;
    }

    // Get the current period
    Optional<DCMIPeriod> period = getPeriodFromCatalog(dc, ename);
    // Get the current duration
    Long duration = 0L;
    try {
      duration = Long.parseLong(field.getValue().toString());
    } catch (NumberFormatException e) {
      logger.debug("Unable to parse the duration's value '{}' as a long value. Trying it as a period next.",
              field.getValue());
    }
    if (duration < 1L) {
      duration = getDuration(period);
    }
    // Get the current start date (WARN: this assumes any start time updates have already been performed)
    DateTime startDateTime = getCurrentStartDateTime(period);
    // Get the current end date based on new date and duration.
    DateTime endDate = new DateTime(startDateTime.toDate().getTime() + duration);
    dc.set(ename, EncodingSchemeUtils.encodePeriod(new DCMIPeriod(startDateTime.toDate(), endDate.toDate()),
            Precision.Second));
  }

  public static Map<String, MetadataField> getDublinCoreProperties(Dictionary<String, ?> configProperties) {

    final Map<String,Map<String, String>> allProperties = new HashMap<>();

    for (String configObject : Collections.list(configProperties.keys())) {
      String propertyName = getDublinCorePropertyName(configObject);
      String propertyKey = getDublinCorePropertyKey(configObject);

      if (propertyName != null && propertyKey != null) {
        Map<String,String> metadataFieldProperties = allProperties.computeIfAbsent(
                propertyName,
                key -> new HashMap<>());
        metadataFieldProperties.put(propertyKey, configProperties.get(configObject).toString());
      }
    }

    final Map<String, MetadataField> metadataFieldsMap = new TreeMap<>();
    for (Map<String, String> metadataFieldPropertiesMap : allProperties.values()) {
      MetadataField metadataField = MetadataField.createMetadataField(metadataFieldPropertiesMap);
      metadataFieldsMap.put(metadataField.getOutputID(), metadataField);
    }

    return metadataFieldsMap;
  }

  private static boolean isDublinCoreProperty(String propertyKey) {
    return !StringUtils.isBlank(propertyKey) && propertyKey.split("\\.").length == 3
            && propertyKey.split("\\.")[0].equalsIgnoreCase(MetadataField.CONFIG_PROPERTY_PREFIX);
  }

  static String getDublinCorePropertyName(String propertyKey) {
    if (isDublinCoreProperty(propertyKey)) {
      return propertyKey.split("\\.")[1];
    }
    return null;
  }

  static String getDublinCorePropertyKey(String propertyKey) {
    if (isDublinCoreProperty(propertyKey)) {
      return propertyKey.split("\\.")[2];
    }
    return null;
  }

}