Package com.google.enterprise.connector.otex

Source Code of com.google.enterprise.connector.otex.LivelinkConnector$PropertyValidator

// Copyright 2007 Google Inc.
//
// 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.

package com.google.enterprise.connector.otex;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.enterprise.connector.otex.client.Client;
import com.google.enterprise.connector.otex.client.ClientFactory;
import com.google.enterprise.connector.otex.client.ClientValue;
import com.google.enterprise.connector.spi.AuthenticationManager;
import com.google.enterprise.connector.spi.AuthorizationManager;
import com.google.enterprise.connector.spi.Connector;
import com.google.enterprise.connector.spi.RepositoryException;
import com.google.enterprise.connector.spi.RepositoryLoginException;
import com.google.enterprise.connector.spi.Session;
import com.google.enterprise.connector.spi.SpiConstants;
import com.google.enterprise.connector.spi.SpiConstants.FeedType;

import java.text.Format;
import java.text.MessageFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class LivelinkConnector implements Connector {
  /** The logger for this class. */
  private static final Logger LOGGER =
      Logger.getLogger(LivelinkConnector.class.getName());

  /** A value type of string in a configuration map. */
  private static final int STRING = 0;

  /**
   * A value type of <code>MessageFormat</code> pattern in a
   * configuration map.
   */
  private static final int PATTERN = 1;

  /** A value type of list of strings in a configuration map. */
  private static final int LIST_OF_STRINGS = 2;

  /**
   * A pattern describing a comma-separated list of unsigned integers,
   * with optional whitespace and brace delimiters. The list may be
   * empty or consist entirely of whitespace. Empty entries (with no
   * digits) are not allowed. Every comma must have one or more digits
   * on both sides of it, in other words, leading, trailing, or
   * multiple consecutive commas are not allowed. The delimiters do
   * not need to appear in a matching pair.
   */
  private static final Pattern LIST_OF_INTEGERS_PATTERN =
      Pattern.compile("\\s*\\{?\\s*(?:\\d+\\s*(?:,\\s*\\d+\\s*)*)?\\}?\\s*");

  /**
   * A pattern describing a comma-separated list of strings, with
   * optional whitespace and brace delimiters. The list may be empty
   * or consist entirely of whitespace. The delimiters do not need
   * to appear in a matching pair.
   */
  private static final Pattern LIST_OF_STRINGS_PATTERN =
      Pattern.compile("\\s*\\{?\\s*([^{}]*?)\\s*\\}?\\s*");

  /**
   * Sanitizes a list of integers by checking for invalid characters
   * in the string and removing all whitespace.
   *
   * @param list a list of comma-separated integers
   * @return the list with all whitespace characters removed
   * @throws IllegalArgumentException if the list contains anything
   * but whitespace, digits, or commas
   */
  @VisibleForTesting
  static String sanitizeListOfIntegers(String list) {
    if (LIST_OF_INTEGERS_PATTERN.matcher(list).matches())
      return list.replaceAll("[\\s{}]", "");
    else {
      RuntimeException e = new IllegalArgumentException(list);
      LOGGER.log(Level.SEVERE, e.getMessage(), e);
      throw e;
    }
  }

  /**
   * Sanitizes a list of strings by checking for invalid characters
   * in the string and removing all surrounding whitespace. Leading
   * and trailing whitespace, and whitespace around the commas is
   * removed. Any whitespace inside of non-whitespace strings is
   * kept.
   *
   * @param list a list of comma-separated strings
   * @return the list with surrounding whitespace characters removed
   */
  @VisibleForTesting
  static String sanitizeListOfStrings(String list) {
    Matcher matcher = LIST_OF_STRINGS_PATTERN.matcher(list);
    if (matcher.matches()) {
      String trimmed = matcher.group(1);
      return trimmed.replaceAll("\\s*,\\s*", ",");
    } else {
      RuntimeException e = new IllegalArgumentException(list);
      LOGGER.log(Level.SEVERE, e.getMessage(), e);
      throw e;
    }
  }

  /**
   * Simple wrapper for a validate method.
   */
  private static class PropertyValidator {
    void validate() throws IllegalArgumentException {
    }
  }

  /**
   * The client factory used to configure and instantiate the
   * client facade.
   */
  private final ClientFactory clientFactory;

  /**
   * The client factory used to configure and instantiate the
   * client facade when a client is needed for authentication;
   * may remain null if no custom authentication configuration
   * is provided.
   */
  private ClientFactory authenticationClientFactory;

  /** Enables or disables HTTP tunneling. */
  private boolean useHttpTunneling;

  /**
   * The flag indicating that this connector has a separate set of
   * authentication parameters.
   */
  private boolean useSeparateAuthentication;

  /* The subtypes that do not support FetchVersion. */
  private List<Integer> unsupportedFetchVersionTypes;

  /** The base display URL for the search results. */
  private String displayUrl;

  /**
   * The map from subtypes to relative display URL pattern for the
   * search results.
   */
  private Map<Integer, Object> displayPatterns = new HashMap<Integer, Object>();

  /** The map from subtypes to Livelink display action names. */
  private Map<Integer, Object> displayActions = new HashMap<Integer, Object>();

  /**
   * The non-grouping number format for IDs and subtypes in the
   * display URL. This is a non-static field to avoid extra thread
   * contention, and try to balance object creation and
   * synchronization.
   */
  private final NumberFormat nonGroupingNumberFormat;

  /** The database server type, either "MSSQL" or "Oracle". */
  private String servtype;

  /**
   * Whether the database server is SQL Server, or if it isn't,
   * it's Oracle.
   */
  private boolean isSqlServer;

  /** The SQL queries resource bundle wrapper. */
  private SqlQueries sqlQueries;

  /** The Traversal username.  */
  private String traversalUsername;

  /** The domain used to impersonate users. */
  private String domainName;

  /** The node types that you want to exclude from traversal. */
  private String excludedNodeTypes;

  /** The volume types that you want to exclude from traversal. */
  private String excludedVolumeTypes;

  /** The node IDs that you want to exclude from traversal. */
  private String excludedLocationNodes;

  /** The node IDs that you want to include in the traversal. */
  private String includedLocationNodes;

  /** The map from subtypes to ExtendedData assoc keys. */
  private final Map<Integer, Object> extendedDataKeys =
      new HashMap<Integer, Object>();

  /** The list of ObjectInfo assoc keys. */
  private String[] objectInfoKeys = null;

  /** The list of VersionInfo assoc keys. */
  private String[] versionInfoKeys = null;

  /** The set of Categories to include. */
  private HashSet<Object> includedCategoryIds = null;

  /** The set of Categories to exclude. */
  private HashSet<Object> excludedCategoryIds = null;

  /** The additional select expressions for the main query. */
  private Map<String, String> selectExpressions;

  /** The set of Subtypes for which we index hidden items. */
  private HashSet<Object> hiddenItemsSubtypes = null;

  /** The <code>ContentHandler</code> implementation class. */
  private ContentHandler contentHandler;

  /** True if using content feeds, false for content url feeds. */
  private FeedType feedType;

  /** The earliest modification date that should be indexed. */
  private Date startDate = null;

  private String groupFeedSchedule;

  /**
   * The configuration for checking for a time warp (that is,
   * incorrect results) in the candidates query results. The default
   * is {@code -1} to disable the check.
   *
   * @see #setCandidatesTimeWarpFuzz
   */
  private int candidatesTimeWarpFuzz = -1;

  /** Whether to track deleted items, sending delete notification to GSA. */
  private boolean trackDeletedItems = true;

  /** Whether to use DTreeAncestors table instead of a slower method. */
  private boolean useDTreeAncestors;

  /** The <code>Genealogist</code> implementation class name. */
  private String genealogist;

  /** The initial size of the <code>Genealogist</code> ancestor node caches. */
  private int genealogistMinCacheSize;

  /** The maximum of the <code>Genealogist</code> ancestor node caches. */
  private int genealogistMaxCacheSize;

  /** An additional SQL WHERE clause condition. */
  private String sqlWhereCondition;

  /**
   * The option to include the domain name for authentication and
   * authorization.
   */
  private DomainAndName domainAndName;

  /** The Windows domain name to be used for user authentication. */
  private String windowsDomain;

  /** The Livelink traverser client username. */
  private String username;

  /** The enableNtlm flag. */
  private boolean enableNtlm;

  /** The httpUsername. */
  private String httpUsername;

  /** The httpPassword. */
  private String httpPassword;

  /** The Livelink public content client username. */
  private String publicContentUsername;

  /** The Livelink public content client display URL. */
  private String publicContentDisplayUrl;

  /** The authentication manager to use. */
  private AuthenticationManager authenticationManager;

  /** The authorization manager to use. */
  private AuthorizationManager authorizationManager;

  /** The Livelink public content authorization manager to use. */
  private LivelinkAuthorizationManager publicContentAuthorizationManager;

  /** Lowercase usernames hack. */
  private boolean tryLowercaseUsernames;

  /** The global namespace. */
  private String globalNamespace;

  /** The local namespace. */
  private String localNamespace;

  private String googleFeedHost;

  private String googleConnectorName;

  /** A list of PropertyValidator instances. */
  private List<PropertyValidator> propertyValidators =
      new ArrayList<PropertyValidator>();

  /**
   * Constructs a connector instance for a specific Livelink
   * repository, using the default client factory class.
   */
  LivelinkConnector() {
    this("com.google.enterprise.connector.otex.client.lapi." +
        "LapiClientFactory");
  }

  /**
   * Constructs a connector instance for a specific Livelink
   * repository, using the specified client factory class.
   */
  /* XXX: Should we throw the relevant checked exceptions instead? */
  LivelinkConnector(String clientFactoryClass) {
    this(newClientFactory(clientFactoryClass));
  }

  LivelinkConnector(ClientFactory clientFactory) {
    this.clientFactory = clientFactory;
    nonGroupingNumberFormat = NumberFormat.getIntegerInstance();
    nonGroupingNumberFormat.setGroupingUsed(false);
  }

  private static ClientFactory newClientFactory(String clientFactoryClass) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("NEW INSTANCE: " + clientFactoryClass);
    try {
      return (ClientFactory)
          Class.forName(clientFactoryClass).newInstance();
    } catch (Exception e) {
      LOGGER.log(Level.SEVERE, e.getMessage(), e);
      throw new RuntimeException(e); // XXX: More specific exception?
    }
  }

  /**
   * Sets the host name or IP address of the server.
   *
   * @param server the host name or IP address of the server
   */
  public void setServer(final String server) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("SERVER: " + server);
    clientFactory.setServer(server);
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          if (server == null || server.trim().length() == 0) {
            throw new ConfigurationException(
                "A host name or IP address is required.",
                "missingHost", null);
          }
        }
      });
  }

  /**
   * Sets the Livelink server port. This should be the LAPI
   * port, unless HTTP tunneling is used, in which case it
   * should be the HTTP port.
   *
   * @param port the port number
   */
  public void setPort(final String port) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("PORT: " + port);
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          clientFactory.setPort(Integer.parseInt(port));
        }
      });
  }

  /**
   * Sets the database connection to use. This property is optional.
   *
   * @param connection the database name
   */
  public void setConnection(String connection) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("CONNECTION: " + connection);
    clientFactory.setConnection(connection);
  }

  /**
   * Sets the Livelink username.
   *
   * @param username the username
   */
  public void setUsername(String username) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("USERNAME: " + username);
    clientFactory.setUsername(username);
    this.username = username;
  }

  /**
   * Gets the Livelink username.
   *
   * @return the username
   */
  String getUsername() {
    return username;
  }

  /**
   * Sets the Livelink password.
   *
   * @param password the password
   */
  public void setPassword(String password) {
    LOGGER.config("PASSWORD: [...]");
    clientFactory.setPassword(password);
  }

  /**
   * Sets a flag indicating that HTTP tunneling is enabled.
   *
   * @param useHttpTunneling <code>true</code> if HTTP tunneling is enabled
   */
  public void setUseHttpTunneling(boolean useHttpTunneling) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("USE HTTP TUNNELING: " + useHttpTunneling);
    this.useHttpTunneling = useHttpTunneling;
  }

  /**
   * Sets the Livelink CGI path to use when tunneling LAPI
   * requests through the Livelink web server. If a proxy
   * server is used, this value must be the complete URL to the
   * Livelink CGI (e.g.,
   * http://host:port/Livelink/livelink). If no proxy server is
   * being used, only the path needs to be provided (e.g.,
   * /Livelink/livelink).
   *
   * @param livelinkCgi the path or URL to the Livelink CGI
   */
  public void setLivelinkCgi(String livelinkCgi) {
    // FIXME: This can be non-empty if HTTP tunneling is disabled
    // (init handles that case), but if HTTP tunneling is enabled,
    // then this must set, and we aren't validating that. The
    // LivelinkConnectorType will skip validation if the section
    // was just expanded, so if we're here, then we should make
    // sure that livelinkCgi is non-empty if useHttpTunneling is
    // true.
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("LIVELINK CGI: " + livelinkCgi);
    clientFactory.setLivelinkCgi(livelinkCgi);
  }

  /**
   * Sets the HTTPS property. Set to true to use HTTPS when
   * tunneling through a web server. If this property is set to
   * true, the LivelinkCGI property is set, and Livelink Secure
   * Connect is not installed, connections will fail.
   *
   * @param useHttps true if HTTPS should be used; false otherwise
   */
  public void setHttps(boolean useHttps) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("HTTPS: " + useHttps);
    clientFactory.setHttps(useHttps);
  }

  /**
   * Sets the EnableNTLM property.
   *
   * @param enableNtlm true if the NTLM subsystem should be used
   */
  public void setEnableNtlm(final boolean enableNtlm) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("ENABLE NTLM: " + enableNtlm);
    clientFactory.setEnableNtlm(enableNtlm);
    this.enableNtlm = enableNtlm;
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          if (!useHttpTunneling)
            return;
          if (enableNtlm &&
              ((httpUsername == null ||
                  httpUsername.trim().length() == 0) ||
                  (httpPassword == null ||
                      httpPassword.trim().length() == 0))) {
            throw new ConfigurationException(
                "An HTTP username and HTTP password are required " +
                "when NTLM authentication is enabled.",
                "missingNtlmCredentials", null);
          }
        }
      });
  }

  /**
   * Sets a username to be used for HTTP authentication when
   * accessing Livelink through a web server.
   *
   * @param httpUsername the username
   */
  public void setHttpUsername(String httpUsername) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("HTTP USERNAME: " + httpUsername);
    clientFactory.setHttpUsername(httpUsername);
    this.httpUsername = httpUsername;
  }

  /**
   * Sets a password to be used for HTTP authentication when
   * accessing Livelink through a web server.
   *
   * @param httpPassword the password
   */
  public void setHttpPassword(String httpPassword) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("HTTP PASSWORD: [...]");
    clientFactory.setHttpPassword(httpPassword);
    this.httpPassword = httpPassword;
  }

  /**
   * Sets the VerifyServer property. This property may be used
   * with {@link #setHttps} and {@link #setCaRootCerts}.
   *
   * @param verifyServer true if the server certificate should
   * be verified
   */
  public void setVerifyServer(boolean verifyServer) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("VERIFY SERVER: " + verifyServer);
    clientFactory.setVerifyServer(verifyServer);
  }

  /**
   * Sets the CaRootCerts property.
   *
   * @param caRootCerts a list of certificate authority root certificates
   */
  public void setCaRootCerts(List<String> caRootCerts) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("CA ROOT CERTS: " + caRootCerts);
    clientFactory.setCaRootCerts(caRootCerts);
  }

  /**
   * Sets the Livelink domain name. This property is optional.
   *
   * @param domainName the domain name
   */
  public void setDomainName(String domainName) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("DOMAIN NAME: " + domainName);
    this.domainName = domainName;
    clientFactory.setDomainName(domainName);
  }

  /**
   * Gets the Livelink domain name. This property is optional.
   *
   * @return the domain name
   */
  String getDomainName() {
    return domainName;
  }

  /**
   * Sets the base display URL for the search results, e.g.,
   * "http://myhostname/Livelink/livelink.exe".
   *
   * @param displayUrl the display URL prefix
   */
  public void setDisplayUrl(String displayUrl) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("DISPLAY URL: " + displayUrl);
    this.displayUrl = displayUrl;
  }

  /**
   * Gets the base display URL for the search results.
   *
   * @return the display URL prefix
   */
  String getDisplayUrl() {
    return displayUrl;
  }

  /**
   * Sets the relative display URL for each subtype. The map
   * contains keys that consist of comma-separated subtype integers,
   * or the special string "default". These are mapped to a {@link
   * java.text.MessageFormat MessageFormat} pattern. The pattern
   * arguments are:
   * <dl>
   * <dt> 0
   * <dd> The object ID.
   * <dt> 1
   * <dd> The volume ID.
   * <dt> 2
   * <dd> The subtype.
   * <dt> 3
   * <dd> The display action, which varies by subtype and is
   * configured by {@link #setDisplayActions}.
   * </dl>
   *
   * @param displayPatternsParam a map from subtypes to relative display
   * URL patterns
   */
  public void setDisplayPatterns(
      final Map<String, String> displayPatternsParam) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          setSubtypeMap("DISPLAY PATTERNS", displayPatternsParam,
              displayPatterns, PATTERN);
        }
      });
  }

  /**
   * Sets the display action for each subtype. The map contains
   * keys that consist of comma-separated subtype integers, or the
   * special string "default". These are mapped to Livelink action
   * names, such as "browse" or "overview".
   *
   * @param displayActionsParam a map from subtypes to Livelink actions
   */
  public void setDisplayActions(final Map<String, String> displayActionsParam) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          setSubtypeMap("DISPLAY ACTIONS", displayActionsParam,
              displayActions, STRING);
        }
      });
  }

  /**
   * Converts a user-specified map into a system map. The user map
   * has string keys for each subtype, and the default entry key is
   * "default". The system map has <code>Integer</code> keys for
   * each subtype, and the default entry has a <code>null</code>
   * key. If the user map values are <code>MessageFormat</code>
   * patterns, then the system map will contain
   * <code>MessageFormat</code> instances for those patterns.
   *
   * @param userMap the user map to read from
   * @param systemMap the system map to add converted entries to
   * @param valueType one of the value type constants, <code>STRING</code>,
   * <code>PATTERN</code>, or <code>LIST_OF_STRINGS</code>
   */
  private void setSubtypeMap(String logPrefix, Map<String, String> userMap,
      Map<Integer, Object> systemMap, int valueType) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config(logPrefix + ": " + userMap);
    for (Map.Entry<String, String> entry : userMap.entrySet()) {
      String key = entry.getKey();
      Object value = entry.getValue();

      switch (valueType) {
        case STRING:
          break;
        case PATTERN:
          value = getFormat((String) value);
          break;
        case LIST_OF_STRINGS:
          value = sanitizeListOfStrings((String) value).split(",");
          break;
        default:
          throw new AssertionError("This can't happen (" + valueType + ")");
      }

      if ("default".equals(key))
        systemMap.put(null, value);
      else {
        String[] subtypes = sanitizeListOfIntegers(key).split(",");
        for (int i = 0; i < subtypes.length; i++)
          systemMap.put(new Integer(subtypes[i]), value);
      }
    }
    if (LOGGER.isLoggable(Level.FINE))
      LOGGER.fine(logPrefix + " PARSED: " + systemMap);
  }

  /**
   * We don't control the relative display URL patterns. If the
   * patterns do not specify the formats to be used for the IDs, we
   * specify a format that does not use grouping. Note that the
   * formats array is not complete, but it has entries up to the
   * highest argument actually used in the pattern.
   *
   * @param pattern a <code>MessageFormat</code> pattern
   * @see #setDisplayPatterns
   */
  private MessageFormat getFormat(String pattern) {
    MessageFormat mf = new MessageFormat(pattern);
    Format[] formats = mf.getFormatsByArgumentIndex();
    for (int i = 0; i < 3 && i < formats.length; i++) {
      if (formats[i] == null)
        mf.setFormatByArgumentIndex(i, nonGroupingNumberFormat);
    }
    return mf;
  }

  /**
   * Gets the display URL for the google:displayurl property. This
   * assembles the base display URL and the subtype-specific
   * relative display URL pattern, which may use the
   * subtype-specific display action.
   *
   * @param url the display URL to use; caller may choose default or public
   * @param subType the subtype, used to select a pattern and an action
   * @param volumeId the volume ID of the object
   * @param objectId the object ID of the object
   * @param fileName of the object version (may be null)
   */
  String getDisplayUrl(String url, int subType, int objectId, int volumeId,
                       String fileName) {
    Integer subTypeInteger = new Integer(subType);
    MessageFormat mf = (MessageFormat) displayPatterns.get(subTypeInteger);
    if (mf == null)
      mf = (MessageFormat) displayPatterns.get(null);
    Object action = displayActions.get(subTypeInteger);
    if (action == null)
      action = displayActions.get(null);

    StringBuffer buffer = new StringBuffer();
    buffer.append(url);
    Object[] args = { new Integer(objectId), new Integer(volumeId),
                      subTypeInteger, action,
                      (fileName == null) ? new Integer(objectId) : fileName };
    synchronized (displayPatterns) {
      mf.format(args, buffer, null);
    }
    return buffer.toString();
  }

  /**
   * Sets a property which indicates that any username and
   * password values which need to be authenticated should be
   * used as the HTTP username and password values.
   *
   * @param useWeb true if the username and password should be
   * used for HTTP authentication
   */
  public void setUseUsernamePasswordWithWebServer(boolean useWeb) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("USE USERNAME WITH WEB SERVER: " + useWeb);
    clientFactory.setUseUsernamePasswordWithWebServer(useWeb);
  }

  /**
   * Sets a flag indicating that a separate set of
   * authentication parameters will be provided.
   *
   * @param useAuth true if a separate set of authentication
   * parameters will be provided
   */
  public void setUseSeparateAuthentication(boolean useAuth) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("USE SEPARATE AUTHENTICATION CONFIG: " + useAuth);
    useSeparateAuthentication = useAuth;
  }

  /**
   * Sets the database server type.
   *
   * @param servtypeParam the database server type, either
   * "MSSQL" or "Oracle"; the empty string is also accepted but
   * is ignored
   */
  /*
   * There are strings like "MSSQL70" and "ORACLE80" in the Livelink
   * source, so we'll let those work if someone copies them verbatim
   * from their opentext.ini file.
   */
  public void setServtype(final String servtypeParam) {
    if (servtypeParam == null || servtypeParam.length() == 0)
      return;
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          if (!servtypeParam.regionMatches(true, 0, "MSSQL", 0, 5) &&
              !servtypeParam.regionMatches(true, 0, "Oracle", 0, 6)) {
            throw new IllegalArgumentException(servtypeParam);
          }
          servtype = servtypeParam;
          if (LOGGER.isLoggable(Level.CONFIG))
            LOGGER.config("SERVTYPE: " + servtype);
        }
      });
  }

  /**
   * Determines whether the database type is SQL Server or Oracle.
   *
   * @param client the sysadmin client to use for the query
   * @return <code>true</code> for SQL Server, or <code>false</code>
   * for Oracle.
   */
  /*
   * We could use the traversal client (by adding PermID to the
   * selected columns), but these queries do not return useful data,
   * so we might as well avoid the permissions checks.
   */
  private void autoDetectServtype(Client client) throws RepositoryException {
    boolean isSqlServer;
    if (servtype == null) {
      // Autodetection of the database type. First, ferret out
      // generic errors when connecting or using ListNodes.
      String view = "DTree";
      String[] columns = { "DataID" };
      ClientValue results = client.ListNodes("0=1", view, columns);

      // Then check an Oracle-specific query.
      // We use ListNodesNoThrow() to avoid logging our expected error.
      LOGGER.finest("Testing an Oracle-specific SQL query...");
      results = client.ListNodesNoThrow("0=1 and rownum=1", view, columns);
      isSqlServer = (results == null);

      if (LOGGER.isLoggable(Level.INFO)) {
        LOGGER.info("AUTO DETECT SERVTYPE: " +
            (isSqlServer ? "MSSQL" : "Oracle"));
      }
    } else {
      // This is basically startsWithIgnoreCase.
      isSqlServer = servtype.regionMatches(true, 0, "MSSQL", 0, 5);

      if (LOGGER.isLoggable(Level.CONFIG)) {
        LOGGER.config("CONFIGURED SERVTYPE: " +
            (isSqlServer ? "MSSQL" : "Oracle"));
      }
    }
    this.isSqlServer = isSqlServer;
  }

  /**
   * Gets whether the database type is SQL Server or Oracle.
   *
   * @return <code>true</code> for SQL Server, or <code>false</code>
   * for Oracle.
   */
  boolean isSqlServer() {
    return this.isSqlServer;
  }

  /**
   * Sets the startDate property.
   *
   * @param property is the date string from the config file.
   */
  public void setStartDate(final String property) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          validateStartDate(property);
        }
      });
  }

  private void validateStartDate(final String property) {
    // If we've validated already, don't do it again.
    if (startDate != null)
      return;
    if (property.trim().length() == 0) {
      if (LOGGER.isLoggable(Level.CONFIG))
        LOGGER.config("STARTDATE: " + property);
      return;
    }
    Date parsedDate = null;
    try {
      SimpleDateFormat dateTime =
          new SimpleDateFormat("yyyy-MM-dd' 'HH:mm:ss");
      parsedDate = dateTime.parse(property);
    } catch (ParseException p1) {
      try {
        SimpleDateFormat date = new SimpleDateFormat("yyyy-MM-dd");
        parsedDate = date.parse(property);
      } catch (ParseException p2) {
        if (LOGGER.isLoggable(Level.WARNING)) {
          LOGGER.warning(
              "STARTDATE: Unable to parse startDate property (\"" +
              property + "\").  Starting at beginning.");
        }
        return;
      }
    }
    startDate = parsedDate;
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("STARTDATE: " + property);
  }

  /**
   * Gets the startDate property.
   *
   * @return the startDate
   */
  Date getStartDate() {
    return startDate;
  }

  /**
   * Sets the configuration for checking for a time warp (that is,
   * incorrect results) in the candidates query results.
   *
   * @param fuzz one of {@code -1} to disable the check for incorrect
   *     results, {@code 0}, to check only for candidates older than
   *     the checkpoint date in the query, or a positive integer to
   *     also check for candidates more than the given number of days
   *     newer than the checkpoint date
   */
  public void setCandidatesTimeWarpFuzz(int fuzz) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("CANDIDATES TIME WARP FUZZ: " + fuzz);
    this.candidatesTimeWarpFuzz = fuzz;
  }

  /**
   * Gets the configuration for checking for a time warp (that is,
   * incorrect results) in the candidates query results.
   *
   * @return the fuzz value, see {@link #setCandidatesTimeWarpFuzz} for
   *     possible values
   */
  int getCandidatesTimeWarpFuzz() {
    return candidatesTimeWarpFuzz;
  }

  /**
   * Sets the Livelink public content username.
   *
   * @param username the username
   */
  public void setPublicContentUsername(String username) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("PUBLIC CONTENT USERNAME: " + username);
    if (!Strings.isNullOrEmpty(username)) {
      this.publicContentUsername = username;
    }
  }

  /**
   * Gets the Livelink public content username.
   *
   * @return the username
   */
  String getPublicContentUsername() {
    return publicContentUsername;
  }

  /**
   * Sets the Livelink public content display URL.
   *
   * @param username the URL
   */
  public void setPublicContentDisplayUrl(String url) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("PUBLIC CONTENT DISPLAY URL: " + url);
    if (!Strings.isNullOrEmpty(url)) {
      this.publicContentDisplayUrl = url;
    }
  }

  /**
   * Gets the Livelink public content display URL.
   *
   * @return the URL
   */
  String getPublicContentDisplayUrl() {
    return publicContentDisplayUrl;
  }


  /**
   * Sets the Travsersal username.  This user must have sufficient
   * rights to access the content you wish indexed.  Optional.
   * If not specified, the administrative user is used.
   *
   * @param username the traversal username
   */
  public void setTraversalUsername(String username) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("TRAVERSAL USERNAME: " + username);
    if (!Strings.isNullOrEmpty(username)) {
      this.traversalUsername = username;
    }
  }

  /**
   * Gets the traversal username.
   *
   * @return the username of the indexing traversal user.
   */
  String getTraversalUsername() {
    return traversalUsername;
  }


  /**
   * Sets the node types that you want to exclude from traversal.
   *
   * @param excludedNodeTypesParam the excluded node types
   */
  public void setExcludedNodeTypes(final String excludedNodeTypesParam) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          excludedNodeTypes =
              sanitizeListOfIntegers(excludedNodeTypesParam);
          if (LOGGER.isLoggable(Level.CONFIG)) {
            LOGGER.config("EXCLUDED NODE TYPES: " +
                excludedNodeTypes);
          }
        }
      });
  }

  /**
   * Gets the node types that you want to exclude from traversal.
   *
   * @return the excluded node types
   */
  String getExcludedNodeTypes() {
    return excludedNodeTypes;
  }

  /**
   * Sets the volume types that you want to exclude from traversal.
   *
   * @param excludedVolumeTypesParam the excluded volume types
   */
  public void setExcludedVolumeTypes(final String excludedVolumeTypesParam) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          excludedVolumeTypes =
              sanitizeListOfIntegers(excludedVolumeTypesParam);
          if (LOGGER.isLoggable(Level.CONFIG)) {
            LOGGER.config("EXCLUDED VOLUME TYPES: " +
                excludedVolumeTypes);
          }
        }
      });
  }

  /**
   * Gets the volume types that you want to exclude from traversal.
   *
   * @return the excluded volume types
   */
  String getExcludedVolumeTypes() {
    return excludedVolumeTypes;
  }

  /**
   * Sets the node IDs that you want to exclude from traversal.
   *
   * @param excludedLocationNodesParam the excluded node IDs
   */
  public void setExcludedLocationNodes(final String
      excludedLocationNodesParam) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          excludedLocationNodes =
              sanitizeListOfIntegers(excludedLocationNodesParam);
          if (LOGGER.isLoggable(Level.CONFIG))
            LOGGER.config("EXCLUDED NODE IDS: " + excludedLocationNodes);
        }
      });
  }

  /**
   * Gets the node IDs that you want to exclude from traversal.
   *
   * @return the excluded node IDs
   */
  String getExcludedLocationNodes() {
    return excludedLocationNodes;
  }

  /**
   * Sets the node IDs that you want to included in the traversal.
   *
   * @param includedLocationNodesParam the included node IDs
   */
  public void setIncludedLocationNodes(
      final String includedLocationNodesParam) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          includedLocationNodes =
              sanitizeListOfIntegers(includedLocationNodesParam);
          if (LOGGER.isLoggable(Level.CONFIG)) {
            LOGGER.config("INCLUDED NODE IDS: " +
                includedLocationNodes);
          }
        }
      });
  }

  /**
   * Gets the node IDs that you want to include in the traversal.
   *
   * @return the included node IDs
   */
  /* Only valid after init(). */
  String getIncludedLocationNodes() {
    return includedLocationNodes;
  }

  /**
   * Sets the subtypes that do not support FetchVersion.
   *
   * @param unsupportedTypes a comma-separated list of subtypes
   */
  public void setUnsupportedFetchVersionTypes(final String unsupportedTypes) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          unsupportedFetchVersionTypes = new ArrayList<Integer>();
          if (!Strings.isNullOrEmpty(unsupportedTypes)) {
            String[] subtypes =
                sanitizeListOfIntegers(unsupportedTypes).split(",");
            for (int i = 0; i < subtypes.length; i++) {
              unsupportedFetchVersionTypes.add(new Integer(subtypes[i]));
            }
          }
          if (LOGGER.isLoggable(Level.CONFIG)) {
            LOGGER.config("UNSUPPORTED FETCH VERSION TYPES: "
                + unsupportedFetchVersionTypes);
          }
        }
      });
  }

  /** Gets the subtypes that do not support FetchVersion. */
  List<Integer> getUnsupportedFetchVersionTypes() {
    return unsupportedFetchVersionTypes;
  }

  /**
   * Sets the fields from ExtendedData to index for each subtype.
   * The map contains keys that consist of comma-separated subtype
   * integers. The special string "default" is not supported. The
   * values are comma-separated lists of attribute names in the
   * ExtendedData assoc for that subtype.
   *
   * @param extendedDataKeysParam a map from subtypes to
   * ExtendedData assoc keys
   */
  /*
   * XXX: If we support pulling metadata from ExtendedData without
   * constructing HTML content, then it might make sense to support
   * the default key here. See the call to
   * collectExtendedDataProperties in LivelinkDocument...
   *
   * XXX: Note that the lack of support for "default" is not
   * enforced here, but maybe it should be. If there's a null key in
   * the map, it's just ignored. All lookups are done by subtype.
   * (For details, see collectExtendedDataProperties in LivelinkDocument.)
   */
  public void setIncludedExtendedData(
      final Map<String, String> extendedDataKeysParam) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          setSubtypeMap("EXTENDEDDATA KEYS", extendedDataKeysParam,
              extendedDataKeys, LIST_OF_STRINGS);
        }
      });
  }

  /**
   * Gets the fields from ExtendedData to index.
   *
   * @param subType the subtype of the item
   * @return the map from integer subtypes
   */
  String[] getExtendedDataKeys(int subType) {
    return (String[]) extendedDataKeys.get(new Integer(subType));
  }


  /**
   * Sets the fields from ObjectInfo to index.
   *
   * @param objectInfoKeysParam a comma-separated list of attributes
   * in the ObjectInfo assoc to include in the index.
   */
  public void setIncludedObjectInfo(final String objectInfoKeysParam) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("INCLUDED OBJECTINFO: " + objectInfoKeysParam);

    if (objectInfoKeysParam != null) {
      propertyValidators.add(new PropertyValidator() {
          void validate() {
            validateIncludedObjectInfo(objectInfoKeysParam);
          }
        });
    }
  }

  private void validateIncludedObjectInfo(final String objectInfoKeysParam) {
    String sanikeys = sanitizeListOfStrings(objectInfoKeysParam);
    if ((sanikeys != null) && (sanikeys.length() > 0)) {
      ArrayList<String> keys = new ArrayList<String>(
          Arrays.asList(sanikeys.split(",")));
      Field[] fields = LivelinkTraversalManager.DEFAULT_FIELDS;

      for (int i = 0; i < keys.size(); i++) {
        String key = keys.get(i);

        // If the client asks to index all ExtendedData, then
        // don't bother with the selective ExendedData by
        // subtype map.
        // TODO: unless we can guarantee the order in which
        // the properties are set, extendedDataKeys may not
        // have been initialized yet, so it might add entries
        // to the map later.
        if ("ExtendedData".equalsIgnoreCase(key)) {
          extendedDataKeys.clear();
          continue;
        }

        // Many ObjectInfo fields are already indexed by default.
        // Filter out any duplicates specified here.
        for (int j = 0; j < fields.length; j++) {
          if ((fields[j].propertyNames.length > 0) &&
              (fields[j].propertyNames[0].equalsIgnoreCase(key))) {
            keys.remove(i);
            i--;  // ArrayList.remove shuffles everything down.
            break;
          }
        }
      }

      // Remember anything left after pruning duplicates.
      if (!keys.isEmpty()) {
        objectInfoKeys = keys.toArray(new String[keys.size()]);
      }
    }
  }

  /**
   * Gets the fields from ObjectInfo to index.
   *
   * @return the array of ObjectInfo assoc attribute keys to index.
   */
  String[] getObjectInfoKeys() {
    return this.objectInfoKeys;
  }


  /**
   * Sets the fields from VersionInfo to index.
   *
   * @param versionInfoKeysParam a comma-separated list of attributes
   * in the VersionInfo assoc to include in the index.
   */
  public void setIncludedVersionInfo(final String versionInfoKeysParam) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("INCLUDED VERSIONINFO: " + versionInfoKeysParam);
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          if (versionInfoKeysParam != null) {
            String keys = sanitizeListOfStrings(versionInfoKeysParam);
            if ((keys != null) && (keys.length() > 0))
              versionInfoKeys = keys.split(",");
          }
        }
      });
  }

  /**
   * Gets the fields from VersionInfo to index.
   *
   * @return the array of VersionInfo assoc attribute keys to index.
   */
  String[] getVersionInfoKeys() {
    return this.versionInfoKeys;
  }


  /**
   * Sets the Categories to include.  The value is either a comma-separated
   * list of Category ObjectIDs, or the special keywords: "all", "none",
   * "searchable".  Default is "all,searchable".
   *
   * @param categories a list of Category IDs or one of the special keywords.
   */
  public void setIncludedCategories(final String categories) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          includedCategoryIds = parseCategories(categories, "all,searchable");
          if (LOGGER.isLoggable(Level.CONFIG))
            LOGGER.config("INCLUDED CATEGORIES: " + includedCategoryIds);
        }
      });
  }

  /**
   * Returns the set of the Categories to include.
   *
   * @return HashSet of parsed Category IDs and String special keywords.
   */
  HashSet<Object> getIncludedCategories() {
    return this.includedCategoryIds;
  }


  /**
   * Sets the Categories to exclude.  The value is either a comma-separated
   * list of Category ObjectIDs, or the special keywords: "all", "none",
   * "searchable".  Default is "none".
   *
   * @param categories a list of Category IDs or one of the special keywords.
   */
  public void setExcludedCategories(final String categories) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          excludedCategoryIds = parseCategories(categories, "none");
          if (LOGGER.isLoggable(Level.CONFIG))
            LOGGER.config("EXCLUDED CATEGORIES: " + excludedCategoryIds);
        }
      });
  }

  /**
   * Returns the set of the Categories to exclude.
   *
   * @return HashSet of parsed Category IDs and String special keywords.
   */
  HashSet<Object> getExcludedCategories() {
    return this.excludedCategoryIds;
  }


  /**
   * Parse the list of Category ObjectIDs or special keyword.  Build up a
   * HashSet to quickly look up the items.
   *
   * @param categories a list of Category IDs or one of the special keywords.
   * @return HashSet of parsed Integers or String keywords.
   */
  private HashSet<Object> parseCategories(String categories,
      String defaultStr) {
    String s = sanitizeListOfStrings(categories);
    if ((s == null) || (s.length() == 0))
      s = defaultStr;

    String ids[] = s.split(",");
    HashSet<Object> set = new HashSet<Object>(ids.length);
    for (int i = 0; i < ids.length; i++) {
      try {
        // If it is an integer, it represents a Category ID
        set.add(Integer.valueOf(ids[i]));
      } catch (NumberFormatException e) {
        // Otherwise, it should be one of the special keywords.
        set.add(ids[i].toLowerCase());
      }
    }
    return set;
  }

  /**
   * Sets the additional SQL {@code SELECT} expressions to include.
   * The value is a map from property name that will be included in
   * the index to SQL {@code SELECT} expressions that will be added to
   * the main query from the {@code WebNodes} table.
   *
   * @param selectExpressions a map from property names to SQL
   * {@code SELECT} expressions. The map may be null or empty.
   */
  public void setIncludedSelectExpressions(
      Map<String, String> selectExpressions) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("INCLUDED SELECT EXPRESSIONS: " + selectExpressions);
    this.selectExpressions = (selectExpressions == null)
        ? Collections.<String, String>emptyMap() : selectExpressions;
  }

  /**
   * Gets the additional SQL {@code SELECT} expressions to include.
   *
   * @return a non-null map from property names to SQL {@code SELECT}
   * expressions
   */
  Map<String, String> getIncludedSelectExpressions() {
    return selectExpressions;
  }

  /**
   * Set the rules for handling the ObjectInfo.Catalog.DISPLAYTYPE_HIDDEN
   * attribute for an object.  Specifies whether hidden items are indexed
   * and searchable.  The supplied value is a list of subtypes for which
   * to index hidden items, or the special keywords "true", "false", "all",
   * or "'ALL'",  optionally in braces {}.  The obtuse syntax matches that
   * used in the opentext.ini file.
   *
   * @param hidden comma-separated list of subtypes or special keywords.
   */
  public void setShowHiddenItems(final String hidden) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          hiddenItemsSubtypes = getHiddenItemsSubtypes(hidden);
          if (LOGGER.isLoggable(Level.CONFIG))
            LOGGER.config("HIDDEN ITEM SUBTYPES: " + hiddenItemsSubtypes);

          // Excluding hidden items requires the DTreeAncestors table.
          // See issue 62.
          if (!hiddenItemsSubtypes.contains("all")
              && useDTreeAncestors == false) {
            throw new ConfigurationException("useDTreeAncestors = false "
                + "is not supported with showHiddenItems = " + hidden,
                "requiredAncestors", new String[] { hidden });
          }
        }
      });
  }

  /**
   * Transforms the {@code showHiddenItems} syntax to a set of subtypes.
   *
   * @param hidden comma-separated list of subtypes or special keywords.
   * @see #setShowHiddenItems
   */
  @VisibleForTesting
  static HashSet<Object> getHiddenItemsSubtypes(String hidden) {
    HashSet<Object> subtypes = new HashSet<Object>();
    String s = sanitizeListOfStrings(hidden);

    // An empty set here indicates that no hidden content should be indexed.
    if (s.length() == 0 || "false".equalsIgnoreCase(s)) {
      return subtypes;
    }

    String ids[] = s.split(",");
    for (int i = 0; i < ids.length; i++) {
      try {
        // If it is an integer, it represents a Subtype.
        subtypes.add(Integer.valueOf(ids[i]));
      } catch (NumberFormatException e) {
        // Otherwise, it should be one of the special keywords.
        String word = ids[i].toLowerCase();
        // "All" in the set means all hidden
        // content will get indexed.
        if ("true".equals(word) || "all".equals(word) ||
            "'all'".equals(word)) {
          subtypes.add("all");
        }
        else {
          // FIXME: Why is this allowed?
          subtypes.add(word);
        }
      }
    }
    return subtypes;
  }

  /**
   * Return the set of subtypes for which we show hidden items.
   * If the Set is null, then show all hidden items, avoiding an
   * expensive check before processing each document.
   *
   * @return Set of subtypes for which we show hidden items.
   */
  HashSet<Object> getShowHiddenItems() {
    return hiddenItemsSubtypes;
  }

  /**
   * Set whether this connector instance will track deleted items.
   * If true, then track delete events in the Livelink Audit Log,
   * and send a delete notification to the GSA, so it may purge
   * the deleted item from its index.
   *
   * @param trackDeletedItems
   */
  public void setTrackDeletedItems(boolean trackDeletedItems) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("TRACK DELETED ITEMS: " + trackDeletedItems);
    this.trackDeletedItems = trackDeletedItems;
  }

  /**
   * Return true if this connector instance will track deleted
   * items and send delete requests to the GSA, false otherwise.
   *
   * @return true if track deleted items, false otherwise.
   */
  public boolean getTrackDeletedItems() {
    return this.trackDeletedItems;
  }

  /**
   * Sets whether or not to use the DTreeAncestors table for hierarchy data.
   *
   * @param useDTreeAncestors <code>true</code> to use the DTreeAncestors
   * table when necessary, or <code>false</code> to use a slower method
   */
  public void setUseDTreeAncestors(boolean useDTreeAncestors) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("USE DTREEANCESTORS: " + useDTreeAncestors);
    this.useDTreeAncestors = useDTreeAncestors;
  }

  /**
   * Gets whether or not to use the DTreeAncestors table for hierarchy data.
   *
   * @return <code>true</code> to use the DTreeAncestors
   * table when necessary, or <code>false</code> to use a slower method
   */
  public boolean getUseDTreeAncestors() {
    return useDTreeAncestors;
  }

  /**
   * Sets the concrete implementation for the <code>Genealogist</code>
   * interface.
   *
   * @param genealogist the fully-qualified name of the
   * <code>Genealogist</code> implementation to use
   */
  public void setGenealogist(String genealogist) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("GENEALOGIST: " + genealogist);
    this.genealogist = genealogist;
  }

  /**
   * Gets the <code>Genealogist</code> implementation class name.
   *
   * @return the fully-qualified name of the <code>Genealogist</code>
   * implementation to use
   */
  String getGenealogist() {
    return genealogist;
  }

  /**
   * Sets the minimum size of the <code>Genealogist</code>
   * ancestor node caches.
   * <p/>
   * The caches will grow (doubling in size) upto the maximum configured cache
   * size before aging LRU ancestor nodes from the cache.  Optimal values of
   * the minimum cacheSize are slightly less than (but not exactly equal to)
   * a power of 2.
   *
   * @param cacheSize minimum cache size in number of entries.
   *        The minimum value must not be less than or equal to zero.
   */
  public void setGenealogistMinCacheSize(final int cacheSize) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          if (cacheSize <= 0) {
            throw new ConfigurationException(
                "genealogistMinCacheSize must be positive.");
          }
          if (cacheSize > CacheMap.MAXIMUM_CAPACITY) {
            throw new ConfigurationException("genealogistMinCacheSize must "
                + "not exceed " + CacheMap.MAXIMUM_CAPACITY);
          }
          LivelinkConnector.this.genealogistMinCacheSize = cacheSize;
          if (LOGGER.isLoggable(Level.CONFIG)) {
            LOGGER.config("GENEALOGIST MIN CACHE SIZE: " + cacheSize);
          }
        }
      });
  }

  /**
   * Gets the minimum <code>Genealogist</code> cache size.
   *
   * @return the minimum <code>Genealogist</code> cache size
   */
  int getGenealogistMinCacheSize() {
    return genealogistMinCacheSize;
  }

  /**
   * Sets the maximum size of the <code>Genealogist</code>
   * ancestor node caches.
   * <p/>
   * The caches will grow (doubling in size) upto the maximum configured cache
   * size before aging LRU ancestor nodes from the cache.
   *
   * @param cacheSize maximum cache size in number of entries.
   *        The maximum cache size should be greater than or equal to the
   *        minimum, and should be a power-of-2 multiple of the minimum.
   */
  public void setGenealogistMaxCacheSize(final int cacheSize) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          if (cacheSize <= 0) {
            throw new ConfigurationException(
                "genealogistMaxCacheSize must be positive.");
          }
          if (cacheSize > CacheMap.MAXIMUM_CAPACITY) {
            throw new ConfigurationException("genealogistMaxCacheSize must "
                + "not exceed " + CacheMap.MAXIMUM_CAPACITY);
          }
          LivelinkConnector.this.genealogistMaxCacheSize = cacheSize;
          if (LOGGER.isLoggable(Level.CONFIG)) {
            LOGGER.config("GENEALOGIST MAX CACHE SIZE: " + cacheSize);
          }
        }
      });
  }

  /**
   * Gets the <code>Genealogist</code> maximum cache size. The cache size is
   * the maximum number of ancestor nodes that will be held in each cache.
   *
   * @return the <code>Genealogist</code> maximum cache size
   */
  int getGenealogistMaxCacheSize() {
    return genealogistMaxCacheSize;
  }

  /**
   * Sets an additional SQL WHERE clause condition.
   *
   * @param sqlWhereCondition a valid SQL condition
   */
  public void setSqlWhereCondition(String sqlWhereCondition) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("SQL WHERE CONDITION: " + sqlWhereCondition);
    this.sqlWhereCondition = sqlWhereCondition;
  }

  /**
   * Gets the additional SQL WHERE clause condition.
   *
   * @param sqlWhereCondition a valid SQL condition
   */
  String getSqlWhereCondition() {
    return sqlWhereCondition;
  }

  /**
   * Creates an empty client factory instance for use with
   * authentication configuration parameters. This will use the
   * same implementation class as the default client factory.
   */
  /*
   * Assumes that there aren't multiple threads configuring a
   * single LivelinkConnector instance.
   */
  private void createAuthenticationClientFactory() {
    if (authenticationClientFactory == null) {
      if (LOGGER.isLoggable(Level.CONFIG)) {
        LOGGER.config("NEW AUTHENTICATION INSTANCE: " +
            clientFactory.getClass().getName());
      }
      try {
        authenticationClientFactory = (ClientFactory)
            clientFactory.getClass().newInstance();
      } catch (Exception e) {
        LOGGER.log(Level.SEVERE, e.getMessage(), e);
        throw new RuntimeException(e); // XXX: More specific exception?
      }
    }
  }

  /**
   * Sets the option to include the domain name with the username for
   * authentication and authorization.
   *
   * @param domainAndName one of the strings {@code "TRUE"},
   *     {@code "FALSE"}, {@code "AUTHENTICATION"}, or {@code "LEGACY"},
   *     case-insensitive
   */
  public void setDomainAndName(final String domainAndName) {
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          if (Strings.isNullOrEmpty(domainAndName)) {
            throw new IllegalArgumentException(domainAndName);
          } else {
            LivelinkConnector.this.domainAndName =
                DomainAndName.valueOf(domainAndName.trim().toUpperCase());
            if (LOGGER.isLoggable(Level.CONFIG)) {
              LOGGER.config("DOMAIN AND NAME: " + domainAndName);
            }
          }
        }
      });
  }

  DomainAndName getDomainAndName() {
    return domainAndName;
  }

  /**
   * Sets the Windows domain name to be used for user
   * authentication. The Windows domain might be used for direct
   * connections (see the DomainAndName parameter in the [Security]
   * section of opentext.ini), HTTP tunneling, or separate
   * authentication.
   *
   * @param domain the Windows domain name
   * @since 1.0.3
   */
  public void setWindowsDomain(String domain) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("WINDOWS DOMAIN: " + domain);
    this.windowsDomain = domain;
  }

  /** Gets the Windows domain name to be used for user authentication. */
  String getWindowsDomain() {
    return windowsDomain;
  }

  /**
   * Sets the host name or IP address for authentication. See {@link
   * #setServer}.
   *
   * @param server the host name or IP address of the server
   */
  public void setAuthenticationServer(String server) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION SERVER: " + server);
    authenticationClientFactory.setServer(server);
  }

  /**
   * Sets the port to use. See {@link #setPort}.
   *
   * @param port the port number
   */
  public void setAuthenticationPort(final String port) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION PORT: " + port);
    propertyValidators.add(new PropertyValidator() {
        void validate() {
          authenticationClientFactory.setPort(Integer.parseInt(port));
        }
      });
  }

  /**
   * Sets the database connection to use when
   * authenticating. See {@link #setConnection}.
   *
   * @param connection the database name
   */
  public void setAuthenticationConnection(String connection) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION CONNECTION: " + connection);
    authenticationClientFactory.setConnection(connection);
  }

  /**
   * Sets the HTTPS property. See {@link #setHttps}.
   *
   * @param useHttps true if HTTPS should be used; false otherwise
   */
  public void setAuthenticationHttps(boolean useHttps) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION HTTPS: " + useHttps);
    authenticationClientFactory.setHttps(useHttps);
  }

  /**
   * Sets the EnableNTLM property. See {@link @setEnableNtlm}.
   *
   * @param verifyServer true if the NTLM subsystem should be used
   */
  public void setAuthenticationEnableNtlm(boolean enableNtlm) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION ENABLE NTLM: " + enableNtlm);
    authenticationClientFactory.setEnableNtlm(enableNtlm);
  }

  /**
   * Sets the Livelink CGI path for use when tunneling requests
   * through a web server. See {@link #setLivelinkCgi}.
   *
   * @param livelinkCgi the Livelink CGI path or URL
   */
  public void setAuthenticationLivelinkCgi(String livelinkCgi) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION LIVELINK CGI: " + livelinkCgi);
    authenticationClientFactory.setLivelinkCgi(livelinkCgi);
  }

  /**
   * Sets the Verify Server property. See {@link @setVerifyServer}.
   *
   * @param verifyServer true if the server certificate should
   * be verified
   */
  public void setAuthenticationVerifyServer(boolean verifyServer) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION VERIFY SERVER: " + verifyServer);
    authenticationClientFactory.setVerifyServer(verifyServer);
  }

  /**
   * Sets the CaRootCerts property. See {@link #setCaRootCerts}.
   *
   * @param caRootCerts a list of certificate authority root certificates
   */
  public void setAuthenticationCaRootCerts(List<String> caRootCerts) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION CA ROOT CERTS: " + caRootCerts);
    authenticationClientFactory.setCaRootCerts(caRootCerts);
  }

  /**
   * Sets the Livelink domain name. See {@link #setDomainName}.
   *
   * @param domainName the domain name
   */
  public void setAuthenticationDomainName(String domainName) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION DOMAIN NAME: " + domainName);
    authenticationClientFactory.setDomainName(domainName);
  }

  /**
   * Sets a property which indicates that any username and
   * password values which need to be authenticated should be
   * used as the HTTP username and password values.
   *
   * @param useWeb true if the username and password should be
   * used for HTTP authentication
   */
  public void setAuthenticationUseUsernamePasswordWithWebServer(
      boolean useWeb) {
    createAuthenticationClientFactory();
    if (LOGGER.isLoggable(Level.CONFIG)) {
      LOGGER.config("AUTHENTICATION USE USERNAME WITH WEB SERVER: " +
          useWeb);
    }
    authenticationClientFactory.setUseUsernamePasswordWithWebServer(
        useWeb);
  }

  /**
   * Sets the concrete implementation for the
   * <code>ContentHandler</code> interface.
   *
   * @param contentHandler the fully-qualified name of the
   * <code>ContentHandler</code> implementation to use, or
   * a <code>ContentHandler</code> object
   */
  public void setContentHandler(Object contentHandler) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("CONTENT HANDLER: " + contentHandler);

    if (contentHandler instanceof String) {
      try {
        this.contentHandler = (ContentHandler)
            Class.forName((String) contentHandler).newInstance();
      } catch (Exception e) {
          throw new ConfigurationException("contentHandler " +
            "class " + contentHandler + " could not be instantiated", e);
      }
    }
    else if (contentHandler instanceof ContentHandler)
      this.contentHandler = (ContentHandler) contentHandler;
    else
      throw new ConfigurationException("contentHandler must be "
        + "a class name string or a ContentHandler object.");
  }

  /**
   * Gets the <code>ContentHandler</code> implementation object.
   *
   * @param client the client to use to access the server
   * @return the <code>ContentHandler</code> implementation to use
   * @throws RepositoryException if the class cannot be initialized
   */
  ContentHandler getContentHandler(Client client)
      throws RepositoryException {
    // TODO: This code is relying on the fact that the client is
    // always the traversal client. In order for this to work with
    // retrievers at serve-time, we need to push the client down into
    // the ContentHandler.getInputStream method. The alternative is to
    // create a new ContentHandlerFactory interface and configure that
    // instead so that we can create new instances here.
    contentHandler.initialize(this, client);
    return contentHandler;
  }

  /**
   * Sets the FeedType.  Supported feed types are CONTENT and CONTENTURL.
   *
   * @param feedTypeString the String representation of a
   *        {@link SpiConstants.FeedType}
   */
  public void setFeedType(String feedTypeString) {
    FeedType type;
    try {
      type = Enum.valueOf(FeedType.class, feedTypeString.toUpperCase());
      if (type != FeedType.CONTENT && type != FeedType.CONTENTURL) {
        LOGGER.warning("Unsupported FeedType: " + feedTypeString);
        type = FeedType.CONTENT;
      }
    } catch (IllegalArgumentException e) {
      LOGGER.warning("Unknown FeedType: " + feedTypeString);
      type = FeedType.CONTENT;
    }
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("FEED TYPE: " + type.toString());
    this.feedType = type;
  }

  /**
   * Returns the configured {@link SpiConstants.FeedType} to use for content.
   *
   * @return the configured {@link SpiConstants.FeedType}
   */
  FeedType getFeedType() {
    return feedType;
  }

  public void setGroupFeedSchedule(String groupFeedSchedule) {
    this.groupFeedSchedule = groupFeedSchedule;
  }

  String getGroupFeedSchedule() {
    return groupFeedSchedule;
  }

  /**
   * Sets the AuthenticationManager implementation to use.
   *
   * @param authenticationManager an authentication manager
   */
  public void setAuthenticationManager(
      AuthenticationManager authenticationManager) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHENTICATION MANAGER: " + authenticationManager);
    this.authenticationManager = authenticationManager;
  }

  /**
   * Sets the AuthorizationManager implementation to use.
   *
   * @param authorizationManager an authorization manager
   */
  public void setAuthorizationManager(
      AuthorizationManager authorizationManager) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("AUTHORIZATION MANAGER: " + authorizationManager);
    this.authorizationManager = authorizationManager;
  }

  /**
   * Sets the Livelink public content AuthorizationManager
   * implementation to use.
   *
   * @param authorizationManager an authorization manager
   */
  public void setPublicContentAuthorizationManager(
      LivelinkAuthorizationManager authorizationManager) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("PUBLIC CONTENT AUTHORIZATION MANAGER: "
          + authorizationManager);
    this.publicContentAuthorizationManager = authorizationManager;
  }

  /**
   * Gets the Livelink public content AuthorizationManager
   * implementation to use.
   *
   * return an initialized authorization manager
   */
  public LivelinkAuthorizationManager getPublicContentAuthorizationManager() {
    return publicContentAuthorizationManager;
  }

  /**
   * Sets whether or not to try using lowercase usernames.
   *
   * @param tryLowercaseUsernames <code>true</code> to try using a
   * lowercase version of the usernames for authentication and
   * authorization, and <code>false</code> to not modify the given
   * usernames
   */
  public void setTryLowercaseUsernames(boolean tryLowercaseUsernames) {
    if (LOGGER.isLoggable(Level.CONFIG))
      LOGGER.config("LOWERCASE USERNAME HACK: " + tryLowercaseUsernames);
    this.tryLowercaseUsernames = tryLowercaseUsernames;
  }

  /**
   * Gets whether or not to try using lowercase usernames.
   *
   * @return <code>true</code> to try using a lowercase version of
   * the usernames for authentication and authorization, and
   * <code>false</code> to not modify the given usernames
   */
  boolean isTryLowercaseUsernames() {
    return tryLowercaseUsernames;
  }

  /**
   * Sets globalNamespace.
   *
   * @param globalNamespace global namespace
   */
  public void setGoogleGlobalNamespace(String globalNamespace) {
    if (LOGGER.isLoggable(Level.CONFIG)) {
      LOGGER.config("globalNamespace: " + globalNamespace);
    }
    this.globalNamespace = globalNamespace;
  }

  /**
   * Gets globalNamespace.
   *
   * @return global namespace
   */
  String getGoogleGlobalNamespace() {
    return globalNamespace;
  }

  /**
   * Sets localNamespace.
   *
   * @param localNamespace local namespace
   */
  public void setGoogleLocalNamespace(String localNamespace) {
    if (LOGGER.isLoggable(Level.CONFIG)) {
      LOGGER.config("localNamespace: " + localNamespace);
    }
    this.localNamespace = localNamespace;
  }

  /**
   * Gets localNamespace.
   *
   * @return local namespace
   */
  String getGoogleLocalNamespace() {
    return localNamespace;
  }

  public void setGoogleFeedHost(String googleFeedHost) {
    this.googleFeedHost = googleFeedHost;
  }

  String getGoogleFeedHost() {
    return googleFeedHost;
  }

  public void setGoogleConnectorName(String googleConnectorName) {
    this.googleConnectorName = googleConnectorName;
  }

  String getGoogleConnectorName() {
    return googleConnectorName;
  }

  /**
   * Gets the <code>ClientFactory</code> for this Connector.
   *
   * @return the <code>ClientFactory</code>
   */
  ClientFactory getClientFactory() {
    return clientFactory;
  }

  /**
   * Gets the <code>ClientFactory</code> for this Connector to
   * use with authentication.
   *
   * @return the <code>ClientFactory</code>
   */
  ClientFactory getAuthenticationClientFactory() {
    if (useSeparateAuthentication && authenticationClientFactory != null)
      return authenticationClientFactory;
    return clientFactory;
  }

  /**
   * Finishes initializing the connector after all properties
   * have been set.
   */
  /*
   * This is essentially a Spring init-method. There's currently
   * no reason to expose it. For the 1.0.2 release of the
   * Connector Manager, this method needs to not be a Spring
   * init-method, because that leads to Spring instantiation
   * failures that are not properly handled.
   * TODO: Figure out if that is still true.
   */
  private void init() throws RepositoryException {
    // Make sure we have at least CM v 2.6.6 (google:folder property
    // first appears in 2.6.6).
    try {
      SpiConstants.class.getDeclaredField("PROPNAME_FOLDER");
    } catch (java.lang.NoSuchFieldException e) {
      LOGGER.severe("This connector requires a newer version of the " +
          "Connector Manager");
      throw new ConfigurationException("This connector requires a newer" +
          " version of the version of the Connector Manager");
    }

    for (int i = 0; i < propertyValidators.size(); i++) {
      propertyValidators.get(i).validate();
    }

    if (!useHttpTunneling) {
      LOGGER.finer("DISABLING HTTP TUNNELING");
      clientFactory.setLivelinkCgi("");
      clientFactory.setUseUsernamePasswordWithWebServer(false);
    }
    if (publicContentDisplayUrl == null ||
        publicContentDisplayUrl.length() == 0) {
      publicContentDisplayUrl = displayUrl;
    }

    // Must be the last thing in this method so that the
    // connector is fully configured when used here.
    if (authenticationManager instanceof ConnectorAware)
      ((ConnectorAware) authenticationManager).setConnector(this);
    if (authorizationManager instanceof ConnectorAware)
      ((ConnectorAware) authorizationManager).setConnector(this);
    if (publicContentAuthorizationManager != null)
      publicContentAuthorizationManager.setConnector(this);
  }

  /**
   * Validates the DTreeAncestors table in the Livelink database,
   * which must be populated for the connector to work. This is a
   * coarse check to see if the table is completely empty.
   *
   * @param client the sysadmin client to use for the query
   */
  /*
   * The client must be a sysadmin client because this method uses
   * <code>ListNodes</code> but we don't want a lack of permissions
   * to give a false positive. Also, this method does not select
   * PermID, or even use the DTree table at all.
   */
  private void validateDTreeAncestors(Client client)
      throws RepositoryException {
    ClientValue ancestors = sqlQueries.execute(client, null,
        "LivelinkConnector.validateDTreeAncestors");
    if (ancestors.size() > 0) {
      LOGGER.finest("The Livelink DTreeAncestors table is not empty");
    } else {
      throw new LivelinkException(
          "The Livelink DTreeAncestors table is empty. Please make " +
          "sure that the Livelink Recommender agent is enabled.",
          LOGGER, "emptyAncestors", null);
    }
  }

  /**
   * Validates the DTreeAncestors table in the Livelink database,
   * which must be populated for the connector to work. This is a
   * check to see if any of the Items to Index are included in the
   * table.
   *
   * As a by-product, this method may update the startDate, if the
   * earliest modification date found is later than the configured
   * startDate.
   *
   * @param client the sysadmin client to use for the query
   */
  /*
   * The client must be a sysadmin client because this methods uses
   * <code>ListNodes</code> but we don't want a lack of permissions
   * to give a false positive. Also, this method does not select
   * PermID, although it could.
   */
  private void validateIncludedLocationStartDate(Client client)
      throws RepositoryException {
    // If the user specified "Items to index", fetch the earliest
    // modification time for any of those items.  We can forge
    // a start checkpoint that skips over any ancient history in
    // the LL database.
    if (!Strings.isNullOrEmpty(includedLocationNodes)) {
      String ancestorNodes =
          Genealogist.getAncestorNodes(includedLocationNodes);
      ClientValue results = sqlQueries.execute(client, null,
          "LivelinkConnector.validateIncludedLocationStartDate",
          ancestorNodes, includedLocationNodes);

      if (results.size() > 0 &&
          results.isDefined(0, "minModifyDate")) {
        Date minDate = results.toDate(0, "minModifyDate");
        if (LOGGER.isLoggable(Level.FINEST))
          LOGGER.finest("COMPUTED START DATE: " + minDate);
        if (startDate == null || minDate.after(startDate)) {
          startDate = minDate;
          if (LOGGER.isLoggable(Level.INFO)) {
            LOGGER.info("INCLUDED LOCATION START DATE: " +
                startDate);
          }
        }
      } else {
        throw new LivelinkException(
            "The Livelink DTreeAncestors table is missing entries " +
            "for all of the items to index.", LOGGER,
            "missingAncestors", null);
      }
    }
  }

  /**
   * Validates the DTreeAncestors table in the Livelink database,
   * which must be populated for the connector to work. This is a
   * check for items in the Enterprise workspace that aren't listed
   * in DTreeAncestors. To avoid problems with false positives, if
   * items are missing a warning is logged, but no exceptions are
   * thrown.
   *
   * @param client the sysadmin client to use for the query
   * @throws RepositoryException if an I/O errors communicating with
   * the Livelink server
   */
  /*
   * The client must be a sysadmin client because this methods uses
   * <code>ListNodes</code> but we don't want a lack of permissions
   * to give a false positive. Also, this method does not select
   * PermID, although it could.
   */
  private void validateEnterpriseWorkspaceAncestors(Client client)
      throws RepositoryException {
    if (!LOGGER.isLoggable(Level.WARNING))
      return;

    ClientValue info = client.AccessEnterpriseWS();
    int id = info.toInteger("ID");
    int volumeId = info.toInteger("VolumeID");

    // FIXME: We don't want to log the exception as an error, but it
    // would be nice to be able to log it as a warning.
    ClientValue missing =
        getMissingEnterpriseWorkspaceAncestors(client, id, volumeId);
    if (missing == null) {
      LOGGER.warning("Unable to check for missing entries in the " +
          "Livelink DTreeAncestors table.");
    } else if (missing.size() > 0) {
      LOGGER.warning("The Livelink DTreeAncestors table " +
          "may be missing entries from the Enterprise workspace (" +
          id + ").");
    } else {
      LOGGER.finest("The Livelink DTreeAncestors table " +
          "passed the Enterprise workspace check.");
    }
  }

  /**
   * A separate method for testability, because the caller has no
   * output except log messages, making it hard to test the query for
   * correctness.
   */
  @VisibleForTesting
  ClientValue getMissingEnterpriseWorkspaceAncestors(Client client,
      int id, int volumeId) throws RepositoryException {
    return sqlQueries.execute(client, null,
        "LivelinkConnector.getMissingEnterpriseWorkspaceAncestors",
        id, volumeId);
  }

  /**
   * Ensures that the configured traversal username is valid.
   *
   * @param client the client to use to access the server
   */
  private void validateTraversalUsername(Client client) {
    try {
      ClientValue userInfo = client.GetUserInfo(traversalUsername);
    } catch (RepositoryException e) {
      throw new ConfigurationException("Traversal username "
          + traversalUsername + " could not be verified."
          + e.getMessage(), "invalidTraversalUsername",
          new String[] { traversalUsername, e.getMessage() });
    }
  }

  /**
   * Validates the additional SQL WHERE clause condition.
   *
   * @param client the client to use for the query
   */
  private void validateSqlWhereCondition(Client client)
      throws RepositoryException {
    // TODO: We should use the traversal client here, because it seems
    // like permissions are a valid concern in this case.
    ClientValue rows = sqlQueries.execute(client, null,
        "LivelinkConnector.validateSqlWhereCondition",
        sqlWhereCondition);
    if (rows.size() == 0) {
      // TODO: ConfigurationException or LivelinkException?
      throw new LivelinkException(
          "The additional SQL WHERE clause condition is too restrictive. "
          + "No documents were found with this condition.",
          LOGGER, "emptySqlResults", null);
    }
  }

  /** {@inheritDoc} */
  @Override
  public Session login()
      throws RepositoryLoginException, RepositoryException {
    LOGGER.fine("LOGIN");

    init();

    // Several birds with one stone. Getting the server info
    // verifies connectivity with the server, and we use the
    // results to set the character encoding for future clients
    // and confirm the availability of the overview action.
    Client client = clientFactory.createClient();
    ClientValue serverInfo = client.GetServerInfo();

    // Get the server version, which is a string like "9.5.0".
    String serverVersion = serverInfo.toString("ServerVersion");
    if (LOGGER.isLoggable(Level.INFO))
      LOGGER.info("LIVELINK SERVER VERSION: " + serverVersion);
    String[] versions = serverVersion.split("\\.");
    int majorVersion;
    int minorVersion;
    if (versions.length >= 2) {
      majorVersion = Integer.parseInt(versions[0]);
      minorVersion = Integer.parseInt(versions[1]);
    } else {
      if (LOGGER.isLoggable(Level.WARNING)) {
        LOGGER.warning(
            "Unable to parse Livelink server version; assuming 9.5.");
      }
      majorVersion = 9;
      minorVersion = 5;
    }

    // The connector requires Livelink 9.5 or later.
    if ((majorVersion < 9) || (majorVersion == 9 && minorVersion < 5)) {
      throw new LivelinkException(
          "Livelink 9.5 or later is required.", LOGGER,
          "unsupportedVersion", new String[] { "9.5" });
    }

    // Check for Livelink 9.5 or earlier; change overview action
    // if needed. We only check for the default entry, because if
    // the map has been customized, perhaps the user knows what
    // they are doing. If a user has a Livelink 9.5 with an custom
    // overview action, they can configure that in the
    // displayPatterns and we won't see it. We are leaving the
    // modified entry in the map rather than removing it and
    // relying on the default action because there are lots and
    // lots of documents, and this avoids a map lookup miss in
    // getDisplayUrl for every one of them.
    if (majorVersion == 9 && minorVersion <= 5) {
      Integer docSubType = new Integer(144);
      Object action = displayActions.get(docSubType);
      if ("overview".equals(action))
        displayActions.put(docSubType, "properties");
    }

    // Set the character encodings in the client factories if needed.
    int serverEncoding = serverInfo.toInteger("CharacterEncoding");
    if (serverEncoding == Client.CHARACTER_ENCODING_UTF8) {
      LOGGER.config("ENCODING: UTF-8");
      clientFactory.setEncoding("UTF-8");
      if (authenticationClientFactory != null)
        authenticationClientFactory.setEncoding("UTF-8");
    }

    // Check that our user has System Administration rights, get
    // the database type, and check the DTreeAncestors table (in
    // that order, because we need SA rights for the database type
    // queries, and we need the database type for the
    // DTreeAncestors queries).
    ClientValue userInfo = client.GetUserInfo(username);
    int privs = userInfo.toInteger("UserPrivileges");
    if ((privs & Client.PRIV_PERM_BYPASS) != Client.PRIV_PERM_BYPASS) {
      LOGGER.info("USER PRIVILEGES: " + privs);
      throw new ConfigurationException("User " + username +
          " does not have Livelink System Administration rights.",
          "missingSaRights", new String[] { username });
    }

    autoDetectServtype(client);
    sqlQueries = new SqlQueries(isSqlServer);

    // Check first to see if we are going to need the
    // DTreeAncestors table.
    if (useDTreeAncestors &&
        (!hiddenItemsSubtypes.contains("all")
            || !Strings.isNullOrEmpty(includedLocationNodes)
            || !Strings.isNullOrEmpty(excludedLocationNodes))) {
      validateDTreeAncestors(client);
      validateIncludedLocationStartDate(client);
      validateEnterpriseWorkspaceAncestors(client);
    }

    if (!Strings.isNullOrEmpty(traversalUsername)) {
      validateTraversalUsername(client);
    }

    if (!Strings.isNullOrEmpty(sqlWhereCondition)) {
      validateSqlWhereCondition(client);
    }

    return new LivelinkSession(this, clientFactory, authenticationManager,
        authorizationManager);
  }
}
TOP

Related Classes of com.google.enterprise.connector.otex.LivelinkConnector$PropertyValidator

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.