ColumnView.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2019 wcm.io
 * %%
 * Licensed under the Apache 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://www.apache.org/licenses/LICENSE-2.0
 *
 * 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.
 * #L%
 */
package io.wcm.wcm.ui.granite.components.pathfield;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.annotation.PostConstruct;
import javax.servlet.ServletException;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.api.wrappers.CompositeValueMap;
import org.apache.sling.api.wrappers.ValueMapDecorator;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.osgi.annotation.versioning.ProviderType;

import com.adobe.granite.ui.components.ComponentHelper;
import com.adobe.granite.ui.components.Config;
import com.adobe.granite.ui.components.ExpressionHelper;
import com.adobe.granite.ui.components.ds.DataSource;

import io.wcm.wcm.ui.granite.pathfield.impl.util.DummyPageContext;
import io.wcm.wcm.ui.granite.resource.GraniteUiSyntheticResource;

/**
 * Model for customized columnview Granite UI component for path field.
 * Logic derived from <code>/libs/granite/ui/components/coral/foundation/columnview/columnview.jsp</code>
 */
@Model(adaptables = SlingHttpServletRequest.class)
@ProviderType
public final class ColumnView {

  private static final String FALLBACK_ROOT_RESOURCE = "/";
  private static final long DEFAULT_PAGINATION_LIMIT = 1000;

  private static final String NN_DATASOURCE = "datasource";
  private static final String PN_SIZE = "size";
  private static final String PN_LIMIT = "limit";
  private static final String PN_ITEM_RESOURCE_TYPE = "itemResourceType";
  private static final String PN_SHOW_ROOT = "showRoot";
  private static final String PN_LOAD_ANCESTORS = "loadAncestors";
  private static final String PN_PATH = "path";

  @SlingObject
  private Resource componentResource;
  @SlingObject
  private SlingHttpServletRequest request;
  @SlingObject
  private SlingHttpServletResponse response;
  @SlingObject
  private ResourceResolver resourceResolver;

  private Resource currentResource;
  private final List<Column> columns = new ArrayList<>();

  @PostConstruct
  @SuppressWarnings("null")
  private void activate() {
    ComponentHelper cmp = new ComponentHelper(new DummyPageContext(request, response));
    Config cfg = cmp.getConfig();
    ExpressionHelper ex = cmp.getExpressionHelper();

    Integer size = ex.get(cfg.get(PN_SIZE, String.class), Integer.class);
    Long limit = ex.get(cfg.get(PN_LIMIT, String.class), Long.class);
    String itemResourceType = cfg.get(PN_ITEM_RESOURCE_TYPE, String.class);
    boolean showRoot = cfg.get(PN_SHOW_ROOT, false);
    boolean loadAncestors = cfg.get(PN_LOAD_ANCESTORS, false);

    // make sure we always have a valid root resource
    Resource rootResource = resourceResolver.getResource(ex.getString(cfg.get("rootPath", FALLBACK_ROOT_RESOURCE)));
    if (rootResource == null) {
      rootResource = resourceResolver.getResource(FALLBACK_ROOT_RESOURCE);
    }

    // if current resource is invalid or not same or descendant of root resource, set it to root resource
    String path = ex.getString(cfg.get(PN_PATH, rootResource.getPath()));
    currentResource = resourceResolver.getResource(path);
    if (currentResource == null || !isSameResourceOrChild(rootResource, currentResource)) {
      currentResource = rootResource;
    }

    // generate column for root
    if (showRoot && (StringUtils.equals(currentResource.getPath(), rootResource.getPath()) || loadAncestors)) {
      columns.add(getRootColumn(rootResource, itemResourceType));
    }

    // generate columns for ancestors
    if (loadAncestors) {
      columns.addAll(getAncestorColumns(currentResource, rootResource));
    }

    // calculate total number of items to return
    // NOTE: i assume the logic was intended in a different way, using DEFAULT_PAGINATION_LIMIT as max cap and not
    // as minimum - but the logic in columnview.jsp is exactly like this
    long totalSize = DEFAULT_PAGINATION_LIMIT;
    if (limit != null) {
      totalSize = Math.max(totalSize, limit);
    }
    if (size != null) {
      totalSize = Math.max(totalSize, size);
    }

    // check if a limit value is defined for the data source
    Long limitFromDataSource = null;
    Resource datasourceResource = componentResource.getChild(NN_DATASOURCE);
    if (datasourceResource != null) {
      limitFromDataSource = ex.get(datasourceResource.getValueMap().get(PN_LIMIT, String.class), Long.class);
    }

    DataSource dataSource = null;
    if (size != null && size >= 20 && datasourceResource != null && limitFromDataSource != null) {
      // if a limit is configured for the data source or size is at least 20,
      // calculate a new limit for the data source and overwrite it (synthetic) in the data source definition
      long newLimit = limitFromDataSource + totalSize - size + 1;
      dataSource = getDataSource(cmp, currentResource, datasourceResource, newLimit);
    }
    else {
      dataSource = getDataSource(cmp, currentResource);
      if (size != null) {
        totalSize = size;
      }
    }

    // generate columns for items
    columns.add(getCurrentResourceColumn(dataSource, totalSize, currentResource, itemResourceType));
  }

  private boolean isSameResourceOrChild(Resource rootResource, Resource resource) {
    if (StringUtils.equals(rootResource.getPath(), resource.getPath())) {
      return true;
    }
    else {
      return StringUtils.startsWith(resource.getPath(), rootResource.getPath() + "/");
    }
  }

  public Resource getCurrentResource() {
    return this.currentResource;
  }

