TransferMetadataWorkflowOperationHandler.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.workflow.handler.workflow;

import org.opencastproject.job.api.JobContext;
import org.opencastproject.mediapackage.Catalog;
import org.opencastproject.mediapackage.EName;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.metadata.dublincore.DublinCoreCatalog;
import org.opencastproject.metadata.dublincore.DublinCoreUtil;
import org.opencastproject.metadata.dublincore.DublinCoreValue;
import org.opencastproject.util.XmlNamespaceContext;
import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
import org.opencastproject.workflow.api.ConfiguredTagsAndFlavors;
import org.opencastproject.workflow.api.WorkflowInstance;
import org.opencastproject.workflow.api.WorkflowOperationException;
import org.opencastproject.workflow.api.WorkflowOperationHandler;
import org.opencastproject.workflow.api.WorkflowOperationInstance;
import org.opencastproject.workflow.api.WorkflowOperationResult;
import org.opencastproject.workflow.api.WorkflowOperationResult.Action;
import org.opencastproject.workspace.api.Workspace;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;

/**
 * The workflow definition for handling "transfer-metadata" operations
 */
@Component(
    immediate = true,
    service = WorkflowOperationHandler.class,
    property = {
        "service.description=Transfer metadata fields between catalogs",
        "workflow.operation=transfer-metadata"
    }
)
public class TransferMetadataWorkflowOperationHandler extends AbstractWorkflowOperationHandler {

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

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.workflow.api.WorkflowOperationHandler#start(
   *      org.opencastproject.workflow.api.WorkflowInstance, JobContext)
   */
  @Override
  public WorkflowOperationResult start(final WorkflowInstance workflowInstance, JobContext context)
          throws WorkflowOperationException {
    logger.debug("Running transfer-metadata workflow operation");

    MediaPackage mediaPackage = workflowInstance.getMediaPackage();
    Configuration configuration = new Configuration(workflowInstance.getCurrentOperation());
    ConfiguredTagsAndFlavors tagsAndFlavors = getTagsAndFlavors(workflowInstance,
        AbstractWorkflowOperationHandler.Configuration.none, AbstractWorkflowOperationHandler.Configuration.one,
        AbstractWorkflowOperationHandler.Configuration.none, AbstractWorkflowOperationHandler.Configuration.one);
    configuration.setSourceFlavor(tagsAndFlavors.getSingleSrcFlavor());
    configuration.setTargetFlavor(tagsAndFlavors.getSingleTargetFlavor());

    // select source metadata catalog
    Metadata sourceMetadata;
    try {
      sourceMetadata = new Metadata(mediaPackage, configuration.sourceFlavor);
    } catch (Metadata.NoMetadataFoundException e) {
      logger.debug("No catalog with flavor {}. Skipping operation.", configuration.sourceFlavor);
      return createResult(mediaPackage, Action.SKIP);
    }
    Metadata targetMetadata;
    try {
      targetMetadata = new Metadata(mediaPackage, configuration.targetFlavor);
    } catch (Metadata.NoMetadataFoundException e) {
      throw new WorkflowOperationException(e);
    }

    // fail if the target exists and we did not configure to override it
    if (targetMetadata.dcCatalog.get(configuration.targetElement).size() > 0 && !configuration.force) {
      throw new WorkflowOperationException("The target metadata field already exists and forcing was not configured");
    }

    // either transfer all values or generate one field by joining all values.
    // all language information will get lost if we join the values.
    if (configuration.concatDelimiter == null) {
      final List<DublinCoreValue> values = sourceMetadata.dcCatalog.get(configuration.sourceElement);
      targetMetadata.dcCatalog.set(configuration.targetElement, values);
      logger.info("Transferred {} metadata elements", values.size());
    } else {
      final String value = sourceMetadata.dcCatalog.get(configuration.sourceElement)
              .stream()
              .map(DublinCoreValue::getValue)
              .collect(Collectors.joining(configuration.concatDelimiter));
      targetMetadata.dcCatalog.set(configuration.targetElement, value);
      logger.info("Transferred concatenated metadata element(s)");
    }

    // set prefix used for the target element's namespace
    if (configuration.targetPrefix != null) {
      final XmlNamespaceContext namespace = XmlNamespaceContext.mk(configuration.targetPrefix,
              configuration.targetElement.getNamespaceURI());
      targetMetadata.dcCatalog.addBindings(namespace);
    }

    try {
      targetMetadata.save();
    } catch (IOException e) {
      throw new WorkflowOperationException("Error saving updated metadata catalog", e);
    }

    return createResult(mediaPackage, Action.CONTINUE);
  }

  private class Metadata {
    private final MediaPackage mediaPackage;
    private final Catalog catalog;
    private final DublinCoreCatalog dcCatalog;

    /**
     * Initialize Metadata object by selecting a specified metadata catalog from a given media package.
     *
     * @param mediaPackage
     *          Media package to work on
     * @param flavor
     *          Flavor specifying the catalog to select
     * @throws NoMetadataFoundException
     *          Could not find catalog with given flavor
     */
    Metadata(final MediaPackage mediaPackage, final MediaPackageElementFlavor flavor) throws NoMetadataFoundException {
      this.mediaPackage = mediaPackage;

      Catalog[] catalogs = mediaPackage.getCatalogs(flavor);
      if (catalogs.length < 1) {
        throw new NoMetadataFoundException();
      }

      if (catalogs.length > 1) {
        logger.warn("More than one metadata catalog of flavor {} found; using the first one", flavor);
      }
      catalog = catalogs[0];
      dcCatalog = DublinCoreUtil.loadDublinCore(workspace, catalog);
    }

    /**
     * Save the modified metadata and update the media package to refer to the new catalog.
     *
     * @throws IOException
     *          Error storing the metadata catalog
     */
    private void save() throws IOException {
      String filename = FilenameUtils.getName(catalog.getURI().toString());
      InputStream stream = IOUtils.toInputStream(dcCatalog.toXmlString(), StandardCharsets.UTF_8);
      catalog.setURI(workspace.put(mediaPackage.getIdentifier().toString(), catalog.getIdentifier(), filename, stream));
      catalog.setChecksum(null);
    }

    /**
     * Exception to throw if no catalog with a specified flavor exists in the media package.
     */
    class NoMetadataFoundException extends Exception {
    }
  }

  /**
   * Storage for this operation's configuration
   */
  private class Configuration {
    private  MediaPackageElementFlavor sourceFlavor;
    private  MediaPackageElementFlavor targetFlavor;
    private final EName sourceElement;
    private final EName targetElement;
    private final boolean force;
    private final String concatDelimiter;
    private final String targetPrefix;

    /**
     * Load configuration from given operation.
     *
     * @param operation
     *          Operation to load configuration from
     * @throws IllegalArgumentException
     *          Invalid configuration
     */
    Configuration(WorkflowOperationInstance operation)
            throws IllegalArgumentException {
      // required
      // will throw IllegalArgumentException if not defined
      sourceFlavor = MediaPackageElementFlavor.parseFlavor(operation.getConfiguration("source-flavor"));
      targetFlavor = MediaPackageElementFlavor.parseFlavor(operation.getConfiguration("target-flavor"));
      sourceElement = EName.fromString(operation.getConfiguration("source-element"));
      targetElement = EName.fromString(operation.getConfiguration("target-element"));

      // optional arguments
      force = BooleanUtils.toBoolean(operation.getConfiguration("force"));
      concatDelimiter = operation.getConfiguration("concat");
      targetPrefix = operation.getConfiguration("target-prefix");
    }

    void setSourceFlavor(MediaPackageElementFlavor flavor) {
      this.sourceFlavor = flavor;
    }

    void setTargetFlavor(MediaPackageElementFlavor flavor) {
      this.targetFlavor = flavor;
    }
  }

  /** OSGi callback to inject the workspace */
  @Reference
  public void setWorkspace(Workspace workspace) {
    this.workspace = workspace;
  }

}