  public List<Column> getColumns() {
    return this.columns;
  }

  /**
   * Get data source to list children of given resource.
   * @param cmp Component helper
   * @param resource Given resource
   * @return Data source
   */
  private DataSource getDataSource(ComponentHelper cmp, Resource resource) {
    return getDataSource(cmp, resource, null, null);
  }


  /**
   * Get data source to list children of given resource.
   * @param cmp Component helper
   * @param resource Resource pointing to current path
   * @param dataSourceResource Data source resource
   * @param newLimit Set limit defined in data source to this new value
   * @return Data source
   */
  @SuppressWarnings("java:S112") // allow generic exception
  private DataSource getDataSource(@NotNull ComponentHelper cmp, @NotNull Resource resource,
      @Nullable Resource dataSourceResource, @Nullable Long newLimit) {
    try {
      /*
       * by default the path is read from request "path" parameter
       * here we overwrite it via a synthetic resource because the path may be overwritten by validation logic
       * to ensure the path is not beyond the configured root path
       */
      ValueMap overwriteProperties = new ValueMapDecorator(Map.of(PN_PATH, resource.getPath()));
      Resource resourceWrapper = GraniteUiSyntheticResource.wrapMerge(componentResource, overwriteProperties);

      if (dataSourceResource != null && newLimit != null) {
        // overwrite limit property in data source definition
        ValueMap overwriteDataSourceProperties = new ValueMapDecorator(Map.of(PN_LIMIT, newLimit));
        Resource dataSourceResourceWrapper = GraniteUiSyntheticResource.child(resourceWrapper, NN_DATASOURCE,
            dataSourceResource.getResourceType(),
            new CompositeValueMap(overwriteDataSourceProperties, dataSourceResource.getValueMap()));
        return cmp.asDataSource(dataSourceResourceWrapper, resourceWrapper);
      }
      else {
        return cmp.getItemDataSource(resourceWrapper);
      }
    }
    catch (ServletException | IOException ex) {
      throw new RuntimeException("Unable to get data source.", ex);
    }
  }

  /**
   * Generate column for data source items for current resource.
   * @param dataSource Data source
   * @param totalSize Size limit
   * @param currentResource Current resource
   * @param itemResourceType Item resource type
   * @return Column
   */
  private static Column getCurrentResourceColumn(DataSource dataSource, long totalSize,
      Resource currentResource, String itemResourceType) {

    Iterator<Resource> items = dataSource.iterator();

    boolean hasMore = false;
    List<Resource> list = new ArrayList<>();
    while (items.hasNext() && list.size() < totalSize) {
      list.add(items.next());
    }
    hasMore = items.hasNext();
    items = list.iterator();

    Column column = new Column()
        .isCurrentResource(true)
        .columnId(currentResource.getPath())
        .hasMore(hasMore)
        .metaElement(true);
    while (items.hasNext()) {
      Resource item = items.next();
      column.addItem(new ColumnItem(item)
          .resourceType(itemResourceType));
    }

    return column;
  }

  /**
   * Generate extra column representing the root resource.
   * @param rootResource Root resource
   * @param itemResourceType Item resource type
   * @return Column
   */
  private static Column getRootColumn(Resource rootResource, String itemResourceType) {
    /*
     * Put a special path for columnId to avoid having the same columnId with the next column to avoid breaking the contract of columnId.
     * The contract of columnId is that it should be a path of the current column, i.e. the path should be a path representing a parent.
     * e.g. When columnId = "/", then the column will show the children of this path, such as "/a", "/b".
     * So for showRoot scenario, if we want to show the item with path = "/", we need to generate the column having a columnId with value of the parent of "/".
     * Since the cannot have a parent of "/", then we decide to just use a special convention ("parentof:<path>") to indicate this.
     * Other component (e.g. `.granite-collection-navigator`) reading the columnId can then understand this convention and handle it accordingly.
     */
    String columnId = "parentof:" + rootResource.getPath();

    Column column = new Column()
        .columnId(columnId)
        .hasMore(false);
    column.addItem(new ColumnItem(rootResource)
        .resourceType(itemResourceType)
        .active(true));
    return column;
  }

  /**
   * Generate column for each ancestor.
   * @param currentResource Current resource
   * @param rootResource Root resource
   * @return Columns
   */
  private static List<Column> getAncestorColumns(Resource currentResource, Resource rootResource) {
    List<Column> columns = new ArrayList<>();
    List<Resource> ancestors = getAncestors(currentResource, rootResource);
    for (int i = 0; i < ancestors.size(); i++) {
      Resource r = ancestors.get(i);

      String activeId;
      if (i < ancestors.size() - 1) {
        activeId = ancestors.get(i + 1).getPath();
      }
      else {
        activeId = currentResource.getPath();
      }

      Column column = new Column()
          .columnId(r.getPath())
          .lazy(true)
          .activeId(activeId);
      columns.add(column);
    }
    return columns;
  }

  /**
   * Returns the ancestors of the current resource (inclusive) up to the root.
   * The result is ordered with the root as the first item.
   */
  private static List<Resource> getAncestors(Resource currentResource, Resource rootResource) {
    List<Resource> results = new ArrayList<>();

    if (currentResource == null || rootResource == null || StringUtils.equals(currentResource.getPath(), rootResource.getPath())) {
      return results;
    }

    Resource parent = currentResource.getParent();
    while (parent != null) {
      results.add(0, parent);
      if (parent.getPath().equals(rootResource.getPath())) {
        break;
      }
      parent = parent.getParent();
    }
    return results;
  }

}