org.apache.manifoldcf.crawler.connectors.livelink.LivelinkConnector.java Source code

Java tutorial

Introduction

Here is the source code for org.apache.manifoldcf.crawler.connectors.livelink.LivelinkConnector.java

Source

/* $Id: LivelinkConnector.java 996524 2010-09-13 13:38:01Z kwright $ */

/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.manifoldcf.crawler.connectors.livelink;

import org.apache.manifoldcf.core.interfaces.*;
import org.apache.manifoldcf.agents.interfaces.*;
import org.apache.manifoldcf.crawler.interfaces.*;
import org.apache.manifoldcf.crawler.system.Logging;
import org.apache.manifoldcf.crawler.system.ManifoldCF;
import org.apache.manifoldcf.connectorcommon.interfaces.*;
import org.apache.manifoldcf.connectorcommon.common.XThreadInputStream;
import org.apache.manifoldcf.connectorcommon.common.XThreadOutputStream;
import org.apache.manifoldcf.connectorcommon.common.InterruptibleSocketFactory;
import org.apache.manifoldcf.core.common.DateParser;

import org.apache.manifoldcf.livelink.*;

import java.io.*;
import java.util.*;
import java.net.*;
import java.util.concurrent.TimeUnit;

import com.opentext.api.*;

import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.protocol.HttpRequestExecutor;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.config.SocketConfig;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.NameValuePair;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.NTCredentials;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.util.EntityUtils;
import org.apache.http.HttpStatus;
import org.apache.http.HttpHost;
import org.apache.http.Header;
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.HTTP;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.HttpRequestRetryHandler;
import org.apache.http.protocol.HttpContext;

import org.apache.http.conn.ConnectTimeoutException;
import org.apache.http.client.RedirectException;
import org.apache.http.client.CircularRedirectException;
import org.apache.http.NoHttpResponseException;
import org.apache.http.HttpException;

/** This is the Livelink implementation of the IRepositoryConnectr interface.
* The original Volant code forced there to be one livelink session per JVM, with
* lots of buggy synchronization present to try to enforce this.  This implementation
* is multi-session.  However, since it is possible that the Volant restriction was
* indeed needed, I have attempted to structure things to allow me to turn on
* single-session if needed.
*
* For livelink, the document identifiers are the object identifiers.
*
*/
public class LivelinkConnector extends org.apache.manifoldcf.crawler.connectors.BaseRepositoryConnector {
    public static final String _rcsid = "@(#)$Id: LivelinkConnector.java 996524 2010-09-13 13:38:01Z kwright $";

    // Activities we will report on
    private final static String ACTIVITY_SEED = "find documents";
    private final static String ACTIVITY_FETCH = "fetch document";

    /** Deny access token for default authority */
    private final static String defaultAuthorityDenyToken = GLOBAL_DENY_TOKEN;

    // A couple of very important points.
    // First, the canonical document identifier has the following form:
    // <D|F>[<volume_id>:]<object_id>
    // Second, the only LEGAL objects for a document identifier to describe
    // are folders, documents, and volume objects.  Project objects are NOT
    // allowed; they must be mapped to the appropriate volume object before
    // being returned to the crawler.

    // Metadata names for general metadata fields
    protected final static String GENERAL_NAME_FIELD = "general_name";
    protected final static String GENERAL_DESCRIPTION_FIELD = "general_description";
    protected final static String GENERAL_CREATIONDATE_FIELD = "general_creationdate";
    protected final static String GENERAL_MODIFYDATE_FIELD = "general_modifydate";
    protected final static String GENERAL_OWNER = "general_owner";
    protected final static String GENERAL_CREATOR = "general_creator";
    protected final static String GENERAL_MODIFIER = "general_modifier";
    protected final static String GENERAL_PARENTID = "general_parentid";

    // Signal that we have set up connection parameters properly
    private boolean hasSessionParameters = false;
    // Signal that we have set up a connection properly
    private boolean hasConnected = false;
    // Session expiration time
    private long expirationTime = -1L;
    // Idle session expiration interval
    private final static long expirationInterval = 300000L;

    // Data required for maintaining livelink connection
    private LAPI_DOCUMENTS LLDocs = null;
    private LAPI_ATTRIBUTES LLAttributes = null;
    private LAPI_USERS LLUsers = null;

    private LLSERVER llServer = null;
    private int LLENTWK_VOL;
    private int LLENTWK_ID;
    private int LLCATWK_VOL;
    private int LLCATWK_ID;

    // Parameter values we need
    private String serverProtocol = null;
    private String serverName = null;
    private int serverPort = -1;
    private String serverUsername = null;
    private String serverPassword = null;
    private String serverHTTPCgi = null;
    private String serverHTTPNTLMDomain = null;
    private String serverHTTPNTLMUsername = null;
    private String serverHTTPNTLMPassword = null;
    private IKeystoreManager serverHTTPSKeystore = null;

    private String ingestProtocol = null;
    private String ingestPort = null;
    private String ingestCgiPath = null;

    private String viewProtocol = null;
    private String viewServerName = null;
    private String viewPort = null;
    private String viewCgiPath = null;

    private String ingestNtlmDomain = null;
    private String ingestNtlmUsername = null;
    private String ingestNtlmPassword = null;

    // SSL support for ingestion
    private IKeystoreManager ingestKeystoreManager = null;

    // Connection management
    private HttpClientConnectionManager connectionManager = null;
    private HttpClient httpClient = null;

    // Base path for viewing
    private String viewBasePath = null;

    // Ingestion port number
    private int ingestPortNumber = -1;

    // Activities list
    private static final String[] activitiesList = new String[] { ACTIVITY_SEED, ACTIVITY_FETCH };

    // Retry count.  This is so we can try to install some measure of sanity into situations where LAPI gets confused communicating to the server.
    // So, for some kinds of errors, we just retry for a while hoping it will go away.
    private static final int FAILURE_RETRY_COUNT = 10;

    // Current host name
    private static String currentHost = null;
    private static java.net.InetAddress currentAddr = null;
    static {
        // Find the current host name
        try {
            currentAddr = java.net.InetAddress.getLocalHost();

            // Get hostname
            currentHost = currentAddr.getHostName();
        } catch (UnknownHostException e) {
        }
    }

    /** Constructor.
    */
    public LivelinkConnector() {
    }

    /** Tell the world what model this connector uses for getDocumentIdentifiers().
    * This must return a model value as specified above.
    *@return the model type value.
    */
    @Override
    public int getConnectorModel() {
        // Livelink is a chained hierarchy model
        return MODEL_CHAINED_ADD_CHANGE;
    }

    /** Connect.  The configuration parameters are included.
    *@param configParams are the configuration parameters for this connection.
    */
    @Override
    public void connect(ConfigParams configParams) {
        super.connect(configParams);

        // This is required by getBins()
        serverName = params.getParameter(LiveLinkParameters.serverName);
    }

    protected class GetSessionThread extends Thread {
        protected Throwable exception = null;

        public GetSessionThread() {
            super();
            setDaemon(true);
        }

        public void run() {
            try {
                // Create the session
                llServer = new LLSERVER(!serverProtocol.equals("internal"), serverProtocol.equals("https"),
                        serverName, serverPort, serverUsername, serverPassword, serverHTTPCgi, serverHTTPNTLMDomain,
                        serverHTTPNTLMUsername, serverHTTPNTLMPassword, serverHTTPSKeystore);

                LLDocs = new LAPI_DOCUMENTS(llServer.getLLSession());
                LLAttributes = new LAPI_ATTRIBUTES(llServer.getLLSession());
                LLUsers = new LAPI_USERS(llServer.getLLSession());

                if (Logging.connectors.isDebugEnabled()) {
                    String passwordExists = (serverPassword != null && serverPassword.length() > 0)
                            ? "password exists"
                            : "";
                    Logging.connectors.debug("Livelink: Livelink Session: Server='" + serverName + "'; port='"
                            + serverPort + "'; user name='" + serverUsername + "'; " + passwordExists);
                }
                LLValue entinfo = new LLValue().setAssoc();

                int status;
                status = LLDocs.AccessEnterpriseWS(entinfo);
                if (status == 0) {
                    LLENTWK_ID = entinfo.toInteger("ID");
                    LLENTWK_VOL = entinfo.toInteger("VolumeID");
                } else
                    throw new ManifoldCFException("Error accessing enterprise workspace: " + status);

                entinfo = new LLValue().setAssoc();
                status = LLDocs.AccessCategoryWS(entinfo);
                if (status == 0) {
                    LLCATWK_ID = entinfo.toInteger("ID");
                    LLCATWK_VOL = entinfo.toInteger("VolumeID");
                } else
                    throw new ManifoldCFException("Error accessing category workspace: " + status);
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public void finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
        }

    }

    /** Get the bin name string for a document identifier.  The bin name describes the queue to which the
    * document will be assigned for throttling purposes.  Throttling controls the rate at which items in a
    * given queue are fetched; it does not say anything about the overall fetch rate, which may operate on
    * multiple queues or bins.
    * For example, if you implement a web crawler, a good choice of bin name would be the server name, since
    * that is likely to correspond to a real resource that will need real throttle protection.
    *@param documentIdentifier is the document identifier.
    *@return the bin name.
    */
    @Override
    public String[] getBinNames(String documentIdentifier) {
        // This should return server name
        return new String[] { serverName };
    }

    protected HttpHost getHost() {
        return new HttpHost(llServer.getHost(), ingestPortNumber, ingestProtocol);
    }

    protected void getSessionParameters() throws ManifoldCFException {
        if (hasSessionParameters == false) {
            // Do the initial setup part (what used to be part of connect() itself)

            // Get the parameters
            ingestProtocol = params.getParameter(LiveLinkParameters.ingestProtocol);
            ingestPort = params.getParameter(LiveLinkParameters.ingestPort);
            ingestCgiPath = params.getParameter(LiveLinkParameters.ingestCgiPath);

            viewProtocol = params.getParameter(LiveLinkParameters.viewProtocol);
            viewServerName = params.getParameter(LiveLinkParameters.viewServerName);
            viewPort = params.getParameter(LiveLinkParameters.viewPort);
            viewCgiPath = params.getParameter(LiveLinkParameters.viewCgiPath);

            ingestNtlmDomain = params.getParameter(LiveLinkParameters.ingestNtlmDomain);
            ingestNtlmUsername = params.getParameter(LiveLinkParameters.ingestNtlmUsername);
            ingestNtlmPassword = params.getObfuscatedParameter(LiveLinkParameters.ingestNtlmPassword);

            serverProtocol = params.getParameter(LiveLinkParameters.serverProtocol);
            String serverPortString = params.getParameter(LiveLinkParameters.serverPort);
            serverUsername = params.getParameter(LiveLinkParameters.serverUsername);
            serverPassword = params.getObfuscatedParameter(LiveLinkParameters.serverPassword);
            serverHTTPCgi = params.getParameter(LiveLinkParameters.serverHTTPCgiPath);
            serverHTTPNTLMDomain = params.getParameter(LiveLinkParameters.serverHTTPNTLMDomain);
            serverHTTPNTLMUsername = params.getParameter(LiveLinkParameters.serverHTTPNTLMUsername);
            serverHTTPNTLMPassword = params.getObfuscatedParameter(LiveLinkParameters.serverHTTPNTLMPassword);

            if (ingestProtocol == null || ingestProtocol.length() == 0)
                ingestProtocol = null;
            if (viewProtocol == null || viewProtocol.length() == 0) {
                if (ingestProtocol == null)
                    viewProtocol = "http";
                else
                    viewProtocol = ingestProtocol;
            }

            if (ingestPort == null || ingestPort.length() == 0) {
                if (ingestProtocol != null) {
                    if (!ingestProtocol.equals("https"))
                        ingestPort = "80";
                    else
                        ingestPort = "443";
                } else
                    ingestPort = null;
            }

            if (viewPort == null || viewPort.length() == 0) {
                if (ingestProtocol == null || !viewProtocol.equals(ingestProtocol)) {
                    if (!viewProtocol.equals("https"))
                        viewPort = "80";
                    else
                        viewPort = "443";
                } else
                    viewPort = ingestPort;
            }

            if (ingestPort != null) {
                try {
                    ingestPortNumber = Integer.parseInt(ingestPort);
                } catch (NumberFormatException e) {
                    throw new ManifoldCFException("Bad ingest port: " + e.getMessage(), e);
                }
            }

            String viewPortString;
            try {
                int portNumber = Integer.parseInt(viewPort);
                viewPortString = ":" + Integer.toString(portNumber);
                if (!viewProtocol.equals("https")) {
                    if (portNumber == 80)
                        viewPortString = "";
                } else {
                    if (portNumber == 443)
                        viewPortString = "";
                }
            } catch (NumberFormatException e) {
                throw new ManifoldCFException("Bad view port: " + e.getMessage(), e);
            }

            if (viewCgiPath == null || viewCgiPath.length() == 0)
                viewCgiPath = ingestCgiPath;

            if (ingestNtlmDomain != null && ingestNtlmDomain.length() == 0)
                ingestNtlmDomain = null;
            if (ingestNtlmDomain == null) {
                ingestNtlmUsername = null;
                ingestNtlmPassword = null;
            } else {
                if (ingestNtlmUsername == null || ingestNtlmUsername.length() == 0) {
                    ingestNtlmUsername = serverUsername;
                    if (ingestNtlmPassword == null || ingestNtlmPassword.length() == 0)
                        ingestNtlmPassword = serverPassword;
                } else {
                    if (ingestNtlmPassword == null)
                        ingestNtlmPassword = "";
                }
            }

            // Set up ingest ssl if indicated
            String ingestKeystoreData = params.getParameter(LiveLinkParameters.ingestKeystore);
            if (ingestKeystoreData != null)
                ingestKeystoreManager = KeystoreManagerFactory.make("", ingestKeystoreData);

            // Server parameter processing

            if (serverProtocol == null || serverProtocol.length() == 0)
                serverProtocol = "internal";

            if (serverPortString == null)
                serverPort = 2099;
            else
                serverPort = new Integer(serverPortString).intValue();

            if (serverHTTPNTLMDomain != null && serverHTTPNTLMDomain.length() == 0)
                serverHTTPNTLMDomain = null;
            if (serverHTTPNTLMUsername == null || serverHTTPNTLMUsername.length() == 0) {
                serverHTTPNTLMUsername = null;
                serverHTTPNTLMPassword = null;
            }

            // Set up server ssl if indicated
            String serverHTTPSKeystoreData = params.getParameter(LiveLinkParameters.serverHTTPSKeystore);
            if (serverHTTPSKeystoreData != null)
                serverHTTPSKeystore = KeystoreManagerFactory.make("", serverHTTPSKeystoreData);

            // View parameters
            if (viewServerName == null || viewServerName.length() == 0)
                viewServerName = serverName;

            viewBasePath = viewProtocol + "://" + viewServerName + viewPortString + viewCgiPath;

            hasSessionParameters = true;
        }
    }

    protected void getSession() throws ManifoldCFException, ServiceInterruption {
        getSessionParameters();
        if (hasConnected == false) {
            int socketTimeout = 900000;
            int connectionTimeout = 300000;

            // Set up connection manager
            connectionManager = new PoolingHttpClientConnectionManager();

            CredentialsProvider credentialsProvider = new BasicCredentialsProvider();

            // Set up ingest ssl if indicated
            SSLConnectionSocketFactory myFactory = null;
            if (ingestKeystoreManager != null) {
                myFactory = new SSLConnectionSocketFactory(
                        new InterruptibleSocketFactory(ingestKeystoreManager.getSecureSocketFactory(),
                                connectionTimeout),
                        new BrowserCompatHostnameVerifier());
            }

            // Set up authentication to use
            if (ingestNtlmDomain != null) {
                credentialsProvider.setCredentials(AuthScope.ANY,
                        new NTCredentials(ingestNtlmUsername, ingestNtlmPassword, currentHost, ingestNtlmDomain));
            }

            HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager)
                    .setMaxConnTotal(1).disableAutomaticRetries()
                    .setDefaultRequestConfig(RequestConfig.custom().setCircularRedirectsAllowed(true)
                            .setSocketTimeout(socketTimeout).setStaleConnectionCheckEnabled(true)
                            .setExpectContinueEnabled(true).setConnectTimeout(connectionTimeout)
                            .setConnectionRequestTimeout(socketTimeout).build())
                    .setDefaultSocketConfig(
                            SocketConfig.custom().setTcpNoDelay(true).setSoTimeout(socketTimeout).build())
                    .setDefaultCredentialsProvider(credentialsProvider)
                    .setRequestExecutor(new HttpRequestExecutor(socketTimeout))
                    .setRedirectStrategy(new DefaultRedirectStrategy());

            if (myFactory != null)
                builder.setSSLSocketFactory(myFactory);

            httpClient = builder.build();

            // System.out.println("Connection server object = "+llServer.toString());

            // Establish the actual connection
            int sanityRetryCount = FAILURE_RETRY_COUNT;
            while (true) {
                GetSessionThread t = new GetSessionThread();
                try {
                    t.start();
                    t.finishUp();
                    hasConnected = true;
                    break;
                } catch (InterruptedException e) {
                    t.interrupt();
                    throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                            ManifoldCFException.INTERRUPTED);
                } catch (RuntimeException e2) {
                    sanityRetryCount = handleLivelinkRuntimeException(e2, sanityRetryCount, true);
                }
            }
        }
        expirationTime = System.currentTimeMillis() + expirationInterval;
    }

    // All methods below this line will ONLY be called if a connect() call succeeded
    // on this instance!

    protected static int executeMethodViaThread(HttpClient client, HttpRequestBase executeMethod)
            throws InterruptedException, HttpException, IOException {
        ExecuteMethodThread t = new ExecuteMethodThread(client, executeMethod);
        t.start();
        try {
            return t.getResponseCode();
        } catch (InterruptedException e) {
            t.interrupt();
            throw e;
        } finally {
            t.abort();
            t.finishUp();
        }
    }

    /** Check status of connection.
    */
    @Override
    public String check() throws ManifoldCFException {
        try {
            // Destroy saved session setup and repeat it
            hasConnected = false;
            getSession();

            // Now, set up trial of ingestion connection
            if (ingestProtocol != null) {
                String contextMsg = "for document access";
                String ingestHttpAddress = ingestCgiPath;

                HttpClient client = getInitializedClient(contextMsg);
                HttpGet method = new HttpGet(getHost().toURI() + ingestHttpAddress);
                method.setHeader(new BasicHeader("Accept", "*/*"));
                try {
                    int statusCode = executeMethodViaThread(client, method);
                    switch (statusCode) {
                    case 502:
                        return "Fetch test had transient 502 error response";

                    case HttpStatus.SC_UNAUTHORIZED:
                        return "Fetch test returned UNAUTHORIZED (401) response; check the security credentials and configuration";

                    case HttpStatus.SC_OK:
                        return super.check();

                    default:
                        return "Fetch test returned an unexpected response code of " + Integer.toString(statusCode);
                    }
                } catch (InterruptedException e) {
                    throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                            ManifoldCFException.INTERRUPTED);
                } catch (java.net.SocketTimeoutException e) {
                    return "Fetch test timed out reading from the Livelink HTTP Server: " + e.getMessage();
                } catch (java.net.SocketException e) {
                    return "Fetch test received a socket error reading from Livelink HTTP Server: "
                            + e.getMessage();
                } catch (javax.net.ssl.SSLHandshakeException e) {
                    return "Fetch test was unable to set up a SSL connection to Livelink HTTP Server: "
                            + e.getMessage();
                } catch (ConnectTimeoutException e) {
                    return "Fetch test connection timed out reading from Livelink HTTP Server: " + e.getMessage();
                } catch (InterruptedIOException e) {
                    throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                            ManifoldCFException.INTERRUPTED);
                } catch (HttpException e) {
                    return "Fetch test had an HTTP exception: " + e.getMessage();
                } catch (IOException e) {
                    return "Fetch test had an IO failure: " + e.getMessage();
                }
            } else
                return super.check();
        } catch (ServiceInterruption e) {
            return "Transient error: " + e.getMessage();
        } catch (ManifoldCFException e) {
            if (e.getErrorCode() == ManifoldCFException.INTERRUPTED)
                throw e;
            return "Error: " + e.getMessage();
        }
    }

    /** This method is periodically called for all connectors that are connected but not
    * in active use.
    */
    @Override
    public void poll() throws ManifoldCFException {
        if (!hasConnected)
            return;

        long currentTime = System.currentTimeMillis();
        if (currentTime >= expirationTime) {
            hasConnected = false;
            expirationTime = -1L;

            // Shutdown livelink connection
            if (llServer != null) {
                llServer.disconnect();
                llServer = null;
            }

            // Shutdown pool
            if (connectionManager != null) {
                connectionManager.shutdown();
                connectionManager = null;
            }
        }
    }

    /** This method is called to assess whether to count this connector instance should
    * actually be counted as being connected.
    *@return true if the connector instance is actually connected.
    */
    @Override
    public boolean isConnected() {
        return hasConnected;
    }

    /** Close the connection.  Call this before discarding the repository connector.
    */
    @Override
    public void disconnect() throws ManifoldCFException {
        hasSessionParameters = false;
        hasConnected = false;
        expirationTime = -1L;
        if (llServer != null) {
            llServer.disconnect();
            llServer = null;
        }
        LLDocs = null;
        LLAttributes = null;
        ingestKeystoreManager = null;
        ingestPortNumber = -1;

        serverProtocol = null;
        serverName = null;
        serverPort = -1;
        serverUsername = null;
        serverPassword = null;
        serverHTTPCgi = null;
        serverHTTPNTLMDomain = null;
        serverHTTPNTLMUsername = null;
        serverHTTPNTLMPassword = null;
        serverHTTPSKeystore = null;

        ingestPort = null;
        ingestProtocol = null;
        ingestCgiPath = null;

        viewPort = null;
        viewServerName = null;
        viewProtocol = null;
        viewCgiPath = null;

        viewBasePath = null;

        ingestNtlmDomain = null;
        ingestNtlmUsername = null;
        ingestNtlmPassword = null;

        if (connectionManager != null) {
            connectionManager.shutdown();
            connectionManager = null;
        }

        super.disconnect();
    }

    /** List the activities we might report on.
    */
    @Override
    public String[] getActivitiesList() {
        return activitiesList;
    }

    /** Convert a document identifier to a relative URI to read data from.  This is not the search URI; that's constructed
    * by a different method.
    *@param documentIdentifier is the document identifier.
    *@return the relative document uri.
    */
    protected String convertToIngestURI(String documentIdentifier) throws ManifoldCFException {
        // The document identifier is the string form of the object ID for this connector.
        if (!documentIdentifier.startsWith("D"))
            return null;
        int colonPosition = documentIdentifier.indexOf(":", 1);
        if (colonPosition == -1)
            return ingestCgiPath + "?func=ll&objID=" + documentIdentifier.substring(1) + "&objAction=download";
        else
            return ingestCgiPath + "?func=ll&objID=" + documentIdentifier.substring(colonPosition + 1)
                    + "&objAction=download";
    }

    /** Convert a document identifier to a URI to view.  The URI is the URI that will be the unique key from
    * the search index, and will be presented to the user as part of the search results.  It must therefore
    * be a unique way of describing the document.
    *@param documentIdentifier is the document identifier.
    *@return the document uri.
    */
    protected String convertToViewURI(String documentIdentifier) throws ManifoldCFException {
        // The document identifier is the string form of the object ID for this connector.
        if (!documentIdentifier.startsWith("D"))
            return null;
        int colonPosition = documentIdentifier.indexOf(":", 1);
        if (colonPosition == -1)
            return viewBasePath + "?func=ll&objID=" + documentIdentifier.substring(1) + "&objAction=download";
        else
            return viewBasePath + "?func=ll&objID=" + documentIdentifier.substring(colonPosition + 1)
                    + "&objAction=download";
    }

    /** Request arbitrary connector information.
    * This method is called directly from the API in order to allow API users to perform any one of several connector-specific
    * queries.
    *@param output is the response object, to be filled in by this method.
    *@param command is the command, which is taken directly from the API request.
    *@return true if the resource is found, false if not.  In either case, output may be filled in.
    */
    @Override
    public boolean requestInfo(Configuration output, String command) throws ManifoldCFException {
        if (command.equals("workspaces")) {
            try {
                String[] workspaces = getWorkspaceNames();
                int i = 0;
                while (i < workspaces.length) {
                    String workspace = workspaces[i++];
                    ConfigurationNode node = new ConfigurationNode("workspace");
                    node.setValue(workspace);
                    output.addChild(output.getChildCount(), node);
                }
            } catch (ServiceInterruption e) {
                ManifoldCF.createServiceInterruptionNode(output, e);
            } catch (ManifoldCFException e) {
                ManifoldCF.createErrorNode(output, e);
            }
        } else if (command.startsWith("folders/")) {
            String path = command.substring("folders/".length());

            try {
                String[] folders = getChildFolderNames(path);
                int i = 0;
                while (i < folders.length) {
                    String folder = folders[i++];
                    ConfigurationNode node = new ConfigurationNode("folder");
                    node.setValue(folder);
                    output.addChild(output.getChildCount(), node);
                }
            } catch (ServiceInterruption e) {
                ManifoldCF.createServiceInterruptionNode(output, e);
            } catch (ManifoldCFException e) {
                ManifoldCF.createErrorNode(output, e);
            }
        } else if (command.startsWith("categories/")) {
            String path = command.substring("categories/".length());

            try {
                String[] categories = getChildCategoryNames(path);
                int i = 0;
                while (i < categories.length) {
                    String category = categories[i++];
                    ConfigurationNode node = new ConfigurationNode("category");
                    node.setValue(category);
                    output.addChild(output.getChildCount(), node);
                }
            } catch (ServiceInterruption e) {
                ManifoldCF.createServiceInterruptionNode(output, e);
            } catch (ManifoldCFException e) {
                ManifoldCF.createErrorNode(output, e);
            }

        } else if (command.startsWith("categoryattributes/")) {
            String path = command.substring("categoryattributes/".length());

            try {
                String[] attributes = getCategoryAttributes(path);
                int i = 0;
                while (i < attributes.length) {
                    String attribute = attributes[i++];
                    ConfigurationNode node = new ConfigurationNode("attribute");
                    node.setValue(attribute);
                    output.addChild(output.getChildCount(), node);
                }
            } catch (ServiceInterruption e) {
                ManifoldCF.createServiceInterruptionNode(output, e);
            } catch (ManifoldCFException e) {
                ManifoldCF.createErrorNode(output, e);
            }
        } else
            return super.requestInfo(output, command);
        return true;
    }

    /** Queue "seed" documents.  Seed documents are the starting places for crawling activity.  Documents
    * are seeded when this method calls appropriate methods in the passed in ISeedingActivity object.
    *
    * This method can choose to find repository changes that happen only during the specified time interval.
    * The seeds recorded by this method will be viewed by the framework based on what the
    * getConnectorModel() method returns.
    *
    * It is not a big problem if the connector chooses to create more seeds than are
    * strictly necessary; it is merely a question of overall work required.
    *
    * The end time and seeding version string passed to this method may be interpreted for greatest efficiency.
    * For continuous crawling jobs, this method will
    * be called once, when the job starts, and at various periodic intervals as the job executes.
    *
    * When a job's specification is changed, the framework automatically resets the seeding version string to null.  The
    * seeding version string may also be set to null on each job run, depending on the connector model returned by
    * getConnectorModel().
    *
    * Note that it is always ok to send MORE documents rather than less to this method.
    * The connector will be connected before this method can be called.
    *@param activities is the interface this method should use to perform whatever framework actions are desired.
    *@param spec is a document specification (that comes from the job).
    *@param seedTime is the end of the time range of documents to consider, exclusive.
    *@param lastSeedVersionString is the last seeding version string for this job, or null if the job has no previous seeding version string.
    *@param jobMode is an integer describing how the job is being run, whether continuous or once-only.
    *@return an updated seeding version string, to be stored with the job.
    */
    @Override
    public String addSeedDocuments(ISeedingActivity activities, Specification spec, String lastSeedVersion,
            long seedTime, int jobMode) throws ManifoldCFException, ServiceInterruption {
        getSession();
        LivelinkContext llc = new LivelinkContext();

        // First, grab the root LLValue
        ObjectInformation rootValue = llc.getObjectInformation(LLENTWK_VOL, LLENTWK_ID);
        if (!rootValue.exists()) {
            // If we get here, it HAS to be a bad network/transient problem.
            Logging.connectors
                    .warn("Livelink: Could not look up root workspace object during seeding!  Retrying -");
            throw new ServiceInterruption("Service interruption during seeding",
                    new ManifoldCFException("Could not looking root workspace object during seeding"),
                    System.currentTimeMillis() + 60000L, System.currentTimeMillis() + 600000L, -1, true);
        }

        // Walk the specification for the "startpoint" types.  Amalgamate these into a list of strings.
        // Presume that all roots are startpoint nodes
        boolean doUserWorkspaces = false;
        for (int i = 0; i < spec.getChildCount(); i++) {
            SpecificationNode n = spec.getChild(i);
            if (n.getType().equals("startpoint")) {
                // The id returned is simply the node path, which can't be messed up
                long beginTime = System.currentTimeMillis();
                String path = n.getAttributeValue("path");
                VolumeAndId vaf = rootValue.getPathId(path);
                if (vaf != null) {
                    activities.recordActivity(new Long(beginTime), ACTIVITY_SEED, null, path, "OK", null, null);

                    String newID = "F" + new Integer(vaf.getVolumeID()).toString() + ":"
                            + new Integer(vaf.getPathId()).toString();
                    activities.addSeedDocument(newID);
                    if (Logging.connectors.isDebugEnabled())
                        Logging.connectors.debug("Livelink: Seed = '" + newID + "'");
                } else {
                    activities.recordActivity(new Long(beginTime), ACTIVITY_SEED, null, path, "NOT FOUND", null,
                            null);
                }
            } else if (n.getType().equals("userworkspace")) {
                String value = n.getAttributeValue("value");
                if (value != null && value.equals("true"))
                    doUserWorkspaces = true;
                else if (value != null && value.equals("false"))
                    doUserWorkspaces = false;
            }

            if (doUserWorkspaces) {
                // Do ListUsers and enumerate the values.
                int sanityRetryCount = FAILURE_RETRY_COUNT;
                while (true) {
                    ListUsersThread t = new ListUsersThread();
                    try {
                        t.start();
                        LLValue childrenDocs;
                        try {
                            childrenDocs = t.finishUp();
                        } catch (ManifoldCFException e) {
                            sanityRetryCount = assessRetry(sanityRetryCount, e);
                            continue;
                        }

                        int size = 0;

                        if (childrenDocs.isRecord())
                            size = 1;
                        if (childrenDocs.isTable())
                            size = childrenDocs.size();

                        // Do the scan
                        for (int j = 0; j < size; j++) {
                            int childID = childrenDocs.toInteger(j, "ID");

                            // Skip admin user
                            if (childID == 1000 || childID == 1001)
                                continue;

                            if (Logging.connectors.isDebugEnabled())
                                Logging.connectors.debug("Livelink: Found a user: ID=" + Integer.toString(childID));

                            activities.addSeedDocument("F0:" + Integer.toString(childID));
                        }
                        break;
                    } catch (InterruptedException e) {
                        t.interrupt();
                        throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                ManifoldCFException.INTERRUPTED);
                    } catch (RuntimeException e) {
                        sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                        continue;
                    }
                }
            }

        }
        return "";
    }

    /** Process a set of documents.
    * This is the method that should cause each document to be fetched, processed, and the results either added
    * to the queue of documents for the current job, and/or entered into the incremental ingestion manager.
    * The document specification allows this class to filter what is done based on the job.
    * The connector will be connected before this method can be called.
    *@param documentIdentifiers is the set of document identifiers to process.
    *@param statuses are the currently-stored document versions for each document in the set of document identifiers
    * passed in above.
    *@param activities is the interface this method should use to queue up new document references
    * and ingest documents.
    *@param jobMode is an integer describing how the job is being run, whether continuous or once-only.
    *@param usesDefaultAuthority will be true only if the authority in use for these documents is the default one.
    */
    @Override
    public void processDocuments(String[] documentIdentifiers, IExistingVersions statuses, Specification spec,
            IProcessActivity activities, int jobMode, boolean usesDefaultAuthority)
            throws ManifoldCFException, ServiceInterruption {

        // Initialize a "livelink context", to minimize the number of objects we have to fetch
        LivelinkContext llc = new LivelinkContext();
        // Initialize the table of catid's.
        // Keeping this around will allow us to benefit from batching of documents.
        MetadataDescription desc = new MetadataDescription(llc);

        // First, process the spec to get the string we tack on
        SystemMetadataDescription sDesc = new SystemMetadataDescription(llc, spec);

        // Read the forced acls.  A null return indicates that security is disabled!!!
        // A zero-length return indicates that the native acls should be used.
        // All of this is germane to how we ingest the document, so we need to note it in
        // the version string completely.
        String[] acls = sDesc.getAcls();
        // Sort it, in case it is needed.
        if (acls != null)
            java.util.Arrays.sort(acls);

        // Prepare the specified metadata
        String metadataString = null;
        String[] specifiedMetadataAttributes = null;
        CategoryPathAccumulator catAccum = null;
        if (!sDesc.includeAllMetadata()) {
            StringBuilder sb = new StringBuilder();
            specifiedMetadataAttributes = sDesc.getMetadataAttributes();
            // Sort!
            java.util.Arrays.sort(specifiedMetadataAttributes);
            // Build the metadata string piece now
            packList(sb, specifiedMetadataAttributes, '+');
            metadataString = sb.toString();
        } else
            catAccum = new CategoryPathAccumulator(llc);

        // Calculate the part of the version string that comes from path name and mapping.
        // This starts with = since ; is used by another optional component (the forced acls)
        String pathNameAttributeVersion;
        StringBuilder sb2 = new StringBuilder();
        if (sDesc.getPathAttributeName() != null)
            sb2.append("=").append(sDesc.getPathAttributeName()).append(":").append(sDesc.getPathSeparator())
                    .append(":").append(sDesc.getMatchMapString());
        pathNameAttributeVersion = sb2.toString();

        // Since the identifier indicates it is a directory, then queue up all the current children which pass the filter.
        String filterString = sDesc.getFilterString();

        for (String documentIdentifier : documentIdentifiers) {
            // Since each livelink access is time-consuming, be sure that we abort if the job has gone inactive
            activities.checkJobStillActive();

            // Read the document or folder metadata, which includes the ModifyDate
            String docID = documentIdentifier;

            boolean isFolder = docID.startsWith("F");

            int colonPos = docID.indexOf(":", 1);

            int objID;
            int vol;

            if (colonPos == -1) {
                objID = new Integer(docID.substring(1)).intValue();
                vol = LLENTWK_VOL;
            } else {
                objID = new Integer(docID.substring(colonPos + 1)).intValue();
                vol = new Integer(docID.substring(1, colonPos)).intValue();
            }

            getSession();
            ObjectInformation value = llc.getObjectInformation(vol, objID);
            if (!value.exists()) {
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Object " + Integer.toString(vol) + ":"
                            + Integer.toString(objID) + " has no information - deleting");
                activities.deleteDocument(documentIdentifier);
                continue;
            }

            // Make sure we have permission to see the object's contents
            int permissions = value.getPermissions().intValue();
            if ((permissions & LAPI_DOCUMENTS.PERM_SEECONTENTS) == 0) {
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Crawl user cannot see contents of object "
                            + Integer.toString(vol) + ":" + Integer.toString(objID) + " - deleting");
                activities.deleteDocument(documentIdentifier);
                continue;
            }

            Date dt = value.getModifyDate();
            // The rights don't change when the object changes, so we have to include those too.
            int[] rights = getObjectRights(vol, objID);
            if (rights == null) {
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Could not get rights for object " + Integer.toString(vol)
                            + ":" + Integer.toString(objID) + " - deleting");
                activities.deleteDocument(documentIdentifier);
                continue;
            }

            // We were able to get rights, so object still exists.

            // Changed folder versioning for MCF 2.0
            if (isFolder) {
                // === Livelink folder ===
                // I'm still not sure if Livelink folder modified dates are one-level or hierarchical.
                // The code below assumes one-level only, so we always scan folders and there's no versioning
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug(
                            "Livelink: Processing folder " + Integer.toString(vol) + ":" + Integer.toString(objID));

                int sanityRetryCount = FAILURE_RETRY_COUNT;
                while (true) {
                    ListObjectsThread t = new ListObjectsThread(vol, objID, filterString);
                    try {
                        t.start();
                        LLValue childrenDocs;
                        try {
                            childrenDocs = t.finishUp();
                        } catch (ManifoldCFException e) {
                            sanityRetryCount = assessRetry(sanityRetryCount, e);
                            continue;
                        }

                        int size = 0;

                        if (childrenDocs.isRecord())
                            size = 1;
                        if (childrenDocs.isTable())
                            size = childrenDocs.size();

                        // System.out.println("Total child count = "+Integer.toString(size));
                        // Do the scan
                        for (int j = 0; j < size; j++) {
                            int childID = childrenDocs.toInteger(j, "ID");

                            if (Logging.connectors.isDebugEnabled())
                                Logging.connectors
                                        .debug("Livelink: Found a child of folder " + Integer.toString(vol) + ":"
                                                + Integer.toString(objID) + " : ID=" + Integer.toString(childID));

                            int subtype = childrenDocs.toInteger(j, "SubType");
                            boolean childIsFolder = (subtype == LAPI_DOCUMENTS.FOLDERSUBTYPE
                                    || subtype == LAPI_DOCUMENTS.PROJECTSUBTYPE
                                    || subtype == LAPI_DOCUMENTS.COMPOUNDDOCUMENTSUBTYPE);

                            // If it's a folder, we just let it through for now
                            if (!childIsFolder && checkInclude(
                                    childrenDocs.toString(j, "Name") + "." + childrenDocs.toString(j, "FileType"),
                                    spec) == false) {
                                if (Logging.connectors.isDebugEnabled())
                                    Logging.connectors.debug("Livelink: Child identifier "
                                            + Integer.toString(childID) + " was excluded by inclusion criteria");
                                continue;
                            }

                            if (childIsFolder) {
                                if (Logging.connectors.isDebugEnabled())
                                    Logging.connectors.debug("Livelink: Child identifier "
                                            + Integer.toString(childID)
                                            + " is a folder, project, or compound document; adding a reference");
                                if (subtype == LAPI_DOCUMENTS.PROJECTSUBTYPE) {
                                    // If we pick up a project object, we need to describe the volume object (which
                                    // will be the root of all documents beneath)
                                    activities.addDocumentReference("F" + new Integer(childID).toString() + ":"
                                            + new Integer(-childID).toString());
                                } else
                                    activities.addDocumentReference("F" + new Integer(vol).toString() + ":"
                                            + new Integer(childID).toString());
                            } else {
                                if (Logging.connectors.isDebugEnabled())
                                    Logging.connectors
                                            .debug("Livelink: Child identifier " + Integer.toString(childID)
                                                    + " is a simple document; adding a reference");

                                activities.addDocumentReference(
                                        "D" + new Integer(vol).toString() + ":" + new Integer(childID).toString());
                            }

                        }
                        break;
                    } catch (InterruptedException e) {
                        t.interrupt();
                        throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                ManifoldCFException.INTERRUPTED);
                    } catch (RuntimeException e) {
                        sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                        continue;
                    }
                }
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Done processing folder " + Integer.toString(vol) + ":"
                            + Integer.toString(objID));
            } else {
                // === Livelink document ===

                // The version string includes the following:
                // 1) The modify date for the document
                // 2) The rights for the document, ordered (which can change without changing the ModifyDate field)
                // 3) The requested metadata fields (category and attribute, ordered) for the document
                //
                // The document identifiers are object id's.

                StringBuilder sb = new StringBuilder();

                String[] categoryPaths;
                if (sDesc.includeAllMetadata()) {
                    // Find all the metadata associated with this object, and then
                    // find the set of category pathnames that correspond to it.
                    int[] catIDs = getObjectCategoryIDs(vol, objID);
                    categoryPaths = catAccum.getCategoryPathsAttributeNames(catIDs);
                    // Sort!
                    java.util.Arrays.sort(categoryPaths);
                    // Build the metadata string piece now
                    packList(sb, categoryPaths, '+');
                } else {
                    categoryPaths = specifiedMetadataAttributes;
                    sb.append(metadataString);
                }

                String[] actualAcls;
                String[] denyAcls;

                String denyAcl;
                if (acls != null && acls.length == 0) {
                    // No forced acls.  Read the actual acls from livelink, as a set of rights.
                    // We need also to add in support for the special rights objects.  These are:
                    // -1: RIGHT_WORLD
                    // -2: RIGHT_SYSTEM
                    // -3: RIGHT_OWNER
                    // -4: RIGHT_GROUP
                    //
                    // RIGHT_WORLD means guest access.
                    // RIGHT_SYSTEM is "Public Access".
                    // RIGHT_OWNER is access by the owner of the object.
                    // RIGHT_GROUP is access by a member of the base group containing the owner
                    //
                    // These objects are returned by the GetObjectRights() call made above, and NOT
                    // returned by LLUser.ListObjects().  We have to figure out how to map these to
                    // things that are
                    // the equivalent of acls.

                    actualAcls = lookupTokens(rights, value);
                    java.util.Arrays.sort(actualAcls);
                    // If security is on, no deny acl is needed for the local authority, since the repository does not support "deny".  But this was added
                    // to be really really really sure.
                    denyAcl = defaultAuthorityDenyToken;

                } else if (acls != null && acls.length > 0) {
                    // Forced acls
                    actualAcls = acls;
                    denyAcl = defaultAuthorityDenyToken;
                } else {
                    // Security is OFF
                    actualAcls = acls;
                    denyAcl = null;
                }

                // Now encode the acls.  If null, we write a special value.
                if (actualAcls == null) {
                    sb.append('-');
                    denyAcls = null;
                } else {
                    sb.append('+');
                    packList(sb, actualAcls, '+');
                    // This was added on 4/21/2008 to support forced acls working with the global default authority.
                    pack(sb, denyAcl, '+');
                    denyAcls = new String[] { denyAcl };
                }

                // The date does not need to be parseable
                sb.append(new Long(dt.getTime()).toString());

                // PathNameAttributeVersion comes completely from the spec, so we don't
                // have to worry about it changing.  No need, therefore, to parse it during
                // processDocuments.
                sb.append("=").append(pathNameAttributeVersion);

                // Tack on ingestCgiPath, to insulate us against changes to the repository connection setup.  Added 9/7/07.
                sb.append("_").append(viewBasePath);

                String versionString = sb.toString();
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Successfully calculated version string for document "
                            + Integer.toString(vol) + ":" + Integer.toString(objID) + " : '" + versionString + "'");

                if (!activities.checkDocumentNeedsReindexing(documentIdentifier, versionString))
                    continue;

                // Index the document
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Processing document " + Integer.toString(vol) + ":"
                            + Integer.toString(objID));
                if (!checkIngest(llc, objID, spec)) {
                    if (Logging.connectors.isDebugEnabled())
                        Logging.connectors.debug("Livelink: Decided not to ingest document " + Integer.toString(vol)
                                + ":" + Integer.toString(objID) + " - Did not match ingestion criteria");
                    activities.noDocument(documentIdentifier, versionString);
                    continue;
                }

                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Decided to ingest document " + Integer.toString(vol) + ":"
                            + Integer.toString(objID));

                // Grab the access tokens for this file from the version string, inside ingest method.
                ingestFromLiveLink(llc, documentIdentifier, versionString, actualAcls, denyAcls, categoryPaths,
                        activities, desc, sDesc);

                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Done processing document " + Integer.toString(vol) + ":"
                            + Integer.toString(objID));
            }
        }
    }

    protected class ListObjectsThread extends Thread {
        protected final int vol;
        protected final int objID;
        protected final String filterString;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public ListObjectsThread(int vol, int objID, String filterString) {
            super();
            setDaemon(true);
            this.vol = vol;
            this.objID = objID;
            this.filterString = filterString;
        }

        public void run() {
            try {
                LLValue childrenDocs = new LLValue();
                int status = LLDocs.ListObjects(vol, objID, null, filterString, LAPI_DOCUMENTS.PERM_SEECONTENTS,
                        childrenDocs);
                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving contents of folder " + Integer.toString(vol)
                            + ":" + Integer.toString(objID) + " : Status=" + Integer.toString(status) + " ("
                            + llServer.getErrors() + ")");
                }
                rval = childrenDocs;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }
    }

    /** Get the maximum number of documents to amalgamate together into one batch, for this connector.
    *@return the maximum number. 0 indicates "unlimited".
    */
    @Override
    public int getMaxDocumentRequest() {
        // Intrinsically, Livelink doesn't batch well.  Multiple chunks have no advantage over one-at-a-time requests,
        // since apparently the Livelink API does not support multiples.  HOWEVER - when metadata is considered,
        // it becomes worthwhile, because we will be able to do what is needed to look up the correct CATID node
        // only once per n requests!  So it's a tradeoff between the advantage gained by threading, and the
        // savings gained by CATID lookup.
        // Note that at Shell, the fact that the network hiccups a lot makes it better to choose a smaller value.
        return 6;
    }

    // UI support methods.
    //
    // These support methods come in two varieties.  The first bunch is involved in setting up connection configuration information.  The second bunch
    // is involved in presenting and editing document specification information for a job.  The two kinds of methods are accordingly treated differently,
    // in that the first bunch cannot assume that the current connector object is connected, while the second bunch can.  That is why the first bunch
    // receives a thread context argument for all UI methods, while the second bunch does not need one (since it has already been applied via the connect()
    // method, above).

    /** Output the configuration header section.
    * This method is called in the head section of the connector's configuration page.  Its purpose is to add the required tabs to the list, and to output any
    * javascript methods that might be needed by the configuration editing HTML.
    *@param threadContext is the local thread context.
    *@param out is the output to which any HTML should be sent.
    *@param parameters are the configuration parameters, as they currently exist, for this connection being configured.
    *@param tabsArray is an array of tab names.  Add to this array any tab names that are specific to the connector.
    */
    @Override
    public void outputConfigurationHeader(IThreadContext threadContext, IHTTPOutput out, Locale locale,
            ConfigParams parameters, List<String> tabsArray) throws ManifoldCFException, IOException {
        tabsArray.add(Messages.getString(locale, "LivelinkConnector.Server"));
        tabsArray.add(Messages.getString(locale, "LivelinkConnector.DocumentAccess"));
        tabsArray.add(Messages.getString(locale, "LivelinkConnector.DocumentView"));
        out.print("<script type=\"text/javascript\">\n" + "<!--\n" + "function ServerDeleteCertificate(aliasName)\n"
                + "{\n" + "  editconnection.serverkeystorealias.value = aliasName;\n"
                + "  editconnection.serverconfigop.value = \"Delete\";\n" + "  postForm();\n" + "}\n" + "\n"
                + "function ServerAddCertificate()\n" + "{\n"
                + "  if (editconnection.servercertificate.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.ChooseACertificateFile") + "\");\n"
                + "    editconnection.servercertificate.focus();\n" + "  }\n" + "  else\n" + "  {\n"
                + "    editconnection.serverconfigop.value = \"Add\";\n" + "    postForm();\n" + "  }\n" + "}\n"
                + "\n" + "function IngestDeleteCertificate(aliasName)\n" + "{\n"
                + "  editconnection.ingestkeystorealias.value = aliasName;\n"
                + "  editconnection.ingestconfigop.value = \"Delete\";\n" + "  postForm();\n" + "}\n" + "\n"
                + "function IngestAddCertificate()\n" + "{\n"
                + "  if (editconnection.ingestcertificate.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.ChooseACertificateFile") + "\");\n"
                + "    editconnection.ingestcertificate.focus();\n" + "  }\n" + "  else\n" + "  {\n"
                + "    editconnection.ingestconfigop.value = \"Add\";\n" + "    postForm();\n" + "  }\n" + "}\n"
                + "\n" + "function checkConfig()\n" + "{\n"
                + "  if (editconnection.serverport.value != \"\" && !isInteger(editconnection.serverport.value))\n"
                + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.AValidNumberIsRequired") + "\");\n"
                + "    editconnection.serverport.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.ingestport.value != \"\" && !isInteger(editconnection.ingestport.value))\n"
                + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.AValidNumberOrBlankIsRequired")
                + "\");\n" + "    editconnection.ingestport.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.viewport.value != \"\" && !isInteger(editconnection.viewport.value))\n"
                + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.AValidNumberOrBlankIsRequired")
                + "\");\n" + "    editconnection.viewport.focus();\n" + "    return false;\n" + "  }\n"
                + "  return true;\n" + "}\n" + "\n" + "function checkConfigForSave()\n" + "{\n"
                + "  if (editconnection.servername.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.EnterALivelinkServerName") + "\");\n"
                + "    SelectTab(\"" + Messages.getBodyJavascriptString(locale, "LivelinkConnector.Server")
                + "\");\n" + "    editconnection.servername.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.serverport.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.AServerPortNumberIsRequired")
                + "\");\n" + "    SelectTab(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.Server") + "\");\n"
                + "    editconnection.serverport.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.serverhttpcgipath.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.EnterTheServerCgiPathToLivelink")
                + "\");\n" + "    SelectTab(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.Server") + "\");\n"
                + "    editconnection.serverhttpcgipath.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.serverhttpcgipath.value.substring(0,1) != \"/\")\n" + "  {\n"
                + "    alert(\""
                + Messages.getBodyJavascriptString(locale,
                        "LivelinkConnector.TheServerCgiPathMustBeginWithACharacter")
                + "\");\n" + "    SelectTab(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.Server") + "\");\n"
                + "    editconnection.serverhttpcgipath.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.viewprotocol.value == \"\" && editconnection.ingestprotocol.value == \"\")\n"
                + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.SelectAViewProtocol") + "\");\n"
                + "    SelectTab(\"" + Messages.getBodyJavascriptString(locale, "LivelinkConnector.DocumentView")
                + "\");\n" + "    editconnection.viewprotocol.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.viewcgipath.value == \"\" && editconnection.ingestcgipath.value == \"\")\n"
                + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.EnterTheViewCgiPathToLivelink")
                + "\");\n" + "    SelectTab(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.DocumentView") + "\");\n"
                + "    editconnection.viewcgipath.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.ingestcgipath.value != \"\" && editconnection.ingestcgipath.value.substring(0,1) != \"/\")\n"
                + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale,
                        "LivelinkConnector.TheIngestCgiPathMustBeBlankOrBeginWithACharacter")
                + "\");\n" + "    SelectTab(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.DocumentAccess") + "\");\n"
                + "    editconnection.ingestcgipath.focus();\n" + "    return false;\n" + "  }\n"
                + "  if (editconnection.viewcgipath.value != \"\" && editconnection.viewcgipath.value.substring(0,1) != \"/\")\n"
                + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale,
                        "LivelinkConnector.TheViewCgiPathMustBeBlankOrBeginWithACharacter")
                + "\");\n" + "    SelectTab(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.DocumentView") + "\");\n"
                + "    editconnection.viewcgipath.focus();\n" + "    return false;\n" + "  }\n" + "  return true;\n"
                + "}\n" + "\n" + "//-->\n" + "</script>\n");
    }

    /** Output the configuration body section.
    * This method is called in the body section of the connector's configuration page.  Its purpose is to present the required form elements for editing.
    * The coder can presume that the HTML that is output from this configuration will be within appropriate <html>, <body>, and <form> tags.  The name of the
    * form is "editconnection".
    *@param threadContext is the local thread context.
    *@param out is the output to which any HTML should be sent.
    *@param parameters are the configuration parameters, as they currently exist, for this connection being configured.
    *@param tabName is the current tab name.
    */
    @Override
    public void outputConfigurationBody(IThreadContext threadContext, IHTTPOutput out, Locale locale,
            ConfigParams parameters, String tabName) throws ManifoldCFException, IOException {

        // LAPI parameters
        String serverProtocol = parameters.getParameter(LiveLinkParameters.serverProtocol);
        if (serverProtocol == null)
            serverProtocol = "internal";
        String serverName = parameters.getParameter(LiveLinkParameters.serverName);
        if (serverName == null)
            serverName = "localhost";
        String serverPort = parameters.getParameter(LiveLinkParameters.serverPort);
        if (serverPort == null)
            serverPort = "2099";
        String serverUserName = parameters.getParameter(LiveLinkParameters.serverUsername);
        if (serverUserName == null)
            serverUserName = "";
        String serverPassword = parameters.getObfuscatedParameter(LiveLinkParameters.serverPassword);
        if (serverPassword == null)
            serverPassword = "";
        else
            serverPassword = out.mapPasswordToKey(serverPassword);
        String serverHTTPCgiPath = parameters.getParameter(LiveLinkParameters.serverHTTPCgiPath);
        if (serverHTTPCgiPath == null)
            serverHTTPCgiPath = "/livelink/livelink.exe";
        String serverHTTPNTLMDomain = parameters.getParameter(LiveLinkParameters.serverHTTPNTLMDomain);
        if (serverHTTPNTLMDomain == null)
            serverHTTPNTLMDomain = "";
        String serverHTTPNTLMUserName = parameters.getParameter(LiveLinkParameters.serverHTTPNTLMUsername);
        if (serverHTTPNTLMUserName == null)
            serverHTTPNTLMUserName = "";
        String serverHTTPNTLMPassword = parameters
                .getObfuscatedParameter(LiveLinkParameters.serverHTTPNTLMPassword);
        if (serverHTTPNTLMPassword == null)
            serverHTTPNTLMPassword = "";
        else
            serverHTTPNTLMPassword = out.mapPasswordToKey(serverHTTPNTLMPassword);
        String serverHTTPSKeystore = parameters.getParameter(LiveLinkParameters.serverHTTPSKeystore);
        IKeystoreManager localServerHTTPSKeystore;
        if (serverHTTPSKeystore == null)
            localServerHTTPSKeystore = KeystoreManagerFactory.make("");
        else
            localServerHTTPSKeystore = KeystoreManagerFactory.make("", serverHTTPSKeystore);

        // Document access parameters
        String ingestProtocol = parameters.getParameter(LiveLinkParameters.ingestProtocol);
        if (ingestProtocol == null)
            ingestProtocol = "";
        String ingestPort = parameters.getParameter(LiveLinkParameters.ingestPort);
        if (ingestPort == null)
            ingestPort = "";
        String ingestCgiPath = parameters.getParameter(LiveLinkParameters.ingestCgiPath);
        if (ingestCgiPath == null)
            ingestCgiPath = "";
        String ingestNtlmUsername = parameters.getParameter(LiveLinkParameters.ingestNtlmUsername);
        if (ingestNtlmUsername == null)
            ingestNtlmUsername = "";
        String ingestNtlmPassword = parameters.getObfuscatedParameter(LiveLinkParameters.ingestNtlmPassword);
        if (ingestNtlmPassword == null)
            ingestNtlmPassword = "";
        else
            ingestNtlmPassword = out.mapPasswordToKey(ingestNtlmPassword);
        String ingestNtlmDomain = parameters.getParameter(LiveLinkParameters.ingestNtlmDomain);
        if (ingestNtlmDomain == null)
            ingestNtlmDomain = "";
        String ingestKeystore = parameters.getParameter(LiveLinkParameters.ingestKeystore);
        IKeystoreManager localIngestKeystore;
        if (ingestKeystore == null)
            localIngestKeystore = KeystoreManagerFactory.make("");
        else
            localIngestKeystore = KeystoreManagerFactory.make("", ingestKeystore);

        // Document view parameters
        String viewProtocol = parameters.getParameter(LiveLinkParameters.viewProtocol);
        if (viewProtocol == null)
            viewProtocol = "http";
        String viewServerName = parameters.getParameter(LiveLinkParameters.viewServerName);
        if (viewServerName == null)
            viewServerName = "";
        String viewPort = parameters.getParameter(LiveLinkParameters.viewPort);
        if (viewPort == null)
            viewPort = "";
        String viewCgiPath = parameters.getParameter(LiveLinkParameters.viewCgiPath);
        if (viewCgiPath == null)
            viewCgiPath = "/livelink/livelink.exe";

        // The "Server" tab
        // Always pass the whole keystore as a hidden.
        out.print("<input name=\"serverconfigop\" type=\"hidden\" value=\"Continue\"/>\n");
        if (serverHTTPSKeystore != null) {
            out.print("<input type=\"hidden\" name=\"serverhttpskeystoredata\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPSKeystore) + "\"/>\n");
        }
        if (tabName.equals(Messages.getString(locale, "LivelinkConnector.Server"))) {
            out.print("<table class=\"displaytable\">\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerProtocol") + "</td>\n"
                    + "    <td class=\"value\">\n" + "      <select name=\"serverprotocol\" size=\"2\">\n"
                    + "        <option value=\"internal\" "
                    + ((serverProtocol.equals("internal")) ? "selected=\"selected\"" : "") + ">"
                    + Messages.getBodyString(locale, "LivelinkConnector.internal") + "</option>\n"
                    + "        <option value=\"http\" "
                    + ((serverProtocol.equals("http")) ? "selected=\"selected\"" : "") + ">http</option>\n"
                    + "        <option value=\"https\" "
                    + ((serverProtocol.equals("https")) ? "selected=\"selected\"" : "") + ">https</option>\n"
                    + "      </select>\n" + "    </td>\n" + "  </tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerName") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"64\" name=\"servername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverName) + "\"/></td>\n"
                    + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerPort") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"5\" name=\"serverport\" value=\""
                    + serverPort + "\"/></td>\n" + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerUserName") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"32\" name=\"serverusername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverUserName) + "\"/></td>\n"
                    + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerPassword") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"password\" size=\"32\" name=\"serverpassword\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverPassword) + "\"/></td>\n"
                    + "  </tr>\n" + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerHTTPCGIPath") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"32\" name=\"serverhttpcgipath\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPCgiPath) + "\"/></td>\n"
                    + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerHTTPNTLMDomain")
                    + "</nobr><br/><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.SetIfNTLMAuthDesired") + "</nobr></td>\n"
                    + "    <td class=\"value\">\n"
                    + "      <input type=\"text\" size=\"32\" name=\"serverhttpntlmdomain\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPNTLMDomain) + "\"/>\n"
                    + "    </td>\n" + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerHTTPNTLMUserName") + "</nobr></td>\n"
                    + "    <td class=\"value\">\n"
                    + "      <input type=\"text\" size=\"32\" name=\"serverhttpntlmusername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPNTLMUserName) + "\"/>\n"
                    + "    </td>\n" + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerHTTPNTLMPassword") + "</nobr></td>\n"
                    + "    <td class=\"value\">\n"
                    + "      <input type=\"password\" size=\"32\" name=\"serverhttpntlmpassword\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPNTLMPassword) + "\"/>\n"
                    + "    </td>\n" + "  </tr>\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
            out.print("  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.ServerSSLCertificateList")
                    + "</nobr></td>\n" + "    <td class=\"value\">\n"
                    + "      <input type=\"hidden\" name=\"serverkeystorealias\" value=\"\"/>\n"
                    + "      <table class=\"displaytable\">\n");
            // List the individual certificates in the store, with a delete button for each
            String[] contents = localServerHTTPSKeystore.getContents();
            if (contents.length == 0) {
                out.print("        <tr><td class=\"message\" colspan=\"2\"><nobr>"
                        + Messages.getBodyString(locale, "LivelinkConnector.NoCertificatesPresent")
                        + "</nobr></td></tr>\n");
            } else {
                int i = 0;
                while (i < contents.length) {
                    String alias = contents[i];
                    String description = localServerHTTPSKeystore.getDescription(alias);
                    if (description.length() > 128)
                        description = description.substring(0, 125) + "...";
                    out.print("        <tr>\n"
                            + "          <td class=\"value\"><input type=\"button\" onclick='Javascript:ServerDeleteCertificate(\""
                            + org.apache.manifoldcf.ui.util.Encoder.attributeJavascriptEscape(alias) + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.DeleteCert")
                            + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(alias) + "\" value=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.Delete") + "\"/></td>\n"
                            + "          <td>" + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(description)
                            + "</td>\n" + "        </tr>\n");
                    i++;
                }
            }
            out.print("      </table>\n"
                    + "      <input type=\"button\" onclick='Javascript:ServerAddCertificate()' alt=\""
                    + Messages.getAttributeString(locale, "LivelinkConnector.AddCert") + "\" value=\""
                    + Messages.getAttributeString(locale, "LivelinkConnector.Add") + "\"/>&nbsp;\n" + "      "
                    + Messages.getBodyString(locale, "LivelinkConnector.Certificate")
                    + "<input name=\"servercertificate\" size=\"50\" type=\"file\"/>\n" + "    </td>\n"
                    + "  </tr>\n");
            out.print("</table>\n");
        } else {
            // Hiddens for Server tab
            out.print("<input type=\"hidden\" name=\"serverprotocol\" value=\"" + serverProtocol + "\"/>\n"
                    + "<input type=\"hidden\" name=\"servername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverName) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"serverport\" value=\"" + serverPort + "\"/>\n"
                    + "<input type=\"hidden\" name=\"serverusername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverUserName) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"serverpassword\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverPassword) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"serverhttpcgipath\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPCgiPath) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"serverhttpntlmdomain\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPNTLMDomain) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"serverhttpntlmusername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPNTLMUserName) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"serverhttpntlmpassword\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(serverHTTPNTLMPassword) + "\"/>\n");
        }

        // The "Document Access" tab
        // Always pass the whole keystore as a hidden.
        out.print("<input name=\"ingestconfigop\" type=\"hidden\" value=\"Continue\"/>\n");
        if (ingestKeystore != null) {
            out.print("<input type=\"hidden\" name=\"ingestkeystoredata\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestKeystore) + "\"/>\n");
        }
        if (tabName.equals(Messages.getString(locale, "LivelinkConnector.DocumentAccess"))) {
            out.print("<table class=\"displaytable\">\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentFetchProtocol") + "</td>\n"
                    + "    <td class=\"value\">\n" + "      <select name=\"ingestprotocol\" size=\"3\">\n"
                    + "        <option value=\"\" " + ((ingestProtocol.equals("")) ? "selected=\"selected\"" : "")
                    + ">" + Messages.getBodyString(locale, "LivelinkConnector.UseLAPI") + "</option>\n"
                    + "        <option value=\"http\" "
                    + ((ingestProtocol.equals("http")) ? "selected=\"selected\"" : "") + ">http</option>\n"
                    + "        <option value=\"https\" "
                    + ((ingestProtocol.equals("https")) ? "selected=\"selected\"" : "") + ">https</option>\n"
                    + "      </select>\n" + "    </td>\n" + "  </tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentFetchPort") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"5\" name=\"ingestport\" value=\""
                    + ingestPort + "\"/></td>\n" + "  </tr>\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentFetchCGIPath") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"32\" name=\"ingestcgipath\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestCgiPath) + "\"/></td>\n"
                    + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentFetchNTLMDomain")
                    + "</nobr><br/><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.SetIfNTLMAuthDesired") + "</nobr></td>\n"
                    + "    <td class=\"value\">\n"
                    + "      <input type=\"text\" size=\"32\" name=\"ingestntlmdomain\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestNtlmDomain) + "\"/>\n"
                    + "    </td>\n" + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentFetchNTLMUserName")
                    + "</nobr><br/><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.SetIfDifferentFromServerUserName")
                    + "</nobr></td>\n" + "    <td class=\"value\">\n"
                    + "      <input type=\"text\" size=\"32\" name=\"ingestntlmusername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestNtlmUsername) + "\"/>\n"
                    + "    </td>\n" + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentFetchNTLMPassword")
                    + "</nobr><br/><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.SetIfDifferentFromServerPassword")
                    + "</nobr></td>\n" + "    <td class=\"value\">\n"
                    + "      <input type=\"password\" size=\"32\" name=\"ingestntlmpassword\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestNtlmPassword) + "\"/>\n"
                    + "    </td>\n" + "  </tr>\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
            out.print("  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentFetchSSLCertificateList")
                    + "</nobr></td>\n" + "    <td class=\"value\">\n"
                    + "      <input type=\"hidden\" name=\"ingestkeystorealias\" value=\"\"/>\n"
                    + "      <table class=\"displaytable\">\n");
            // List the individual certificates in the store, with a delete button for each
            String[] contents = localIngestKeystore.getContents();
            if (contents.length == 0) {
                out.print("        <tr><td class=\"message\" colspan=\"2\"><nobr>"
                        + Messages.getBodyString(locale, "LivelinkConnector.NoCertificatesPresent")
                        + "</nobr></td></tr>\n");
            } else {
                int i = 0;
                while (i < contents.length) {
                    String alias = contents[i];
                    String description = localIngestKeystore.getDescription(alias);
                    if (description.length() > 128)
                        description = description.substring(0, 125) + "...";
                    out.print("        <tr>\n"
                            + "          <td class=\"value\"><input type=\"button\" onclick='Javascript:IngestDeleteCertificate(\""
                            + org.apache.manifoldcf.ui.util.Encoder.attributeJavascriptEscape(alias) + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.DeleteCert")
                            + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(alias) + "\" value=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.Delete") + "\"/></td>\n"
                            + "          <td>" + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(description)
                            + "</td>\n" + "        </tr>\n");
                    i++;
                }
            }
            out.print("      </table>\n"
                    + "      <input type=\"button\" onclick='Javascript:IngestAddCertificate()' alt=\""
                    + Messages.getAttributeString(locale, "LivelinkConnector.AddCert") + "\" value=\""
                    + Messages.getAttributeString(locale, "LivelinkConnector.Add") + "\"/>&nbsp;\n" + "      "
                    + Messages.getBodyString(locale, "LivelinkConnector.Certificate")
                    + "<input name=\"ingestcertificate\" size=\"50\" type=\"file\"/>\n" + "    </td>\n"
                    + "  </tr>\n");
            out.print("</table>\n");
        } else {
            // Hiddens for Document Access tab
            out.print("<input type=\"hidden\" name=\"ingestprotocol\" value=\"" + ingestProtocol + "\"/>\n"
                    + "<input type=\"hidden\" name=\"ingestport\" value=\"" + ingestPort + "\"/>\n"
                    + "<input type=\"hidden\" name=\"ingestcgipath\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestCgiPath) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"ingestntlmusername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestNtlmUsername) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"ingestntlmpassword\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestNtlmPassword) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"ingestntlmdomain\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(ingestNtlmDomain) + "\"/>\n");
        }

        // Document View tab
        if (tabName.equals(Messages.getString(locale, "LivelinkConnector.DocumentView"))) {
            out.print("<table class=\"displaytable\">\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentViewProtocol") + "</td>\n"
                    + "    <td class=\"value\">\n" + "      <select name=\"viewprotocol\" size=\"3\">\n"
                    + "        <option value=\"\" " + ((viewProtocol.equals("")) ? "selected=\"selected\"" : "")
                    + ">" + Messages.getBodyString(locale, "LivelinkConnector.SameAsFetchProtocol") + "</option>\n"
                    + "        <option value=\"http\" "
                    + ((viewProtocol.equals("http")) ? "selected=\"selected\"" : "") + ">http</option>\n"
                    + "        <option value=\"https\" "
                    + ((viewProtocol.equals("https")) ? "selected=\"selected\"" : "") + ">https</option>\n"
                    + "      </select>\n" + "    </td>\n" + "  </tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentViewServerName")
                    + "</nobr><br/><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.BlankSameAsFetchServer") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"64\" name=\"viewservername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(viewServerName) + "\"/></td>\n"
                    + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentViewPort")
                    + "</nobr><br/><nobr>(blank = same as fetch port)</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"5\" name=\"viewport\" value=\""
                    + viewPort + "\"/></td>\n" + "  </tr>\n" + "  <tr>\n" + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.DocumentViewCGIPath") + "</nobr><br/><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.BlankSameAsFetchServer") + "</nobr></td>\n"
                    + "    <td class=\"value\"><input type=\"text\" size=\"32\" name=\"viewcgipath\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(viewCgiPath) + "\"/></td>\n"
                    + "  </tr>\n" + "</table>\n");
        } else {
            // Hiddens for Document View tab
            out.print("<input type=\"hidden\" name=\"viewprotocol\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(viewProtocol) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"viewservername\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(viewServerName) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"viewport\" value=\"" + viewPort + "\"/>\n"
                    + "<input type=\"hidden\" name=\"viewcgipath\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(viewCgiPath) + "\"/>\n");
        }
    }

    /** Process a configuration post.
    * This method is called at the start of the connector's configuration page, whenever there is a possibility that form data for a connection has been
    * posted.  Its purpose is to gather form information and modify the configuration parameters accordingly.
    * The name of the posted form is "editconnection".
    *@param threadContext is the local thread context.
    *@param variableContext is the set of variables available from the post, including binary file post information.
    *@param parameters are the configuration parameters, as they currently exist, for this connection being configured.
    *@return null if all is well, or a string error message if there is an error that should prevent saving of the connection (and cause a redirection to an error page).
    */
    @Override
    public String processConfigurationPost(IThreadContext threadContext, IPostParameters variableContext,
            Locale locale, ConfigParams parameters) throws ManifoldCFException {
        // View parameters
        String viewProtocol = variableContext.getParameter("viewprotocol");
        if (viewProtocol != null)
            parameters.setParameter(LiveLinkParameters.viewProtocol, viewProtocol);
        String viewServerName = variableContext.getParameter("viewservername");
        if (viewServerName != null)
            parameters.setParameter(LiveLinkParameters.viewServerName, viewServerName);
        String viewPort = variableContext.getParameter("viewport");
        if (viewPort != null)
            parameters.setParameter(LiveLinkParameters.viewPort, viewPort);
        String viewCgiPath = variableContext.getParameter("viewcgipath");
        if (viewCgiPath != null)
            parameters.setParameter(LiveLinkParameters.viewCgiPath, viewCgiPath);

        // Server parameters
        String serverProtocol = variableContext.getParameter("serverprotocol");
        if (serverProtocol != null)
            parameters.setParameter(LiveLinkParameters.serverProtocol, serverProtocol);
        String serverName = variableContext.getParameter("servername");
        if (serverName != null)
            parameters.setParameter(LiveLinkParameters.serverName, serverName);
        String serverPort = variableContext.getParameter("serverport");
        if (serverPort != null)
            parameters.setParameter(LiveLinkParameters.serverPort, serverPort);
        String serverUserName = variableContext.getParameter("serverusername");
        if (serverUserName != null)
            parameters.setParameter(LiveLinkParameters.serverUsername, serverUserName);
        String serverPassword = variableContext.getParameter("serverpassword");
        if (serverPassword != null)
            parameters.setObfuscatedParameter(LiveLinkParameters.serverPassword,
                    variableContext.mapKeyToPassword(serverPassword));
        String serverHTTPCgiPath = variableContext.getParameter("serverhttpcgipath");
        if (serverHTTPCgiPath != null)
            parameters.setParameter(LiveLinkParameters.serverHTTPCgiPath, serverHTTPCgiPath);
        String serverHTTPNTLMDomain = variableContext.getParameter("serverhttpntlmdomain");
        if (serverHTTPNTLMDomain != null)
            parameters.setParameter(LiveLinkParameters.serverHTTPNTLMDomain, serverHTTPNTLMDomain);
        String serverHTTPNTLMUserName = variableContext.getParameter("serverhttpntlmusername");
        if (serverHTTPNTLMUserName != null)
            parameters.setParameter(LiveLinkParameters.serverHTTPNTLMUsername, serverHTTPNTLMUserName);
        String serverHTTPNTLMPassword = variableContext.getParameter("serverhttpntlmpassword");
        if (serverHTTPNTLMPassword != null)
            parameters.setObfuscatedParameter(LiveLinkParameters.serverHTTPNTLMPassword,
                    variableContext.mapKeyToPassword(serverHTTPNTLMPassword));
        String serverHTTPSKeystoreValue = variableContext.getParameter("serverhttpskeystoredata");
        if (serverHTTPSKeystoreValue != null)
            parameters.setParameter(LiveLinkParameters.serverHTTPSKeystore, serverHTTPSKeystoreValue);

        String serverConfigOp = variableContext.getParameter("serverconfigop");
        if (serverConfigOp != null) {
            if (serverConfigOp.equals("Delete")) {
                String alias = variableContext.getParameter("serverkeystorealias");
                serverHTTPSKeystoreValue = parameters.getParameter(LiveLinkParameters.serverHTTPSKeystore);
                IKeystoreManager mgr;
                if (serverHTTPSKeystoreValue != null)
                    mgr = KeystoreManagerFactory.make("", serverHTTPSKeystoreValue);
                else
                    mgr = KeystoreManagerFactory.make("");
                mgr.remove(alias);
                parameters.setParameter(LiveLinkParameters.serverHTTPSKeystore, mgr.getString());
            } else if (serverConfigOp.equals("Add")) {
                String alias = IDFactory.make(threadContext);
                byte[] certificateValue = variableContext.getBinaryBytes("servercertificate");
                serverHTTPSKeystoreValue = parameters.getParameter(LiveLinkParameters.serverHTTPSKeystore);
                IKeystoreManager mgr;
                if (serverHTTPSKeystoreValue != null)
                    mgr = KeystoreManagerFactory.make("", serverHTTPSKeystoreValue);
                else
                    mgr = KeystoreManagerFactory.make("");
                java.io.InputStream is = new java.io.ByteArrayInputStream(certificateValue);
                String certError = null;
                try {
                    mgr.importCertificate(alias, is);
                } catch (Throwable e) {
                    certError = e.getMessage();
                } finally {
                    try {
                        is.close();
                    } catch (IOException e) {
                        // Eat this exception
                    }
                }

                if (certError != null) {
                    return "Illegal certificate: " + certError;
                }
                parameters.setParameter(LiveLinkParameters.serverHTTPSKeystore, mgr.getString());
            }
        }

        // Ingest parameters
        String ingestProtocol = variableContext.getParameter("ingestprotocol");
        if (ingestProtocol != null)
            parameters.setParameter(LiveLinkParameters.ingestProtocol, ingestProtocol);
        String ingestPort = variableContext.getParameter("ingestport");
        if (ingestPort != null)
            parameters.setParameter(LiveLinkParameters.ingestPort, ingestPort);
        String ingestCgiPath = variableContext.getParameter("ingestcgipath");
        if (ingestCgiPath != null)
            parameters.setParameter(LiveLinkParameters.ingestCgiPath, ingestCgiPath);
        String ingestNtlmDomain = variableContext.getParameter("ingestntlmdomain");
        if (ingestNtlmDomain != null)
            parameters.setParameter(LiveLinkParameters.ingestNtlmDomain, ingestNtlmDomain);
        String ingestNtlmUsername = variableContext.getParameter("ingestntlmusername");
        if (ingestNtlmUsername != null)
            parameters.setParameter(LiveLinkParameters.ingestNtlmUsername, ingestNtlmUsername);
        String ingestNtlmPassword = variableContext.getParameter("ingestntlmpassword");
        if (ingestNtlmPassword != null)
            parameters.setObfuscatedParameter(LiveLinkParameters.ingestNtlmPassword,
                    variableContext.mapKeyToPassword(ingestNtlmPassword));
        String ingestKeystoreValue = variableContext.getParameter("ingestkeystoredata");
        if (ingestKeystoreValue != null)
            parameters.setParameter(LiveLinkParameters.ingestKeystore, ingestKeystoreValue);

        String ingestConfigOp = variableContext.getParameter("ingestconfigop");
        if (ingestConfigOp != null) {
            if (ingestConfigOp.equals("Delete")) {
                String alias = variableContext.getParameter("ingestkeystorealias");
                ingestKeystoreValue = parameters.getParameter(LiveLinkParameters.ingestKeystore);
                IKeystoreManager mgr;
                if (ingestKeystoreValue != null)
                    mgr = KeystoreManagerFactory.make("", ingestKeystoreValue);
                else
                    mgr = KeystoreManagerFactory.make("");
                mgr.remove(alias);
                parameters.setParameter(LiveLinkParameters.ingestKeystore, mgr.getString());
            } else if (ingestConfigOp.equals("Add")) {
                String alias = IDFactory.make(threadContext);
                byte[] certificateValue = variableContext.getBinaryBytes("ingestcertificate");
                ingestKeystoreValue = parameters.getParameter(LiveLinkParameters.ingestKeystore);
                IKeystoreManager mgr;
                if (ingestKeystoreValue != null)
                    mgr = KeystoreManagerFactory.make("", ingestKeystoreValue);
                else
                    mgr = KeystoreManagerFactory.make("");
                java.io.InputStream is = new java.io.ByteArrayInputStream(certificateValue);
                String certError = null;
                try {
                    mgr.importCertificate(alias, is);
                } catch (Throwable e) {
                    certError = e.getMessage();
                } finally {
                    try {
                        is.close();
                    } catch (IOException e) {
                        // Eat this exception
                    }
                }

                if (certError != null) {
                    return "Illegal certificate: " + certError;
                }
                parameters.setParameter(LiveLinkParameters.ingestKeystore, mgr.getString());
            }
        }

        return null;
    }

    /** View configuration.
    * This method is called in the body section of the connector's view configuration page.  Its purpose is to present the connection information to the user.
    * The coder can presume that the HTML that is output from this configuration will be within appropriate <html> and <body> tags.
    *@param threadContext is the local thread context.
    *@param out is the output to which any HTML should be sent.
    *@param parameters are the configuration parameters, as they currently exist, for this connection being configured.
    */
    @Override
    public void viewConfiguration(IThreadContext threadContext, IHTTPOutput out, Locale locale,
            ConfigParams parameters) throws ManifoldCFException, IOException {
        out.print("<table class=\"displaytable\">\n" + "  <tr>\n"
                + "    <td class=\"description\" colspan=\"1\"><nobr>"
                + Messages.getBodyString(locale, "LivelinkConnector.Parameters") + "</nobr></td>\n"
                + "    <td class=\"value\" colspan=\"3\">\n");
        Iterator iter = parameters.listParameters();
        while (iter.hasNext()) {
            String param = (String) iter.next();
            String value = parameters.getParameter(param);
            if (param.length() >= "password".length()
                    && param.substring(param.length() - "password".length()).equalsIgnoreCase("password")) {
                out.print("      <nobr>" + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(param)
                        + "=********</nobr><br/>\n");
            } else if (param.length() >= "keystore".length()
                    && param.substring(param.length() - "keystore".length()).equalsIgnoreCase("keystore")
                    || param.length() > "truststore".length() && param
                            .substring(param.length() - "truststore".length()).equalsIgnoreCase("truststore")) {
                IKeystoreManager kmanager = KeystoreManagerFactory.make("", value);
                out.print("      <nobr>" + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(param) + "=&lt;"
                        + Integer.toString(kmanager.getContents().length)
                        + Messages.getBodyString(locale, "LivelinkConnector.certificates") + "&gt;</nobr><br/>\n");
            } else {
                out.print("      <nobr>" + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(param) + "="
                        + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(value) + "</nobr><br/>\n");
            }
        }
        out.print("    </td>\n" + "  </tr>\n" + "</table>\n");
    }

    /** Output the specification header section.
    * This method is called in the head section of a job page which has selected a repository connection of the
    * current type.  Its purpose is to add the required tabs to the list, and to output any javascript methods
    * that might be needed by the job editing HTML.
    * The connector will be connected before this method can be called.
    *@param out is the output to which any HTML should be sent.
    *@param locale is the locale the output is preferred to be in.
    *@param ds is the current document specification for this job.
    *@param connectionSequenceNumber is the unique number of this connection within the job.
    *@param tabsArray is an array of tab names.  Add to this array any tab names that are specific to the connector.
    */
    @Override
    public void outputSpecificationHeader(IHTTPOutput out, Locale locale, Specification ds,
            int connectionSequenceNumber, List<String> tabsArray) throws ManifoldCFException, IOException {
        tabsArray.add(Messages.getString(locale, "LivelinkConnector.Paths"));
        tabsArray.add(Messages.getString(locale, "LivelinkConnector.Filters"));
        tabsArray.add(Messages.getString(locale, "LivelinkConnector.Security"));
        tabsArray.add(Messages.getString(locale, "LivelinkConnector.Metadata"));

        String seqPrefix = "s" + connectionSequenceNumber + "_";

        out.print("<script type=\"text/javascript\">\n" + "<!--\n" + "\n" + "function " + seqPrefix
                + "SpecOp(n, opValue, anchorvalue)\n" + "{\n"
                + "  eval(\"editjob.\"+n+\".value = \\\"\"+opValue+\"\\\"\");\n"
                + "  postFormSetAnchor(anchorvalue);\n" + "}\n" + "\n" + "function " + seqPrefix
                + "SpecAddToPath(anchorvalue)\n" + "{\n" + "  if (editjob." + seqPrefix
                + "pathaddon.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.SelectAFolderFirst") + "\");\n"
                + "    editjob." + seqPrefix + "pathaddon.focus();\n" + "    return;\n" + "  }\n" + "\n" + "  "
                + seqPrefix + "SpecOp(\"" + seqPrefix + "pathop\",\"AddToPath\",anchorvalue);\n" + "}\n" + "\n"
                + "function " + seqPrefix + "SpecAddFilespec(anchorvalue)\n" + "{\n" + "  if (editjob." + seqPrefix
                + "specfile.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.TypeInAFileSpecification") + "\");\n"
                + "    editjob." + seqPrefix + "specfile.focus();\n" + "    return;\n" + "  }\n" + "  " + seqPrefix
                + "SpecOp(\"" + seqPrefix + "fileop\",\"Add\",anchorvalue);\n" + "}\n" + "\n" + "function "
                + seqPrefix + "SpecAddToken(anchorvalue)\n" + "{\n" + "  if (editjob." + seqPrefix
                + "spectoken.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.TypeInAnAccessToken") + "\");\n"
                + "    editjob." + seqPrefix + "spectoken.focus();\n" + "    return;\n" + "  }\n" + "  " + seqPrefix
                + "SpecOp(\"" + seqPrefix + "accessop\",\"Add\",anchorvalue);\n" + "}\n" + "\n" + "function "
                + seqPrefix + "SpecAddToMetadata(anchorvalue)\n" + "{\n" + "  if (editjob." + seqPrefix
                + "metadataaddon.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.SelectAFolderFirst") + "\");\n"
                + "    editjob." + seqPrefix + "metadataaddon.focus();\n" + "    return;\n" + "  }\n" + "  "
                + seqPrefix + "SpecOp(\"" + seqPrefix + "metadataop\",\"AddToPath\",anchorvalue);\n" + "}\n" + "\n"
                + "function " + seqPrefix + "SpecSetWorkspace(anchorvalue)\n" + "{\n" + "  if (editjob." + seqPrefix
                + "metadataaddon.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.SelectAWorkspaceFirst") + "\");\n"
                + "    editjob." + seqPrefix + "metadataaddon.focus();\n" + "    return;\n" + "  }\n" + "  "
                + seqPrefix + "SpecOp(\"" + seqPrefix + "metadataop\",\"SetWorkspace\",anchorvalue);\n" + "}\n"
                + "\n" + "function " + seqPrefix + "SpecAddCategory(anchorvalue)\n" + "{\n" + "  if (editjob."
                + seqPrefix + "categoryaddon.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.SelectACategoryFirst") + "\");\n"
                + "    editjob." + seqPrefix + "categoryaddon.focus();\n" + "    return;\n" + "  }\n" + "  "
                + seqPrefix + "SpecOp(\"" + seqPrefix + "metadataop\",\"AddCategory\",anchorvalue);\n" + "}\n"
                + "\n" + "function " + seqPrefix + "SpecAddMetadata(anchorvalue)\n" + "{\n" + "  if (editjob."
                + seqPrefix + "attributeselect.value == \"\" && editjob." + seqPrefix
                + "attributeall.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.SelectAtLeastOneAttributeFirst")
                + "\");\n" + "    editjob." + seqPrefix + "attributeselect.focus();\n" + "    return;\n" + "  }\n"
                + "  " + seqPrefix + "SpecOp(\"" + seqPrefix + "metadataop\",\"Add\",anchorvalue);\n" + "}\n" + "\n"
                + "function " + seqPrefix + "SpecAddMapping(anchorvalue)\n" + "{\n" + "  if (editjob." + seqPrefix
                + "specmatch.value == \"\")\n" + "  {\n" + "    alert(\""
                + Messages.getBodyJavascriptString(locale, "LivelinkConnector.MatchStringCannotBeEmpty") + "\");\n"
                + "    editjob." + seqPrefix + "specmatch.focus();\n" + "    return;\n" + "  }\n"
                + "  if (!isRegularExpression(editjob." + seqPrefix + "specmatch.value))\n" + "  {\n"
                + "    alert(\""
                + Messages.getBodyJavascriptString(locale,
                        "LivelinkConnector.MatchStringMustBeValidRegularExpression")
                + "\");\n" + "    editjob." + seqPrefix + "specmatch.focus();\n" + "    return;\n" + "  }\n" + "  "
                + seqPrefix + "SpecOp(\"" + seqPrefix + "specmappingop\",\"Add\",anchorvalue);\n" + "}\n"
                + "//-->\n" + "</script>\n");
    }

    /** Output the specification body section.
    * This method is called in the body section of a job page which has selected a repository connection of the
    * current type.  Its purpose is to present the required form elements for editing.
    * The coder can presume that the HTML that is output from this configuration will be within appropriate
    *  <html>, <body>, and <form> tags.  The name of the form is always "editjob".
    * The connector will be connected before this method can be called.
    *@param out is the output to which any HTML should be sent.
    *@param locale is the locale the output is preferred to be in.
    *@param ds is the current document specification for this job.
    *@param connectionSequenceNumber is the unique number of this connection within the job.
    *@param actualSequenceNumber is the connection within the job that has currently been selected.
    *@param tabName is the current tab name.  (actualSequenceNumber, tabName) form a unique tuple within
    *  the job.
    */
    @Override
    public void outputSpecificationBody(IHTTPOutput out, Locale locale, Specification ds,
            int connectionSequenceNumber, int actualSequenceNumber, String tabName)
            throws ManifoldCFException, IOException {
        String seqPrefix = "s" + connectionSequenceNumber + "_";

        int i;
        int k;

        // Paths tab
        boolean userWorkspaces = false;
        i = 0;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("userworkspace")) {
                String value = sn.getAttributeValue("value");
                if (value != null && value.equals("true"))
                    userWorkspaces = true;
            }
        }
        if (tabName.equals(Messages.getString(locale, "LivelinkConnector.Paths"))
                && connectionSequenceNumber == actualSequenceNumber) {
            out.print("<table class=\"displaytable\">\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">\n" + "      <nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.CrawlUserWorkspaces") + "</nobr>\n"
                    + "    </td>\n" + "    <td class=\"value\">\n" + "      <input type=\"checkbox\" name=\""
                    + seqPrefix + "userworkspace\" value=\"true\"" + (userWorkspaces ? " checked=\"true\"" : "")
                    + "/>\n" + "      <input type=\"hidden\" name=\"" + seqPrefix
                    + "userworkspace_present\" value=\"true\"/>\n" + "    </td>\n" + "  </tr>\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
            // Now, loop through paths
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("startpoint")) {
                    String pathDescription = "_" + Integer.toString(k);
                    String pathOpName = seqPrefix + "pathop" + pathDescription;
                    out.print(
                            "  <tr>\n" + "    <td class=\"description\">\n" + "      <input type=\"hidden\" name=\""
                                    + seqPrefix + "specpath" + pathDescription + "\" value=\""
                                    + org.apache.manifoldcf.ui.util.Encoder
                                            .attributeEscape(sn.getAttributeValue("path"))
                                    + "\"/>\n" + "      <input type=\"hidden\" name=\"" + pathOpName
                                    + "\" value=\"\"/>\n" + "      <a name=\"" + seqPrefix + "path_"
                                    + Integer.toString(k) + "\">\n"
                                    + "        <input type=\"button\" value=\"Delete\" onClick='Javascript:"
                                    + seqPrefix + "SpecOp(\"" + pathOpName + "\",\"Delete\",\"" + seqPrefix
                                    + "path_" + Integer.toString(k) + "\")' alt=\""
                                    + Messages.getAttributeString(locale, "LivelinkConnector.DeletePath")
                                    + Integer.toString(k) + "\"/>\n" + "      </a>\n" + "    </td>\n"
                                    + "    <td class=\"value\">\n" + "      "
                                    + ((sn.getAttributeValue("path").length() == 0) ? "(root)"
                                            : org.apache.manifoldcf.ui.util.Encoder
                                                    .bodyEscape(sn.getAttributeValue("path")))
                                    + "\n" + "    </td>\n" + "  </tr>\n");
                    k++;
                }
            }
            if (k == 0) {
                out.print("  <tr>\n" + "    <td class=\"message\" colspan=\"2\">"
                        + Messages.getBodyString(locale, "LivelinkConnector.NoStartingPointsDefined") + "</td>\n"
                        + "  </tr>\n");
            }
            out.print("  <tr><td class=\"lightseparator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">\n" + "      <input type=\"hidden\" name=\"" + seqPrefix
                    + "pathcount\" value=\"" + Integer.toString(k) + "\"/>\n");

            String pathSoFar = (String) currentContext.get(seqPrefix + "specpath");
            if (pathSoFar == null)
                pathSoFar = "";

            // Grab next folder/project list
            try {
                String[] childList;
                childList = getChildFolderNames(pathSoFar);
                if (childList == null) {
                    // Illegal path - set it back
                    pathSoFar = "";
                    childList = getChildFolderNames("");
                    if (childList == null)
                        throw new ManifoldCFException("Can't find any children for root folder");
                }
                out.print("      <input type=\"hidden\" name=\"" + seqPrefix + "specpath\" value=\""
                        + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(pathSoFar) + "\"/>\n"
                        + "      <input type=\"hidden\" name=\"" + seqPrefix + "pathop\" value=\"\"/>\n"
                        + "      <a name=\"" + seqPrefix + "path_" + Integer.toString(k) + "\">\n"
                        + "        <input type=\"button\" value=\"Add\" onClick='Javascript:" + seqPrefix
                        + "SpecOp(\"" + seqPrefix + "pathop\",\"Add\",\"" + seqPrefix + "path_"
                        + Integer.toString(k + 1) + "\")' alt=\""
                        + Messages.getAttributeString(locale, "LivelinkConnector.AddPath") + "\"/>\n"
                        + "      </a>&nbsp;\n" + "    </td>\n" + "    <td class=\"value\">\n" + "      "
                        + ((pathSoFar.length() == 0) ? "(root)"
                                : org.apache.manifoldcf.ui.util.Encoder.bodyEscape(pathSoFar))
                        + "\n");
                if (pathSoFar.length() > 0) {
                    out.print("      <input type=\"button\" value=\"-\" onClick='Javascript:" + seqPrefix
                            + "SpecOp(\"" + seqPrefix + "pathop\",\"Up\",\"" + seqPrefix + "path_"
                            + Integer.toString(k) + "\")' alt=\"Back up path\"/>\n");
                }
                if (childList.length > 0) {
                    out.print("      <input type=\"button\" value=\"+\" onClick='Javascript:" + seqPrefix
                            + "SpecAddToPath(\"" + seqPrefix + "path_" + Integer.toString(k) + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.AddToPath") + "\"/>&nbsp;\n"
                            + "      <select multiple=\"false\" name=\"" + seqPrefix + "pathaddon\" size=\"2\">\n"
                            + "        <option value=\"\" selected=\"selected\">"
                            + Messages.getBodyString(locale, "LivelinkConnector.PickAFolder") + "</option>\n");
                    int j = 0;
                    while (j < childList.length) {
                        out.print("        <option value=\""
                                + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(childList[j]) + "\">"
                                + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(childList[j]) + "</option>\n");
                        j++;
                    }
                    out.print("      </select>\n");
                }
            } catch (ServiceInterruption e) {
                //e.printStackTrace();
                out.println(org.apache.manifoldcf.ui.util.Encoder.bodyEscape(e.getMessage()));
            } catch (ManifoldCFException e) {
                //e.printStackTrace();
                out.println(org.apache.manifoldcf.ui.util.Encoder.bodyEscape(e.getMessage()));
            }
            out.print("    </td>\n" + "  </tr>\n" + "</table>\n");
        } else {
            // Now, loop through paths
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("startpoint")) {
                    String pathDescription = "_" + Integer.toString(k);
                    out.print("<input type=\"hidden\" name=\"" + seqPrefix + "specpath" + pathDescription
                            + "\" value=\""
                            + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(sn.getAttributeValue("path"))
                            + "\"/>\n");
                    k++;
                }
            }
            out.print("<input type=\"hidden\" name=\"" + seqPrefix + "pathcount\" value=\"" + Integer.toString(k)
                    + "\"/>\n" + "<input type=\"hidden\" name=\"" + seqPrefix + "userworkspace\" value=\""
                    + (userWorkspaces ? "true" : "false") + "\"/>\n" + "<input type=\"hidden\" name=\"" + seqPrefix
                    + "userworkspace_present\" value=\"true\"/>\n");
        }

        // Filter tab
        if (tabName.equals(Messages.getString(locale, "LivelinkConnector.Filters"))
                && connectionSequenceNumber == actualSequenceNumber) {
            out.print("<table class=\"displaytable\">\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
            // Next, go through include/exclude filespecs
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("include") || sn.getType().equals("exclude")) {
                    String fileSpecDescription = "_" + Integer.toString(k);
                    String fileOpName = seqPrefix + "fileop" + fileSpecDescription;
                    String filespec = sn.getAttributeValue("filespec");
                    out.print("  <tr>\n" + "    <td class=\"description\">\n"
                            + "      <input type=\"hidden\" name=\"" + seqPrefix + "specfiletype"
                            + fileSpecDescription + "\" value=\"" + sn.getType() + "\"/>\n"
                            + "      <input type=\"hidden\" name=\"" + fileOpName + "\" value=\"\"/>\n"
                            + "      <a name=\"" + seqPrefix + "filespec_" + Integer.toString(k) + "\">\n"
                            + "        <input type=\"button\" value=\"Delete\" onClick='Javascript:" + seqPrefix
                            + "SpecOp(\"" + fileOpName + "\",\"Delete\",\"" + seqPrefix + "filespec_"
                            + Integer.toString(k) + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.DeleteFilespec")
                            + Integer.toString(k) + "\"/>\n" + "      </a>\n" + "    </td>\n"
                            + "    <td class=\"value\">\n" + "      "
                            + (sn.getType().equals("include") ? "Include:" : "") + "\n" + "      "
                            + (sn.getType().equals("exclude") ? "Exclude:" : "") + "\n"
                            + "      &nbsp;<input type=\"hidden\" name=\"" + seqPrefix + "specfile"
                            + fileSpecDescription + "\" value=\""
                            + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(filespec) + "\"/>\n" + "      "
                            + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(filespec) + "\n" + "    </td>\n"
                            + "  </tr>\n");
                    k++;
                }
            }
            if (k == 0) {
                out.print("  <tr>\n" + "    <td class=\"message\" colspan=\"2\">"
                        + Messages.getBodyString(locale, "LivelinkConnector.NoIncludeExcludeFilesDefined")
                        + "</td>\n" + "  </tr>\n");
            }
            out.print("  <tr><td class=\"lightseparator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">\n" + "      <input type=\"hidden\" name=\"" + seqPrefix
                    + "filecount\" value=\"" + Integer.toString(k) + "\"/>\n"
                    + "      <input type=\"hidden\" name=\"" + seqPrefix + "fileop\" value=\"\"/>\n"
                    + "      <a name=\"" + seqPrefix + "filespec_" + Integer.toString(k) + "\">\n"
                    + "        <input type=\"button\" value=\"Add\" onClick='Javascript:" + seqPrefix
                    + "SpecAddFilespec(\"" + seqPrefix + "filespec_" + Integer.toString(k + 1) + "\")' alt=\""
                    + Messages.getAttributeString(locale, "LivelinkConnector.AddFileSpecification") + "\"/>\n"
                    + "      </a>&nbsp;\n" + "    </td>\n" + "    <td class=\"value\">\n" + "      <select name=\""
                    + seqPrefix + "specfiletype\" size=\"1\">\n"
                    + "        <option value=\"include\" selected=\"selected\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.Include") + "</option>\n"
                    + "        <option value=\"exclude\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.Exclude") + "</option>\n"
                    + "      </select>&nbsp;\n" + "      <input type=\"text\" size=\"30\" name=\"" + seqPrefix
                    + "specfile\" value=\"\"/>\n" + "    </td>\n" + "  </tr>\n" + "</table>\n");
        } else {
            // Next, go through include/exclude filespecs
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("include") || sn.getType().equals("exclude")) {
                    String fileSpecDescription = "_" + Integer.toString(k);
                    String filespec = sn.getAttributeValue("filespec");
                    out.print("<input type=\"hidden\" name=\"" + seqPrefix + "specfiletype" + fileSpecDescription
                            + "\" value=\"" + sn.getType() + "\"/>\n" + "<input type=\"hidden\" name=\"" + seqPrefix
                            + "specfile" + fileSpecDescription + "\" value=\""
                            + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(filespec) + "\"/>\n");
                    k++;
                }
            }
            out.print("<input type=\"hidden\" name=\"" + seqPrefix + "filecount\" value=\"" + Integer.toString(k)
                    + "\"/>\n");
        }

        // Security tab
        // Find whether security is on or off
        i = 0;
        boolean securityOn = true;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("security")) {
                String securityValue = sn.getAttributeValue("value");
                if (securityValue.equals("off"))
                    securityOn = false;
                else if (securityValue.equals("on"))
                    securityOn = true;
            }
        }

        if (tabName.equals(Messages.getString(locale, "LivelinkConnector.Security"))
                && connectionSequenceNumber == actualSequenceNumber) {
            out.print("<table class=\"displaytable\">\n"
                    + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.SecurityColon") + "</nobr></td>\n"
                    + "    <td class=\"value\">\n" + "      <input type=\"radio\" name=\"" + seqPrefix
                    + "specsecurity\" value=\"on\" " + (securityOn ? "checked=\"true\"" : "") + " />"
                    + Messages.getBodyString(locale, "LivelinkConnector.Enabled") + "\n"
                    + "      <input type=\"radio\" name=\"" + seqPrefix + "specsecurity\" value=\"off\" "
                    + ((securityOn == false) ? "checked=\"true\"" : "") + " />"
                    + Messages.getBodyString(locale, "LivelinkConnector.Disabled") + "\n" + "    </td>\n"
                    + "  </tr>\n" + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
            // Go through forced ACL
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("access")) {
                    String accessDescription = "_" + Integer.toString(k);
                    String accessOpName = seqPrefix + "accessop" + accessDescription;
                    String token = sn.getAttributeValue("token");
                    out.print("  <tr>\n" + "    <td class=\"description\">\n"
                            + "      <input type=\"hidden\" name=\"" + accessOpName + "\" value=\"\"/>\n"
                            + "      <input type=\"hidden\" name=\"" + seqPrefix + "spectoken" + accessDescription
                            + "\" value=\"" + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(token)
                            + "\"/>\n" + "      <a name=\"" + seqPrefix + "token_" + Integer.toString(k) + "\">\n"
                            + "        <input type=\"button\" value=\"Delete\" onClick='Javascript:" + seqPrefix
                            + "SpecOp(\"" + accessOpName + "\",\"Delete\",\"" + seqPrefix + "token_"
                            + Integer.toString(k) + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.DeleteToken")
                            + Integer.toString(k) + "\"/>\n" + "      </a>&nbsp;\n" + "    </td>\n"
                            + "    <td class=\"value\">\n" + "      "
                            + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(token) + "\n" + "    </td>\n"
                            + "  </tr>\n");
                    k++;
                }
            }
            if (k == 0) {
                out.print("  <tr>\n" + "    <td class=\"message\" colspan=\"2\">"
                        + Messages.getBodyString(locale, "LivelinkConnector.NoAccessTokensPresent") + "</td>\n"
                        + "  </tr>\n");
            }
            out.print("  <tr><td class=\"lightseparator\" colspan=\"2\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">\n" + "      <input type=\"hidden\" name=\"" + seqPrefix
                    + "tokencount\" value=\"" + Integer.toString(k) + "\"/>\n"
                    + "      <input type=\"hidden\" name=\"" + seqPrefix + "accessop\" value=\"\"/>\n"
                    + "      <a name=\"" + seqPrefix + "token_" + Integer.toString(k) + "\">\n"
                    + "        <input type=\"button\" value=\"Add\" onClick='Javascript:" + seqPrefix
                    + "SpecAddToken(\"" + seqPrefix + "token_" + Integer.toString(k + 1) + "\")' alt=\""
                    + Messages.getAttributeString(locale, "LivelinkConnector.AddAccessToken") + "\"/>\n"
                    + "      </a>&nbsp;\n" + "    </td>\n" + "    <td class=\"value\">\n"
                    + "      <input type=\"text\" size=\"30\" name=\"" + seqPrefix + "spectoken\" value=\"\"/>\n"
                    + "    </td>\n" + "  </tr>\n" + "</table>\n");
        } else {
            out.print("<input type=\"hidden\" name=\"" + seqPrefix + "specsecurity\" value=\""
                    + (securityOn ? "on" : "off") + "\"/>\n");
            // Finally, go through forced ACL
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("access")) {
                    String accessDescription = "_" + Integer.toString(k);
                    String token = sn.getAttributeValue("token");
                    out.print("<input type=\"hidden\" name=\"" + seqPrefix + "spectoken" + accessDescription
                            + "\" value=\"" + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(token)
                            + "\"/>\n");
                    k++;
                }
            }
            out.print("<input type=\"hidden\" name=\"" + seqPrefix + "tokencount\" value=\"" + Integer.toString(k)
                    + "\"/>\n");
        }

        // Metadata tab

        // Find the path-value metadata attribute name
        i = 0;
        String pathNameAttribute = "";
        String pathNameSeparator = "/";
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("pathnameattribute")) {
                pathNameAttribute = sn.getAttributeValue("value");
                if (sn.getAttributeValue("separator") != null)
                    pathNameSeparator = sn.getAttributeValue("separator");
            }
        }

        // Find the path-value mapping data
        i = 0;
        MatchMap matchMap = new MatchMap();
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("pathmap")) {
                String pathMatch = sn.getAttributeValue("match");
                String pathReplace = sn.getAttributeValue("replace");
                matchMap.appendMatchPair(pathMatch, pathReplace);
            }
        }

        i = 0;
        String ingestAllMetadata = "false";
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("allmetadata")) {
                ingestAllMetadata = sn.getAttributeValue("all");
                if (ingestAllMetadata == null)
                    ingestAllMetadata = "false";
            }
        }

        if (tabName.equals(Messages.getString(locale, "LivelinkConnector.Metadata"))
                && connectionSequenceNumber == actualSequenceNumber) {
            out.print("<input type=\"hidden\" name=\"" + seqPrefix + "specmappingcount\" value=\""
                    + Integer.toString(matchMap.getMatchCount()) + "\"/>\n" + "<input type=\"hidden\" name=\""
                    + seqPrefix + "specmappingop\" value=\"\"/>\n" + "\n" + "<table class=\"displaytable\">\n"
                    + "  <tr><td class=\"separator\" colspan=\"4\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\" colspan=\"1\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.IngestALLMetadata") + "</nobr></td>\n"
                    + "    <td class=\"value\" colspan=\"3\">\n" + "      <nobr><input type=\"radio\" name=\""
                    + seqPrefix + "specallmetadata\" value=\"true\" "
                    + (ingestAllMetadata.equals("true") ? "checked=\"true\"" : "") + "/>"
                    + Messages.getBodyString(locale, "LivelinkConnector.Yes") + "</nobr>&nbsp;\n"
                    + "      <nobr><input type=\"radio\" name=\"" + seqPrefix + "specallmetadata\" value=\"false\" "
                    + (ingestAllMetadata.equals("false") ? "checked=\"true\"" : "") + "/>"
                    + Messages.getBodyString(locale, "LivelinkConnector.No") + "</nobr>\n" + "    </td>\n"
                    + "  </tr>\n" + "  <tr><td class=\"separator\" colspan=\"4\"><hr/></td></tr>\n");
            // Go through the selected metadata attributes
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("metadata")) {
                    String accessDescription = "_" + Integer.toString(k);
                    String accessOpName = seqPrefix + "metadataop" + accessDescription;
                    String categoryPath = sn.getAttributeValue("category");
                    String isAll = sn.getAttributeValue("all");
                    if (isAll == null)
                        isAll = "false";
                    String attributeName = sn.getAttributeValue("attribute");
                    if (attributeName == null)
                        attributeName = "";
                    out.print("  <tr>\n" + "    <td class=\"description\" colspan=\"1\">\n"
                            + "      <input type=\"hidden\" name=\"" + accessOpName + "\" value=\"\"/>\n"
                            + "      <input type=\"hidden\" name=\"" + seqPrefix + "speccategory"
                            + accessDescription + "\" value=\""
                            + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(categoryPath) + "\"/>\n"
                            + "      <input type=\"hidden\" name=\"" + seqPrefix + "specattributeall"
                            + accessDescription + "\" value=\"" + isAll + "\"/>\n"
                            + "      <input type=\"hidden\" name=\"" + seqPrefix + "specattribute"
                            + accessDescription + "\" value=\""
                            + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(attributeName) + "\"/>\n"
                            + "      <a name=\"" + seqPrefix + "metadata_" + Integer.toString(k) + "\">\n"
                            + "        <input type=\"button\" value=\"Delete\" onClick='Javascript:" + seqPrefix
                            + "SpecOp(\"" + accessOpName + "\",\"Delete\",\"" + seqPrefix + "metadata_"
                            + Integer.toString(k) + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.DeleteMetadata")
                            + Integer.toString(k) + "\"/>\n" + "      </a>&nbsp;\n" + "    </td>\n"
                            + "    <td class=\"value\" colspan=\"3\">\n" + "      "
                            + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(categoryPath) + ":"
                            + ((isAll != null && isAll.equals("true")) ? "(All metadata attributes)"
                                    : org.apache.manifoldcf.ui.util.Encoder.bodyEscape(attributeName))
                            + "\n" + "    </td>\n" + "  </tr>\n");
                    k++;
                }
            }
            if (k == 0) {
                out.print("  <tr>\n" + "    <td class=\"message\" colspan=\"4\">"
                        + Messages.getBodyString(locale, "LivelinkConnector.NoMetadataSpecified") + "</td>\n"
                        + "  </tr>\n");
            }
            out.print("  <tr><td class=\"lightseparator\" colspan=\"4\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\" colspan=\"1\">\n" + "      <a name=\"" + seqPrefix
                    + "metadata_" + Integer.toString(k) + "\"></a>\n" + "      <input type=\"hidden\" name=\""
                    + seqPrefix + "metadatacount\" value=\"" + Integer.toString(k) + "\"/>\n");
            String categorySoFar = (String) currentContext.get(seqPrefix + "speccategory");
            if (categorySoFar == null)
                categorySoFar = "";
            // Grab next folder/project list, and the appropriate category list
            try {
                String[] childList = null;
                String[] workspaceList = null;
                String[] categoryList = null;
                String[] attributeList = null;
                if (categorySoFar.length() == 0) {
                    workspaceList = getWorkspaceNames();
                } else {
                    attributeList = getCategoryAttributes(categorySoFar);
                    if (attributeList == null) {
                        childList = getChildFolderNames(categorySoFar);
                        if (childList == null) {
                            // Illegal path - set it back
                            categorySoFar = "";
                            childList = getChildFolderNames("");
                            if (childList == null)
                                throw new ManifoldCFException("Can't find any children for root folder");
                        }
                        categoryList = getChildCategoryNames(categorySoFar);
                        if (categoryList == null)
                            throw new ManifoldCFException("Can't find any categories for root folder folder");
                    }
                }
                out.print("      <input type=\"hidden\" name=\"" + seqPrefix + "speccategory\" value=\""
                        + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(categorySoFar) + "\"/>\n"
                        + "      <input type=\"hidden\" name=\"" + seqPrefix + "metadataop\" value=\"\"/>\n");
                if (attributeList != null) {
                    // We have a valid category!
                    out.print("      <input type=\"button\" value=\"Add\" onClick='Javascript:" + seqPrefix
                            + "SpecAddMetadata(\"" + seqPrefix + "metadata_" + Integer.toString(k + 1)
                            + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.AddMetadataItem")
                            + "\"/>&nbsp;\n" + "    </td>\n" + "    <td class=\"value\" colspan=\"3\">\n" + "      "
                            + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(categorySoFar)
                            + ":<input type=\"button\" value=\"-\" onClick='Javascript:" + seqPrefix + "SpecOp(\""
                            + seqPrefix + "metadataop\",\"Up\",\"" + seqPrefix + "metadata_" + Integer.toString(k)
                            + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.BackUpMetadataPath")
                            + "\"/>&nbsp;\n" + "      <table class=\"displaytable\">\n" + "        <tr>\n"
                            + "          <td class=\"value\">\n" + "            <input type=\"checkbox\" name=\""
                            + seqPrefix + "attributeall\" value=\"true\"/>"
                            + Messages.getBodyString(locale, "LivelinkConnector.AllAttributesInThisCategory")
                            + "<br/>\n" + "            <select multiple=\"true\" name=\"" + seqPrefix
                            + "attributeselect\" size=\"2\">\n"
                            + "              <option value=\"\" selected=\"selected\">"
                            + Messages.getBodyString(locale, "LivelinkConnector.PickAttributes") + "</option>\n");
                    int l = 0;
                    while (l < attributeList.length) {
                        String attributeName = attributeList[l++];
                        out.print("              <option value=\""
                                + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(attributeName) + "\">"
                                + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(attributeName) + "</option>\n");
                    }
                    out.print("            </select>\n" + "          </td>\n" + "        </tr>\n"
                            + "      </table>\n");
                } else if (workspaceList != null) {
                    out.print("    </td>\n" + "    <td class=\"value\" colspan=\"3\">\n"
                            + "      <input type=\"button\" value=\"+\" onClick='Javascript:" + seqPrefix
                            + "SpecSetWorkspace(\"" + seqPrefix + "metadata_" + Integer.toString(k) + "\")' alt=\""
                            + Messages.getAttributeString(locale, "LivelinkConnector.AddToMetadataPath")
                            + "\"/>&nbsp;\n" + "      <select multiple=\"false\" name=\"" + seqPrefix
                            + "metadataaddon\" size=\"2\">\n" + "        <option value=\"\" selected=\"selected\">"
                            + Messages.getBodyString(locale, "LivelinkConnector.PickWorkspace") + "</option>\n");
                    int j = 0;
                    while (j < workspaceList.length) {
                        out.print("        <option value=\""
                                + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(workspaceList[j]) + "\">"
                                + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(workspaceList[j])
                                + "</option>\n");
                        j++;
                    }
                    out.print("      </select>\n");
                } else {
                    out.print("    </td>\n" + "    <td class=\"value\" colspan=\"3\">\n" + "      "
                            + ((categorySoFar.length() == 0) ? "(root)"
                                    : org.apache.manifoldcf.ui.util.Encoder.bodyEscape(categorySoFar))
                            + "&nbsp;\n");
                    if (categorySoFar.length() > 0) {
                        out.print(
                                "      <input type=\"button\" value=\"-\" onClick='Javascript:" + seqPrefix
                                        + "SpecOp(\"" + seqPrefix + "metadataop\",\"Up\",\"" + seqPrefix
                                        + "metadata_" + Integer.toString(k) + "\")' alt=\"" + Messages
                                                .getAttributeString(locale, "LivelinkConnector.BackUpMetadataPath")
                                        + "\"/>&nbsp;\n");
                    }
                    if (childList.length > 0) {
                        out.print("      <input type=\"button\" value=\"+\" onClick='Javascript:" + seqPrefix
                                + "SpecAddToMetadata(\"" + seqPrefix + "metadata_" + Integer.toString(k)
                                + "\")' alt=\""
                                + Messages.getAttributeString(locale, "LivelinkConnector.AddToMetadataPath")
                                + "\"/>&nbsp;\n" + "      <select multiple=\"false\" name=\"" + seqPrefix
                                + "metadataaddon\" size=\"2\">\n"
                                + "        <option value=\"\" selected=\"selected\">"
                                + Messages.getBodyString(locale, "LivelinkConnector.PickAFolder") + "</option>\n");
                        int j = 0;
                        while (j < childList.length) {
                            out.print("        <option value=\""
                                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(childList[j]) + "\">"
                                    + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(childList[j])
                                    + "</option>\n");
                            j++;
                        }
                        out.print("      </select>\n");
                    }
                    if (categoryList.length > 0) {
                        out.print("      <input type=\"button\" value=\"+\" onClick='Javascript:" + seqPrefix
                                + "SpecAddCategory(\"" + seqPrefix + "metadata_" + Integer.toString(k)
                                + "\")' alt=\""
                                + Messages.getAttributeString(locale, "LivelinkConnector.AddCategory")
                                + "\"/>&nbsp;\n" + "      <select multiple=\"false\" name=\"" + seqPrefix
                                + "categoryaddon\" size=\"2\">\n"
                                + "        <option value=\"\" selected=\"selected\">"
                                + Messages.getBodyString(locale, "LivelinkConnector.PickACategory")
                                + "</option>\n");
                        int j = 0;
                        while (j < categoryList.length) {
                            out.print("        <option value=\""
                                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(categoryList[j]) + "\">"
                                    + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(categoryList[j])
                                    + "</option>\n");
                            j++;
                        }
                        out.print("      </select>\n");
                    }
                }
            } catch (ServiceInterruption e) {
                //e.printStackTrace();
                out.println(org.apache.manifoldcf.ui.util.Encoder.bodyEscape(e.getMessage()));
            } catch (ManifoldCFException e) {
                //e.printStackTrace();
                out.println(org.apache.manifoldcf.ui.util.Encoder.bodyEscape(e.getMessage()));
            }
            out.print("    </td>\n" + "  </tr>\n" + "  <tr><td class=\"separator\" colspan=\"4\"><hr/></td></tr>\n"
                    + "  <tr>\n" + "    <td class=\"description\" colspan=\"1\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.PathAttributeName") + "</nobr></td>\n"
                    + "    <td class=\"value\" colspan=\"1\">\n" + "      <input type=\"text\" name=\"" + seqPrefix
                    + "specpathnameattribute\" size=\"20\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(pathNameAttribute) + "\"/>\n"
                    + "    </td>\n" + "    <td class=\"description\" colspan=\"1\"><nobr>"
                    + Messages.getBodyString(locale, "LivelinkConnector.PathSeparatorString") + "</nobr></td>\n"
                    + "    <td class=\"value\" colspan=\"1\">\n" + "      <input type=\"text\" name=\"" + seqPrefix
                    + "specpathnameseparator\" size=\"20\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(pathNameSeparator) + "\"/>\n"
                    + "    </td>\n" + "  </tr>\n"
                    + "  <tr><td class=\"separator\" colspan=\"4\"><hr/></td></tr>\n");
            i = 0;
            while (i < matchMap.getMatchCount()) {
                String matchString = matchMap.getMatchString(i);
                String replaceString = matchMap.getReplaceString(i);
                out.print("  <tr>\n" + "    <td class=\"description\">\n" + "      <input type=\"hidden\" name=\""
                        + seqPrefix + "specmappingop_" + Integer.toString(i) + "\" value=\"\"/>\n"
                        + "      <a name=\"" + seqPrefix + "mapping_" + Integer.toString(i) + "\">\n"
                        + "        <input type=\"button\" onClick='Javascript:" + seqPrefix + "SpecOp(\""
                        + seqPrefix + "specmappingop_" + Integer.toString(i) + "\",\"Delete\",\"" + seqPrefix
                        + "mapping_" + Integer.toString(i) + "\")' alt=\""
                        + Messages.getAttributeString(locale, "LivelinkConnector.DeleteMapping")
                        + Integer.toString(i) + "\" value=\"Delete\"/>\n" + "      </a>\n" + "    </td>\n"
                        + "    <td class=\"value\">\n" + "      <input type=\"hidden\" name=\"" + seqPrefix
                        + "specmatch_" + Integer.toString(i) + "\" value=\""
                        + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(matchString) + "\"/>"
                        + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(matchString) + "\n" + "    </td>\n"
                        + "    <td class=\"value\">==></td>\n" + "    <td class=\"value\">\n"
                        + "      <input type=\"hidden\" name=\"" + seqPrefix + "specreplace_" + Integer.toString(i)
                        + "\" value=\"" + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(replaceString)
                        + "\"/>" + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(replaceString) + "\n"
                        + "    </td>\n" + "  </tr>\n");
                i++;
            }
            if (i == 0) {
                out.print("  <tr><td colspan=\"4\" class=\"message\">"
                        + Messages.getBodyString(locale, "LivelinkConnector.NoMappingsSpecified") + "</td></tr>\n");
            }
            out.print("  <tr><td class=\"lightseparator\" colspan=\"4\"><hr/></td></tr>\n" + "  <tr>\n"
                    + "    <td class=\"description\">\n" + "      <a name=\"" + seqPrefix + "mapping_"
                    + Integer.toString(i) + "\">\n" + "        <input type=\"button\" onClick='Javascript:"
                    + seqPrefix + "SpecAddMapping(\"" + seqPrefix + "mapping_" + Integer.toString(i + 1)
                    + "\")' alt=\"" + Messages.getAttributeString(locale, "LivelinkConnector.AddToMappings")
                    + "\" value=\"Add\"/>\n" + "      </a>\n" + "    </td>\n"
                    + "    <td class=\"value\">Match regexp:&nbsp;<input type=\"text\" name=\"" + seqPrefix
                    + "specmatch\" size=\"32\" value=\"\"/></td>\n" + "    <td class=\"value\">==></td>\n"
                    + "    <td class=\"value\">Replace string:&nbsp;<input type=\"text\" name=\"" + seqPrefix
                    + "specreplace\" size=\"32\" value=\"\"/></td>\n" + "  </tr>\n" + "</table>\n");
        } else {
            out.print("<input type=\"hidden\" name=\"" + seqPrefix + "specallmetadata\" value=\""
                    + ingestAllMetadata + "\"/>\n");
            // Go through the selected metadata attributes
            i = 0;
            k = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i++);
                if (sn.getType().equals("metadata")) {
                    String accessDescription = "_" + Integer.toString(k);
                    String categoryPath = sn.getAttributeValue("category");
                    String isAll = sn.getAttributeValue("all");
                    if (isAll == null)
                        isAll = "false";
                    String attributeName = sn.getAttributeValue("attribute");
                    if (attributeName == null)
                        attributeName = "";
                    out.print("<input type=\"hidden\" name=\"" + seqPrefix + "speccategory" + accessDescription
                            + "\" value=\"" + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(categoryPath)
                            + "\"/>\n" + "<input type=\"hidden\" name=\"" + seqPrefix + "specattributeall"
                            + accessDescription + "\" value=\"" + isAll + "\"/>\n"
                            + "<input type=\"hidden\" name=\"" + seqPrefix + "specattribute" + accessDescription
                            + "\" value=\"" + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(attributeName)
                            + "\"/>\n");
                    k++;
                }
            }
            out.print("<input type=\"hidden\" name=\"" + seqPrefix + "metadatacount\" value=\""
                    + Integer.toString(k) + "\"/>\n" + "<input type=\"hidden\" name=\"" + seqPrefix
                    + "specpathnameattribute\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(pathNameAttribute) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"" + seqPrefix + "specpathnameseparator\" value=\""
                    + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(pathNameSeparator) + "\"/>\n"
                    + "<input type=\"hidden\" name=\"" + seqPrefix + "specmappingcount\" value=\""
                    + Integer.toString(matchMap.getMatchCount()) + "\"/>\n");
            i = 0;
            while (i < matchMap.getMatchCount()) {
                String matchString = matchMap.getMatchString(i);
                String replaceString = matchMap.getReplaceString(i);
                out.print("<input type=\"hidden\" name=\"" + seqPrefix + "specmatch_" + Integer.toString(i)
                        + "\" value=\"" + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(matchString)
                        + "\"/>\n" + "<input type=\"hidden\" name=\"" + seqPrefix + "specreplace_"
                        + Integer.toString(i) + "\" value=\""
                        + org.apache.manifoldcf.ui.util.Encoder.attributeEscape(replaceString) + "\"/>\n");
                i++;
            }
        }
    }

    /** Process a specification post.
    * This method is called at the start of job's edit or view page, whenever there is a possibility that form
    * data for a connection has been posted.  Its purpose is to gather form information and modify the
    * document specification accordingly.  The name of the posted form is always "editjob".
    * The connector will be connected before this method can be called.
    *@param variableContext contains the post data, including binary file-upload information.
    *@param locale is the locale the output is preferred to be in.
    *@param ds is the current document specification for this job.
    *@param connectionSequenceNumber is the unique number of this connection within the job.
    *@return null if all is well, or a string error message if there is an error that should prevent saving of
    * the job (and cause a redirection to an error page).
    */
    @Override
    public String processSpecificationPost(IPostParameters variableContext, Locale locale, Specification ds,
            int connectionSequenceNumber) throws ManifoldCFException {
        String seqPrefix = "s" + connectionSequenceNumber + "_";

        String userWorkspacesPresent = variableContext.getParameter(seqPrefix + "userworkspace_present");
        if (userWorkspacesPresent != null) {
            String value = variableContext.getParameter(seqPrefix + "userworkspace");
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("userworkspace"))
                    ds.removeChild(i);
                else
                    i++;
            }
            SpecificationNode sn = new SpecificationNode("userworkspace");
            sn.setAttribute("value", value);
            ds.addChild(ds.getChildCount(), sn);
        }

        String xc = variableContext.getParameter(seqPrefix + "pathcount");
        if (xc != null) {
            // Delete all path specs first
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("startpoint"))
                    ds.removeChild(i);
                else
                    i++;
            }

            // Find out how many children were sent
            int pathCount = Integer.parseInt(xc);
            // Gather up these
            i = 0;
            while (i < pathCount) {
                String pathDescription = "_" + Integer.toString(i);
                String pathOpName = seqPrefix + "pathop" + pathDescription;
                xc = variableContext.getParameter(pathOpName);
                if (xc != null && xc.equals("Delete")) {
                    // Skip to the next
                    i++;
                    continue;
                }
                // Path inserts won't happen until the very end
                String path = variableContext.getParameter(seqPrefix + "specpath" + pathDescription);
                SpecificationNode node = new SpecificationNode("startpoint");
                node.setAttribute("path", path);
                ds.addChild(ds.getChildCount(), node);
                i++;
            }

            // See if there's a global add operation
            String op = variableContext.getParameter(seqPrefix + "pathop");
            if (op != null && op.equals("Add")) {
                String path = variableContext.getParameter("specpath");
                SpecificationNode node = new SpecificationNode("startpoint");
                node.setAttribute("path", path);
                ds.addChild(ds.getChildCount(), node);
            } else if (op != null && op.equals("Up")) {
                // Strip off end
                String path = variableContext.getParameter(seqPrefix + "specpath");
                int lastSlash = -1;
                int k = 0;
                while (k < path.length()) {
                    char x = path.charAt(k++);
                    if (x == '/') {
                        lastSlash = k - 1;
                        continue;
                    }
                    if (x == '\\')
                        k++;
                }
                if (lastSlash == -1)
                    path = "";
                else
                    path = path.substring(0, lastSlash);
                currentContext.save(seqPrefix + "specpath", path);
            } else if (op != null && op.equals("AddToPath")) {
                String path = variableContext.getParameter(seqPrefix + "specpath");
                String addon = variableContext.getParameter(seqPrefix + "pathaddon");
                if (addon != null && addon.length() > 0) {
                    StringBuilder sb = new StringBuilder();
                    int k = 0;
                    while (k < addon.length()) {
                        char x = addon.charAt(k++);
                        if (x == '/' || x == '\\' || x == ':')
                            sb.append('\\');
                        sb.append(x);
                    }
                    if (path.length() == 0)
                        path = sb.toString();
                    else
                        path += "/" + sb.toString();
                }
                currentContext.save(seqPrefix + "specpath", path);
            }
        }

        xc = variableContext.getParameter(seqPrefix + "filecount");
        if (xc != null) {
            // Delete all file specs first
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("include") || sn.getType().equals("exclude"))
                    ds.removeChild(i);
                else
                    i++;
            }

            int fileCount = Integer.parseInt(xc);
            i = 0;
            while (i < fileCount) {
                String fileSpecDescription = "_" + Integer.toString(i);
                String fileOpName = seqPrefix + "fileop" + fileSpecDescription;
                xc = variableContext.getParameter(fileOpName);
                if (xc != null && xc.equals("Delete")) {
                    // Next row
                    i++;
                    continue;
                }
                // Get the stuff we need
                String filespecType = variableContext
                        .getParameter(seqPrefix + "specfiletype" + fileSpecDescription);
                String filespec = variableContext.getParameter(seqPrefix + "specfile" + fileSpecDescription);
                SpecificationNode node = new SpecificationNode(filespecType);
                node.setAttribute("filespec", filespec);
                ds.addChild(ds.getChildCount(), node);
                i++;
            }

            String op = variableContext.getParameter(seqPrefix + "fileop");
            if (op != null && op.equals("Add")) {
                String filespec = variableContext.getParameter(seqPrefix + "specfile");
                String filespectype = variableContext.getParameter(seqPrefix + "specfiletype");
                SpecificationNode node = new SpecificationNode(filespectype);
                node.setAttribute("filespec", filespec);
                ds.addChild(ds.getChildCount(), node);
            }
        }

        xc = variableContext.getParameter(seqPrefix + "specsecurity");
        if (xc != null) {
            // Delete all security entries first
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("security"))
                    ds.removeChild(i);
                else
                    i++;
            }

            SpecificationNode node = new SpecificationNode("security");
            node.setAttribute("value", xc);
            ds.addChild(ds.getChildCount(), node);

        }

        xc = variableContext.getParameter(seqPrefix + "tokencount");
        if (xc != null) {
            // Delete all file specs first
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("access"))
                    ds.removeChild(i);
                else
                    i++;
            }

            int accessCount = Integer.parseInt(xc);
            i = 0;
            while (i < accessCount) {
                String accessDescription = "_" + Integer.toString(i);
                String accessOpName = seqPrefix + "accessop" + accessDescription;
                xc = variableContext.getParameter(accessOpName);
                if (xc != null && xc.equals("Delete")) {
                    // Next row
                    i++;
                    continue;
                }
                // Get the stuff we need
                String accessSpec = variableContext.getParameter(seqPrefix + "spectoken" + accessDescription);
                SpecificationNode node = new SpecificationNode("access");
                node.setAttribute("token", accessSpec);
                ds.addChild(ds.getChildCount(), node);
                i++;
            }

            String op = variableContext.getParameter(seqPrefix + "accessop");
            if (op != null && op.equals("Add")) {
                String accessspec = variableContext.getParameter(seqPrefix + "spectoken");
                SpecificationNode node = new SpecificationNode("access");
                node.setAttribute("token", accessspec);
                ds.addChild(ds.getChildCount(), node);
            }
        }

        xc = variableContext.getParameter(seqPrefix + "specallmetadata");
        if (xc != null) {
            // Look for the 'all metadata' checkbox
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("allmetadata"))
                    ds.removeChild(i);
                else
                    i++;
            }

            if (xc.equals("true")) {
                SpecificationNode newNode = new SpecificationNode("allmetadata");
                newNode.setAttribute("all", xc);
                ds.addChild(ds.getChildCount(), newNode);
            }
        }

        xc = variableContext.getParameter(seqPrefix + "metadatacount");
        if (xc != null) {
            // Delete all metadata specs first
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("metadata"))
                    ds.removeChild(i);
                else
                    i++;
            }

            // Find out how many children were sent
            int metadataCount = Integer.parseInt(xc);
            // Gather up these
            i = 0;
            while (i < metadataCount) {
                String pathDescription = "_" + Integer.toString(i);
                String pathOpName = seqPrefix + "metadataop" + pathDescription;
                xc = variableContext.getParameter(pathOpName);
                if (xc != null && xc.equals("Delete")) {
                    // Skip to the next
                    i++;
                    continue;
                }
                // Metadata inserts won't happen until the very end
                String category = variableContext.getParameter(seqPrefix + "speccategory" + pathDescription);
                String attributeName = variableContext.getParameter(seqPrefix + "specattribute" + pathDescription);
                String isAll = variableContext.getParameter(seqPrefix + "specattributeall" + pathDescription);
                SpecificationNode node = new SpecificationNode("metadata");
                node.setAttribute("category", category);
                if (isAll != null && isAll.equals("true"))
                    node.setAttribute("all", "true");
                else
                    node.setAttribute("attribute", attributeName);
                ds.addChild(ds.getChildCount(), node);
                i++;
            }

            // See if there's a global add operation
            String op = variableContext.getParameter(seqPrefix + "metadataop");
            if (op != null && op.equals("Add")) {
                String category = variableContext.getParameter(seqPrefix + "speccategory");
                String isAll = variableContext.getParameter(seqPrefix + "attributeall");
                if (isAll != null && isAll.equals("true")) {
                    SpecificationNode node = new SpecificationNode("metadata");
                    node.setAttribute("category", category);
                    node.setAttribute("all", "true");
                    ds.addChild(ds.getChildCount(), node);
                } else {
                    String[] attributes = variableContext.getParameterValues(seqPrefix + "attributeselect");
                    if (attributes != null && attributes.length > 0) {
                        int k = 0;
                        while (k < attributes.length) {
                            String attribute = attributes[k++];
                            SpecificationNode node = new SpecificationNode("metadata");
                            node.setAttribute("category", category);
                            node.setAttribute("attribute", attribute);
                            ds.addChild(ds.getChildCount(), node);
                        }
                    }
                }
            } else if (op != null && op.equals("Up")) {
                // Strip off end
                String category = variableContext.getParameter(seqPrefix + "speccategory");
                int lastSlash = -1;
                int firstColon = -1;
                int k = 0;
                while (k < category.length()) {
                    char x = category.charAt(k++);
                    if (x == '/') {
                        lastSlash = k - 1;
                        continue;
                    }
                    if (x == ':') {
                        firstColon = k;
                        continue;
                    }
                    if (x == '\\')
                        k++;
                }

                if (lastSlash == -1) {
                    if (firstColon == -1 || firstColon == category.length())
                        category = "";
                    else
                        category = category.substring(0, firstColon);
                } else
                    category = category.substring(0, lastSlash);
                currentContext.save(seqPrefix + "speccategory", category);
            } else if (op != null && op.equals("AddToPath")) {
                String category = variableContext.getParameter(seqPrefix + "speccategory");
                String addon = variableContext.getParameter(seqPrefix + "metadataaddon");
                if (addon != null && addon.length() > 0) {
                    StringBuilder sb = new StringBuilder();
                    int k = 0;
                    while (k < addon.length()) {
                        char x = addon.charAt(k++);
                        if (x == '/' || x == '\\' || x == ':')
                            sb.append('\\');
                        sb.append(x);
                    }
                    if (category.length() == 0 || category.endsWith(":"))
                        category += sb.toString();
                    else
                        category += "/" + sb.toString();
                }
                currentContext.save(seqPrefix + "speccategory", category);
            } else if (op != null && op.equals("SetWorkspace")) {
                String addon = variableContext.getParameter(seqPrefix + "metadataaddon");
                if (addon != null && addon.length() > 0) {
                    StringBuilder sb = new StringBuilder();
                    int k = 0;
                    while (k < addon.length()) {
                        char x = addon.charAt(k++);
                        if (x == '/' || x == '\\' || x == ':')
                            sb.append('\\');
                        sb.append(x);
                    }

                    String category = sb.toString() + ":";
                    currentContext.save(seqPrefix + "speccategory", category);
                }
            } else if (op != null && op.equals("AddCategory")) {
                String category = variableContext.getParameter(seqPrefix + "speccategory");
                String addon = variableContext.getParameter(seqPrefix + "categoryaddon");
                if (addon != null && addon.length() > 0) {
                    StringBuilder sb = new StringBuilder();
                    int k = 0;
                    while (k < addon.length()) {
                        char x = addon.charAt(k++);
                        if (x == '/' || x == '\\' || x == ':')
                            sb.append('\\');
                        sb.append(x);
                    }
                    if (category.length() == 0 || category.endsWith(":"))
                        category += sb.toString();
                    else
                        category += "/" + sb.toString();
                }
                currentContext.save(seqPrefix + "speccategory", category);
            }
        }

        xc = variableContext.getParameter(seqPrefix + "specpathnameattribute");
        if (xc != null) {
            String separator = variableContext.getParameter(seqPrefix + "specpathnameseparator");
            if (separator == null)
                separator = "/";
            // Delete old one
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("pathnameattribute"))
                    ds.removeChild(i);
                else
                    i++;
            }
            if (xc.length() > 0) {
                SpecificationNode node = new SpecificationNode("pathnameattribute");
                node.setAttribute("value", xc);
                node.setAttribute("separator", separator);
                ds.addChild(ds.getChildCount(), node);
            }
        }

        xc = variableContext.getParameter(seqPrefix + "specmappingcount");
        if (xc != null) {
            // Delete old spec
            int i = 0;
            while (i < ds.getChildCount()) {
                SpecificationNode sn = ds.getChild(i);
                if (sn.getType().equals("pathmap"))
                    ds.removeChild(i);
                else
                    i++;
            }

            // Now, go through the data and assemble a new list.
            int mappingCount = Integer.parseInt(xc);

            // Gather up these
            i = 0;
            while (i < mappingCount) {
                String pathDescription = "_" + Integer.toString(i);
                String pathOpName = seqPrefix + "specmappingop" + pathDescription;
                xc = variableContext.getParameter(pathOpName);
                if (xc != null && xc.equals("Delete")) {
                    // Skip to the next
                    i++;
                    continue;
                }
                // Inserts won't happen until the very end
                String match = variableContext.getParameter(seqPrefix + "specmatch" + pathDescription);
                String replace = variableContext.getParameter(seqPrefix + "specreplace" + pathDescription);
                SpecificationNode node = new SpecificationNode("pathmap");
                node.setAttribute("match", match);
                node.setAttribute("replace", replace);
                ds.addChild(ds.getChildCount(), node);
                i++;
            }

            // Check for add
            xc = variableContext.getParameter(seqPrefix + "specmappingop");
            if (xc != null && xc.equals("Add")) {
                String match = variableContext.getParameter(seqPrefix + "specmatch");
                String replace = variableContext.getParameter(seqPrefix + "specreplace");
                SpecificationNode node = new SpecificationNode("pathmap");
                node.setAttribute("match", match);
                node.setAttribute("replace", replace);
                ds.addChild(ds.getChildCount(), node);
            }
        }
        return null;
    }

    /** View specification.
    * This method is called in the body section of a job's view page.  Its purpose is to present the document
    * specification information to the user.  The coder can presume that the HTML that is output from
    * this configuration will be within appropriate <html> and <body> tags.
    * The connector will be connected before this method can be called.
    *@param out is the output to which any HTML should be sent.
    *@param locale is the locale the output is preferred to be in.
    *@param ds is the current document specification for this job.
    *@param connectionSequenceNumber is the unique number of this connection within the job.
    */
    @Override
    public void viewSpecification(IHTTPOutput out, Locale locale, Specification ds, int connectionSequenceNumber)
            throws ManifoldCFException, IOException {
        out.print("<table class=\"displaytable\">\n" + "  <tr>\n");
        int i = 0;
        boolean userWorkspaces = false;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("userworkspace")) {
                String value = sn.getAttributeValue("value");
                if (value != null && value.equals("true"))
                    userWorkspaces = true;
            }
        }

        out.print("    <td class=\"description\"/>\n" + "      <nobr>"
                + Messages.getBodyString(locale, "LivelinkConnector.CrawlUserWorkspaces") + "</nobr>\n"
                + "    </td>\n" + "    <td class=\"value\"/>\n" + "      "
                + (userWorkspaces ? Messages.getBodyString(locale, "LivelinkConnector.Yes")
                        : Messages.getBodyString(locale, "LivelinkConnector.No"))
                + "\n" + "    </td>\n" + "  </tr>");
        out.print("  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
        out.print("  <tr>");

        i = 0;
        boolean seenAny = false;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("startpoint")) {
                if (seenAny == false) {
                    out.print("    <td class=\"description\">"
                            + Messages.getBodyString(locale, "LivelinkConnector.Roots") + "</td>\n"
                            + "    <td class=\"value\">\n");
                    seenAny = true;
                }
                out.print("      " + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(sn.getAttributeValue("path"))
                        + "<br/>\n");
            }
        }

        if (seenAny) {
            out.print("    </td>\n" + "  </tr>\n");
        } else {
            out.print("  <tr><td class=\"message\" colspan=\"2\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.NoStartPointsSpecified") + "</td></tr>\n");
        }
        out.print("  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");

        seenAny = false;
        // Go through looking for include or exclude file specs
        i = 0;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("include") || sn.getType().equals("exclude")) {
                if (seenAny == false) {
                    out.print("  <tr><td class=\"description\">"
                            + Messages.getBodyString(locale, "LivelinkConnector.FileSpecs") + "</td>\n"
                            + "    <td class=\"value\">\n");
                    seenAny = true;
                }
                String filespec = sn.getAttributeValue("filespec");
                out.print("      " + (sn.getType().equals("include") ? "Include file:" : "") + "\n" + "      "
                        + (sn.getType().equals("exclude") ? "Exclude file:" : "") + "\n" + "      "
                        + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(filespec) + "<br/>\n");
            }
        }

        if (seenAny) {
            out.print("    </td>\n" + "  </tr>\n");
        } else {
            out.print("  <tr><td class=\"message\" colspan=\"2\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.NoFileSpecsSpecified") + "</td></tr>\n");
        }
        out.print("  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
        // Find whether security is on or off
        i = 0;
        boolean securityOn = true;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("security")) {
                String securityValue = sn.getAttributeValue("value");
                if (securityValue.equals("off"))
                    securityOn = false;
                else if (securityValue.equals("on"))
                    securityOn = true;
            }
        }
        out.print("  <tr>\n" + "    <td class=\"description\">"
                + Messages.getBodyString(locale, "LivelinkConnector.SecurityColon") + "</td>\n"
                + "    <td class=\"value\">"
                + (securityOn ? Messages.getBodyString(locale, "LivelinkConnector.Enabled2")
                        : Messages.getBodyString(locale, "LivelinkConnector.Disabled"))
                + "</td>\n" + "  </tr>\n" + "\n" + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
        // Go through looking for access tokens
        seenAny = false;
        i = 0;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("access")) {
                if (seenAny == false) {
                    out.print("  <tr><td class=\"description\">"
                            + Messages.getBodyString(locale, "LivelinkConnector.AccessTokens") + "</td>\n"
                            + "    <td class=\"value\">\n");
                    seenAny = true;
                }
                String token = sn.getAttributeValue("token");
                out.print("      " + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(token) + "<br/>\n");
            }
        }

        if (seenAny) {
            out.print("    </td>\n" + "  </tr>\n");
        } else {
            out.print("  <tr><td class=\"message\" colspan=\"2\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.NoAccessTokensSpecified") + "</td></tr>\n");
        }
        out.print("  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
        i = 0;
        String allMetadata = Messages.getBodyString(locale,
                "LivelinkConnector.OnlySpecifiedMetadataWillBeIngested");
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("allmetadata")) {
                String value = sn.getAttributeValue("all");
                if (value != null && value.equals("true")) {
                    allMetadata = Messages.getBodyString(locale,
                            "LivelinkConnector.AllDocumentMetadataWillBeIngested");
                }
            }
        }
        out.print("  <tr>\n" + "    <td class=\"description\"><nobr>"
                + Messages.getBodyString(locale, "LivelinkConnector.MetadataSpecification") + "</nobr></td>\n"
                + "    <td class=\"value\"><nobr>" + allMetadata + "</nobr></td>\n" + "  </tr>\n"
                + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
        // Go through looking for metadata spec
        seenAny = false;
        i = 0;
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("metadata")) {
                if (seenAny == false) {
                    out.print("  <tr><td class=\"description\"><nobr>"
                            + Messages.getBodyString(locale, "LivelinkConnector.SpecificMetadata")
                            + "</nobr></td>\n" + "    <td class=\"value\">\n");
                    seenAny = true;
                }
                String category = sn.getAttributeValue("category");
                String attribute = sn.getAttributeValue("attribute");
                String isAll = sn.getAttributeValue("all");
                out.print("      " + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(category) + ":"
                        + ((isAll != null && isAll.equals("true")) ? "(All metadata attributes)"
                                : org.apache.manifoldcf.ui.util.Encoder.bodyEscape(attribute))
                        + "<br/>\n");
            }
        }

        if (seenAny) {
            out.print("    </td>\n" + "  </tr>\n");
        } else {
            out.print("  <tr><td class=\"message\" colspan=\"2\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.NoMetadataSpecified") + "</td></tr>\n");
        }
        out.print("  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n");
        // Find the path-name metadata attribute name
        i = 0;
        String pathNameAttribute = "";
        String pathSeparator = "/";
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("pathnameattribute")) {
                pathNameAttribute = sn.getAttributeValue("value");
                if (sn.getAttributeValue("separator") != null)
                    pathSeparator = sn.getAttributeValue("separator");
            }
        }
        if (pathNameAttribute.length() > 0) {
            out.print("  <tr>\n" + "    <td class=\"description\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.PathNameMetadataAttribute") + "</td>\n"
                    + "    <td class=\"value\">"
                    + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(pathNameAttribute) + "</td>\n" + "  </tr>\n"
                    + "  <tr>\n" + "    <td class=\"description\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.PathSeparatorString") + "</td>\n"
                    + "    <td class=\"value\">" + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(pathSeparator)
                    + "</td>\n" + "  </tr>\n");
        } else {
            out.print("  <tr>\n" + "    <td class=\"message\" colspan=\"2\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.NoPathNameMetadataAttributeSpecified")
                    + "</td>\n" + "  </tr>\n");
        }
        out.print("\n" + "  <tr><td class=\"separator\" colspan=\"2\"><hr/></td></tr>\n" + "\n" + "  <tr>\n");
        // Find the path-value mapping data
        i = 0;
        MatchMap matchMap = new MatchMap();
        while (i < ds.getChildCount()) {
            SpecificationNode sn = ds.getChild(i++);
            if (sn.getType().equals("pathmap")) {
                String pathMatch = sn.getAttributeValue("match");
                String pathReplace = sn.getAttributeValue("replace");
                matchMap.appendMatchPair(pathMatch, pathReplace);
            }
        }
        if (matchMap.getMatchCount() > 0) {
            out.print("    <td class=\"description\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.PathValueMapping") + "</td>\n"
                    + "    <td class=\"value\">\n" + "      <table class=\"displaytable\">\n");
            i = 0;
            while (i < matchMap.getMatchCount()) {
                String matchString = matchMap.getMatchString(i);
                String replaceString = matchMap.getReplaceString(i);
                out.print("        <tr>\n" + "          <td class=\"value\">"
                        + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(matchString) + "</td>\n"
                        + "          <td class=\"value\">--></td>\n" + "          <td class=\"value\">"
                        + org.apache.manifoldcf.ui.util.Encoder.bodyEscape(replaceString) + "</td>\n"
                        + "        </tr>\n");
                i++;
            }
            out.print("      </table>\n" + "    </td>\n");
        } else {
            out.print("    <td class=\"message\" colspan=\"2\">"
                    + Messages.getBodyString(locale, "LivelinkConnector.NoMappingsSpecified") + "</td>\n");
        }
        out.print("  </tr>\n" + "</table>\n");
    }

    // The following public methods are NOT part of the interface.  They are here so that the UI can present information
    // that will allow users to select what they need.

    protected final static String CATEGORY_NAME = "CATEGORY";
    protected final static String ENTWKSPACE_NAME = "ENTERPRISE";

    /** Get the allowed workspace names.
    *@return a list of workspace names.
    */
    public String[] getWorkspaceNames() throws ManifoldCFException, ServiceInterruption {
        return new String[] { CATEGORY_NAME, ENTWKSPACE_NAME };
    }

    /** Given a path string, get a list of folders and projects under that node.
    *@param pathString is the current path (folder names and project names, separated by dots (.)).
    *@return a list of folder and project names, in sorted order, or null if the path was invalid.
    */
    public String[] getChildFolderNames(String pathString) throws ManifoldCFException, ServiceInterruption {
        getSession();
        return getChildFolders(new LivelinkContext(), pathString);
    }

    /** Given a path string, get a list of categories under that node.
    *@param pathString is the current path (folder names and project names, separated by dots (.)).
    *@return a list of category names, in sorted order, or null if the path was invalid.
    */
    public String[] getChildCategoryNames(String pathString) throws ManifoldCFException, ServiceInterruption {
        getSession();
        return getChildCategories(new LivelinkContext(), pathString);
    }

    /** Given a category path, get a list of legal attribute names.
    *@param pathString is the current path of a category (with path components separated by dots).
    *@return a list of attribute names, in sorted order, or null of the path was invalid.
    */
    public String[] getCategoryAttributes(String pathString) throws ManifoldCFException, ServiceInterruption {
        getSession();
        return getCategoryAttributes(new LivelinkContext(), pathString);
    }

    protected String[] getCategoryAttributes(LivelinkContext llc, String pathString)
            throws ManifoldCFException, ServiceInterruption {
        // Start at root
        RootValue rv = new RootValue(llc, pathString);

        // Get the object id of the category the path describes
        int catObjectID = getCategoryId(rv);
        if (catObjectID == -1)
            return null;

        String[] rval = getCategoryAttributes(catObjectID);
        if (rval == null)
            return new String[0];
        return rval;
    }

    // Protected methods and classes

    /** Create the login URI.  This must be a relative URI.
    */
    protected String createLivelinkLoginURI() throws ManifoldCFException {
        StringBuilder llURI = new StringBuilder();

        llURI.append(ingestCgiPath);
        llURI.append("?func=ll.login&CurrentClientTime=D%2F2005%2F3%2F9%3A13%3A16%3A30&NextURL=");
        llURI.append(org.apache.manifoldcf.core.util.URLEncoder.encode(ingestCgiPath));
        llURI.append("%3FRedirect%3D1&Username=");
        llURI.append(org.apache.manifoldcf.core.util.URLEncoder.encode(llServer.getLLUser()));
        llURI.append("&Password=");
        llURI.append(org.apache.manifoldcf.core.util.URLEncoder.encode(llServer.getLLPwd()));

        return llURI.toString();
    }

    /**
    * Connects to the specified Livelink document using HTTP protocol
    * @param documentIdentifier is the document identifier (as far as the crawler knows).
    * @param activities is the process activity structure, so we can ingest
    */
    protected void ingestFromLiveLink(LivelinkContext llc, String documentIdentifier, String version,
            String[] actualAcls, String[] denyAcls, String[] categoryPaths, IProcessActivity activities,
            MetadataDescription desc, SystemMetadataDescription sDesc)
            throws ManifoldCFException, ServiceInterruption {

        String contextMsg = "for '" + documentIdentifier + "'";

        // Fetch logging
        long startTime = System.currentTimeMillis();
        String resultCode = null;
        String resultDescription = null;
        Long readSize = null;
        int objID;
        int vol;

        int colonPos = documentIdentifier.indexOf(":", 1);

        if (colonPos == -1) {
            objID = new Integer(documentIdentifier.substring(1)).intValue();
            vol = LLENTWK_VOL;
        } else {
            objID = new Integer(documentIdentifier.substring(colonPos + 1)).intValue();
            vol = new Integer(documentIdentifier.substring(1, colonPos)).intValue();
        }

        // Try/finally for fetch logging
        try {
            String viewHttpAddress = convertToViewURI(documentIdentifier);
            if (viewHttpAddress == null) {
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: No view URI " + contextMsg + " - not ingesting");
                resultCode = "NOVIEWURI";
                resultDescription = "Document had no view URI";
                activities.noDocument(documentIdentifier, version);
                return;
            }

            // Check URL first
            if (!activities.checkURLIndexable(viewHttpAddress)) {
                // Document not ingestable due to URL
                resultCode = activities.EXCLUDED_URL;
                resultDescription = "URL (" + viewHttpAddress + ") was rejected by output connector";
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Excluding document " + documentIdentifier
                            + " because its URL (" + viewHttpAddress + ") was rejected by output connector");
                activities.noDocument(documentIdentifier, version);
                return;
            }

            // Add general metadata
            ObjectInformation objInfo = llc.getObjectInformation(vol, objID);
            VersionInformation versInfo = llc.getVersionInformation(vol, objID, 0);
            if (!objInfo.exists()) {
                resultCode = "OBJECTNOTFOUND";
                resultDescription = "Object was not found in Livelink";
                Logging.connectors.debug("Livelink: No object " + contextMsg + ": not ingesting");
                activities.noDocument(documentIdentifier, version);
                return;
            }
            if (!versInfo.exists()) {
                resultCode = "VERSIONNOTFOUND";
                resultDescription = "Version was not found in Livelink";
                Logging.connectors.debug("Livelink: No version data " + contextMsg + ": not ingesting");
                activities.noDocument(documentIdentifier, version);
                return;
            }

            String mimeType = versInfo.getMimeType();
            if (!activities.checkMimeTypeIndexable(mimeType)) {
                // Document not indexable because of its mime type
                resultCode = activities.EXCLUDED_MIMETYPE;
                resultDescription = "Mime type (" + mimeType + ") was rejected by output connector";
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Excluding document " + documentIdentifier
                            + " because its mime type (" + mimeType + ") was rejected by output connector");
                activities.noDocument(documentIdentifier, version);
                return;
            }

            Long dataSize = versInfo.getDataSize();
            if (dataSize == null) {
                // Document had no length
                resultCode = "DOCUMENTNOLENGTH";
                resultDescription = "Document had no length in Livelink";
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug(
                            "Livelink: Excluding document " + documentIdentifier + " because it had no length");
                activities.noDocument(documentIdentifier, version);
                return;
            }

            if (!activities.checkLengthIndexable(dataSize.longValue())) {
                // Document not indexable because of its length
                resultCode = activities.EXCLUDED_LENGTH;
                resultDescription = "Document length (" + dataSize + ") was rejected by output connector";
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Excluding document " + documentIdentifier
                            + " because its length (" + dataSize + ") was rejected by output connector");
                activities.noDocument(documentIdentifier, version);
                return;
            }

            Date modifyDate = versInfo.getModifyDate();
            if (!activities.checkDateIndexable(modifyDate)) {
                // Document not indexable because of its date
                resultCode = activities.EXCLUDED_DATE;
                resultDescription = "Document date (" + modifyDate + ") was rejected by output connector";
                if (Logging.connectors.isDebugEnabled())
                    Logging.connectors.debug("Livelink: Excluding document " + documentIdentifier
                            + " because its date (" + modifyDate + ") was rejected by output connector");
                activities.noDocument(documentIdentifier, version);
                return;
            }

            String fileName = versInfo.getFileName();
            Date creationDate = objInfo.getCreationDate();
            Integer parentID = objInfo.getParentId();

            RepositoryDocument rd = new RepositoryDocument();

            // Add general data we need for the output connector
            if (mimeType != null)
                rd.setMimeType(mimeType);
            if (fileName != null)
                rd.setFileName(fileName);
            if (creationDate != null)
                rd.setCreatedDate(creationDate);
            if (modifyDate != null)
                rd.setModifiedDate(modifyDate);

            rd.addField(GENERAL_NAME_FIELD, objInfo.getName());
            rd.addField(GENERAL_DESCRIPTION_FIELD, objInfo.getComments());
            if (creationDate != null)
                rd.addField(GENERAL_CREATIONDATE_FIELD, DateParser.formatISO8601Date(creationDate));
            if (modifyDate != null)
                rd.addField(GENERAL_MODIFYDATE_FIELD, DateParser.formatISO8601Date(modifyDate));
            if (parentID != null)
                rd.addField(GENERAL_PARENTID, parentID.toString());
            UserInformation owner = llc.getUserInformation(objInfo.getOwnerId().intValue());
            UserInformation creator = llc.getUserInformation(objInfo.getCreatorId().intValue());
            UserInformation modifier = llc.getUserInformation(versInfo.getOwnerId().intValue());
            if (owner != null)
                rd.addField(GENERAL_OWNER, owner.getName());
            if (creator != null)
                rd.addField(GENERAL_CREATOR, creator.getName());
            if (modifier != null)
                rd.addField(GENERAL_MODIFIER, modifier.getName());

            // Iterate over the metadata items.  These are organized by category
            // for speed of lookup.

            Iterator<MetadataItem> catIter = desc.getItems(categoryPaths);
            while (catIter.hasNext()) {
                MetadataItem item = catIter.next();
                MetadataPathItem pathItem = item.getPathItem();
                if (pathItem != null) {
                    int catID = pathItem.getCatID();
                    // grab the associated catversion
                    LLValue catVersion = getCatVersion(objID, catID);
                    if (catVersion != null) {
                        // Go through attributes now
                        Iterator<String> attrIter = item.getAttributeNames();
                        while (attrIter.hasNext()) {
                            String attrName = attrIter.next();
                            // Create a unique metadata name
                            String metadataName = pathItem.getCatName() + ":" + attrName;
                            // Fetch the metadata and stuff it into the RepositoryData structure
                            String[] metadataValue = getAttributeValue(catVersion, attrName);
                            if (metadataValue != null)
                                rd.addField(metadataName, metadataValue);
                            else
                                Logging.connectors.warn("Livelink: Metadata attribute '" + metadataName
                                        + "' does not seem to exist; please correct the job");
                        }
                    }

                }
            }

            if (actualAcls != null && denyAcls != null)
                rd.setSecurity(RepositoryDocument.SECURITY_TYPE_DOCUMENT, actualAcls, denyAcls);

            // Add the path metadata item into the mix, if enabled
            String pathAttributeName = sDesc.getPathAttributeName();
            if (pathAttributeName != null && pathAttributeName.length() > 0) {
                String pathString = sDesc.getPathAttributeValue(documentIdentifier);
                if (pathString != null) {
                    if (Logging.connectors.isDebugEnabled())
                        Logging.connectors.debug("Livelink: Path attribute name is '" + pathAttributeName + "'"
                                + contextMsg + ", value is '" + pathString + "'");
                    rd.addField(pathAttributeName, pathString);
                }
            }

            if (ingestProtocol != null) {
                // Use HTTP to fetch document!
                String ingestHttpAddress = convertToIngestURI(documentIdentifier);
                if (ingestHttpAddress == null) {
                    if (Logging.connectors.isDebugEnabled())
                        Logging.connectors.debug("Livelink: No fetch URI " + contextMsg + " - not ingesting");
                    resultCode = "NOURI";
                    resultDescription = "Document had no fetch URI";
                    activities.noDocument(documentIdentifier, version);
                    return;
                }

                // Set up connection
                HttpClient client = getInitializedClient(contextMsg);

                long currentTime;

                if (Logging.connectors.isInfoEnabled())
                    Logging.connectors.info("Livelink: " + ingestHttpAddress);

                HttpGet method = new HttpGet(getHost().toURI() + ingestHttpAddress);
                method.setHeader(new BasicHeader("Accept", "*/*"));

                boolean wasInterrupted = false;
                ExecuteMethodThread methodThread = new ExecuteMethodThread(client, method);
                methodThread.start();
                try {
                    int statusCode = methodThread.getResponseCode();
                    switch (statusCode) {
                    case 500:
                    case 502:
                        Logging.connectors.warn("Livelink: Service interruption during fetch " + contextMsg
                                + " with Livelink HTTP Server, retrying...");
                        resultCode = "FETCHFAILED";
                        resultDescription = "HTTP error code " + statusCode + " fetching document";
                        throw new ServiceInterruption("Service interruption during fetch",
                                new ManifoldCFException(Integer.toString(statusCode) + " error while fetching"),
                                System.currentTimeMillis() + 60000L, System.currentTimeMillis() + 600000L, -1,
                                true);

                    case HttpStatus.SC_UNAUTHORIZED:
                        Logging.connectors.warn("Livelink: Document fetch unauthorized for " + ingestHttpAddress
                                + " (" + contextMsg + ")");
                        // Since we logged in, we should fail here if the ingestion user doesn't have access to the
                        // the document, but if we do, don't fail hard.
                        resultCode = "UNAUTHORIZED";
                        resultDescription = "Document fetch was unauthorized by IIS";
                        activities.noDocument(documentIdentifier, version);
                        return;

                    case HttpStatus.SC_OK:
                        if (Logging.connectors.isDebugEnabled())
                            Logging.connectors
                                    .debug("Livelink: Created http document connection to Livelink " + contextMsg);
                        // A non-existent content length will cause a value of -1 to be returned.  This seems to indicate that the session login did not work right.
                        if (methodThread.getResponseContentLength() < 0) {
                            resultCode = "SESSIONLOGINFAILED";
                            resultDescription = "Response content length was -1, which usually means session login did not succeed";
                            activities.noDocument(documentIdentifier, version);
                            return;
                        }

                        try {
                            InputStream is = methodThread.getSafeInputStream();
                            try {
                                rd.setBinary(is, dataSize);

                                activities.ingestDocumentWithException(documentIdentifier, version, viewHttpAddress,
                                        rd);
                                resultCode = "OK";
                                readSize = dataSize;

                                if (Logging.connectors.isDebugEnabled())
                                    Logging.connectors.debug("Livelink: Ingesting done " + contextMsg);

                            } finally {
                                // Close stream via thread, since otherwise this can hang
                                is.close();
                            }
                        } catch (InterruptedException e) {
                            wasInterrupted = true;
                            throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                    ManifoldCFException.INTERRUPTED);
                        } catch (HttpException e) {
                            resultCode = e.getClass().getSimpleName().toUpperCase(Locale.ROOT);
                            resultDescription = e.getMessage();
                            handleHttpException(contextMsg, e);
                        } catch (IOException e) {
                            resultCode = e.getClass().getSimpleName().toUpperCase(Locale.ROOT);
                            resultDescription = e.getMessage();
                            handleIOException(contextMsg, e);
                        }
                        break;
                    case HttpStatus.SC_BAD_REQUEST:
                    case HttpStatus.SC_USE_PROXY:
                    case HttpStatus.SC_GONE:
                        resultCode = "HTTPERROR";
                        resultDescription = "Http request returned status " + Integer.toString(statusCode);
                        throw new ManifoldCFException(
                                "Unrecoverable request failure; error = " + Integer.toString(statusCode));
                    default:
                        resultCode = "UNKNOWNHTTPCODE";
                        resultDescription = "Http request returned status " + Integer.toString(statusCode);
                        Logging.connectors.warn("Livelink: Attempt to retrieve document from '" + ingestHttpAddress
                                + "' received a response of " + Integer.toString(statusCode)
                                + "; retrying in one minute");
                        currentTime = System.currentTimeMillis();
                        throw new ServiceInterruption("Fetch failed; retrying in 1 minute",
                                new ManifoldCFException(
                                        "Fetch failed with unknown code " + Integer.toString(statusCode)),
                                currentTime + 60000L, currentTime + 600000L, -1, true);
                    }
                } catch (InterruptedException e) {
                    // Drop the connection on the floor
                    methodThread.interrupt();
                    methodThread = null;
                    throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                            ManifoldCFException.INTERRUPTED);
                } catch (HttpException e) {
                    resultCode = e.getClass().getSimpleName().toUpperCase(Locale.ROOT);
                    resultDescription = e.getMessage();
                    handleHttpException(contextMsg, e);
                } catch (IOException e) {
                    resultCode = e.getClass().getSimpleName().toUpperCase(Locale.ROOT);
                    resultDescription = e.getMessage();
                    handleIOException(contextMsg, e);
                } finally {
                    if (methodThread != null) {
                        methodThread.abort();
                        try {
                            if (!wasInterrupted)
                                methodThread.finishUp();
                        } catch (InterruptedException e) {
                            throw new ManifoldCFException(e.getMessage(), e, ManifoldCFException.INTERRUPTED);
                        }
                    }
                }
            } else {
                // Use FetchVersion instead
                long currentTime;

                // Fire up the document reading thread
                DocumentReadingThread t = new DocumentReadingThread(vol, objID, 0);
                boolean wasInterrupted = false;
                t.start();
                try {
                    try {
                        InputStream is = t.getSafeInputStream();
                        try {
                            // Can only index while background thread is running!
                            rd.setBinary(is, dataSize);
                            activities.ingestDocumentWithException(documentIdentifier, version, viewHttpAddress,
                                    rd);
                            resultCode = "OK";
                            readSize = dataSize;
                        } finally {
                            is.close();
                        }
                    } catch (java.net.SocketTimeoutException e) {
                        throw e;
                    } catch (InterruptedIOException e) {
                        wasInterrupted = true;
                        throw e;
                    } finally {
                        if (!wasInterrupted)
                            t.finishUp();
                    }

                    // No errors.  Record the fact that we made it.
                } catch (InterruptedException e) {
                    t.interrupt();
                    throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                            ManifoldCFException.INTERRUPTED);
                } catch (IOException e) {
                    resultCode = e.getClass().getSimpleName().toUpperCase(Locale.ROOT);
                    resultDescription = e.getMessage();
                    handleIOException(contextMsg, e);
                } catch (RuntimeException e) {
                    resultCode = e.getClass().getSimpleName().toUpperCase(Locale.ROOT);
                    resultDescription = e.getMessage();
                    handleLivelinkRuntimeException(e, 0, true);
                }
            }
        } catch (ManifoldCFException e) {
            if (e.getErrorCode() == ManifoldCFException.INTERRUPTED)
                resultCode = null;
            throw e;
        } finally {
            if (resultCode != null)
                activities.recordActivity(new Long(startTime), ACTIVITY_FETCH, readSize, vol + ":" + objID,
                        resultCode, resultDescription, null);
        }
    }

    protected static void handleHttpException(String contextMsg, HttpException e)
            throws ManifoldCFException, ServiceInterruption {
        long currentTime = System.currentTimeMillis();
        // Treat unknown error ingesting data as a transient condition
        Logging.connectors.warn("Livelink: HTTP exception ingesting " + contextMsg + ": " + e.getMessage(), e);
        throw new ServiceInterruption("HTTP exception ingesting " + contextMsg + ": " + e.getMessage(), e,
                currentTime + 300000L, currentTime + 6 * 3600000L, -1, false);
    }

    protected static void handleIOException(String contextMsg, IOException e)
            throws ManifoldCFException, ServiceInterruption {
        long currentTime = System.currentTimeMillis();
        if (e instanceof java.net.SocketTimeoutException) {
            Logging.connectors.warn("Livelink: Livelink socket timed out ingesting from the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("Socket timed out: " + e.getMessage(), e, currentTime + 300000L,
                    currentTime + 6 * 3600000L, -1, false);
        }
        if (e instanceof java.net.SocketException) {
            Logging.connectors.warn("Livelink: Livelink socket error ingesting from the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("Socket error: " + e.getMessage(), e, currentTime + 300000L,
                    currentTime + 6 * 3600000L, -1, false);
        }
        if (e instanceof javax.net.ssl.SSLHandshakeException) {
            Logging.connectors
                    .warn("Livelink: SSL handshake failed authenticating " + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("SSL handshake error: " + e.getMessage(), e, currentTime + 60000L,
                    currentTime + 300000L, -1, true);
        }
        if (e instanceof ConnectTimeoutException) {
            Logging.connectors.warn("Livelink: Livelink socket timed out connecting to the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("Connect timed out: " + e.getMessage(), e, currentTime + 300000L,
                    currentTime + 6 * 3600000L, -1, false);
        }
        if (e instanceof InterruptedIOException)
            throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
        // Treat unknown error ingesting data as a transient condition
        Logging.connectors.warn("Livelink: IO exception ingesting " + contextMsg + ": " + e.getMessage(), e);
        throw new ServiceInterruption("IO exception ingesting " + contextMsg + ": " + e.getMessage(), e,
                currentTime + 300000L, currentTime + 6 * 3600000L, -1, false);
    }

    /** Initialize a livelink client connection */
    protected HttpClient getInitializedClient(String contextMsg) throws ServiceInterruption, ManifoldCFException {
        long currentTime;
        if (Logging.connectors.isDebugEnabled())
            Logging.connectors.debug("Livelink: Session authenticating via http " + contextMsg + "...");
        HttpGet authget = new HttpGet(getHost().toURI() + createLivelinkLoginURI());
        authget.setHeader(new BasicHeader("Accept", "*/*"));
        try {
            if (Logging.connectors.isDebugEnabled())
                Logging.connectors
                        .debug("Livelink: Created new HttpGet " + contextMsg + "; executing authentication method");
            int statusCode = executeMethodViaThread(httpClient, authget);

            if (statusCode == 502 || statusCode == 500) {
                Logging.connectors.warn("Livelink: Service interruption during authentication " + contextMsg
                        + " with Livelink HTTP Server, retrying...");
                currentTime = System.currentTimeMillis();
                throw new ServiceInterruption("502 error during authentication",
                        new ManifoldCFException("502 error while authenticating"), currentTime + 60000L,
                        currentTime + 600000L, -1, true);
            }
            if (statusCode != HttpStatus.SC_OK) {
                Logging.connectors.error("Livelink: Failed to authenticate " + contextMsg
                        + " against Livelink HTTP Server; Status code: " + statusCode);
                // Ok, so we didn't get in - simply do not ingest
                if (statusCode == HttpStatus.SC_UNAUTHORIZED)
                    throw new ManifoldCFException(
                            "Session authorization failed with a 401 code; are credentials correct?");
                else
                    throw new ManifoldCFException(
                            "Session authorization failed with code " + Integer.toString(statusCode));
            }
        } catch (InterruptedException e) {
            throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
        } catch (java.net.SocketTimeoutException e) {
            currentTime = System.currentTimeMillis();
            Logging.connectors.warn("Livelink: Socket timed out authenticating to the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("Socket timed out: " + e.getMessage(), e, currentTime + 300000L,
                    currentTime + 6 * 3600000L, -1, true);
        } catch (java.net.SocketException e) {
            currentTime = System.currentTimeMillis();
            Logging.connectors.warn("Livelink: Socket error authenticating to the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("Socket error: " + e.getMessage(), e, currentTime + 300000L,
                    currentTime + 6 * 3600000L, -1, true);
        } catch (javax.net.ssl.SSLHandshakeException e) {
            currentTime = System.currentTimeMillis();
            Logging.connectors
                    .warn("Livelink: SSL handshake failed authenticating " + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("SSL handshake error: " + e.getMessage(), e, currentTime + 60000L,
                    currentTime + 300000L, -1, true);
        } catch (ConnectTimeoutException e) {
            currentTime = System.currentTimeMillis();
            Logging.connectors.warn("Livelink: Connect timed out authenticating to the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ServiceInterruption("Connect timed out: " + e.getMessage(), e, currentTime + 300000L,
                    currentTime + 6 * 3600000L, -1, true);
        } catch (InterruptedIOException e) {
            throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
        } catch (HttpException e) {
            Logging.connectors.error("Livelink: HTTP exception when authenticating to the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ManifoldCFException("Unable to communicate with the Livelink HTTP Server: " + e.getMessage(),
                    e);
        } catch (IOException e) {
            Logging.connectors.error("Livelink: IO exception when authenticating to the Livelink HTTP Server "
                    + contextMsg + ": " + e.getMessage(), e);
            throw new ManifoldCFException("Unable to communicate with the Livelink HTTP Server: " + e.getMessage(),
                    e);
        }

        return httpClient;
    }

    /** Pack category and attribute */
    protected static String packCategoryAttribute(String category, String attribute) {
        StringBuilder sb = new StringBuilder();
        pack(sb, category, ':');
        pack(sb, attribute, ':');
        return sb.toString();
    }

    /** Unpack category and attribute */
    protected static void unpackCategoryAttribute(StringBuilder category, StringBuilder attribute, String value) {
        int startPos = 0;
        startPos = unpack(category, value, startPos, ':');
        startPos = unpack(attribute, value, startPos, ':');
    }

    /** Given a path string, get a list of folders and projects under that node.
    *@param pathString is the current path (folder names and project names, separated by dots (.)).
    *@return a list of folder and project names, in sorted order, or null if the path was invalid.
    */
    protected String[] getChildFolders(LivelinkContext llc, String pathString)
            throws ManifoldCFException, ServiceInterruption {
        RootValue rv = new RootValue(llc, pathString);

        // Get the volume, object id of the folder/project the path describes
        VolumeAndId vid = getPathId(rv);
        if (vid == null)
            return null;

        String filterString = "(SubType=" + LAPI_DOCUMENTS.FOLDERSUBTYPE + " or SubType="
                + LAPI_DOCUMENTS.PROJECTSUBTYPE + " or SubType=" + LAPI_DOCUMENTS.COMPOUNDDOCUMENTSUBTYPE + ")";

        int sanityRetryCount = FAILURE_RETRY_COUNT;
        while (true) {
            ListObjectsThread t = new ListObjectsThread(vid.getVolumeID(), vid.getPathId(), filterString);
            try {
                t.start();
                LLValue children;
                try {
                    children = t.finishUp();
                } catch (ManifoldCFException e) {
                    sanityRetryCount = assessRetry(sanityRetryCount, e);
                    continue;
                }

                String[] rval = new String[children.size()];
                int j = 0;
                while (j < children.size()) {
                    rval[j] = children.toString(j, "Name");
                    j++;
                }
                return rval;
            } catch (InterruptedException e) {
                t.interrupt();
                throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
            } catch (RuntimeException e) {
                sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                continue;
            }
        }
    }

    /** Given a path string, get a list of categories under that node.
    *@param pathString is the current path (folder names and project names, separated by dots (.)).
    *@return a list of category names, in sorted order, or null if the path was invalid.
    */
    protected String[] getChildCategories(LivelinkContext llc, String pathString)
            throws ManifoldCFException, ServiceInterruption {
        // Start at root
        RootValue rv = new RootValue(llc, pathString);

        // Get the volume, object id of the folder/project the path describes
        VolumeAndId vid = getPathId(rv);
        if (vid == null)
            return null;

        // We want only folders that are children of the current object and which match the specified subfolder
        String filterString = "SubType=" + LAPI_DOCUMENTS.CATEGORYSUBTYPE;

        int sanityRetryCount = FAILURE_RETRY_COUNT;
        while (true) {
            ListObjectsThread t = new ListObjectsThread(vid.getVolumeID(), vid.getPathId(), filterString);
            try {
                t.start();
                LLValue children;
                try {
                    children = t.finishUp();
                } catch (ManifoldCFException e) {
                    sanityRetryCount = assessRetry(sanityRetryCount, e);
                    continue;
                }

                String[] rval = new String[children.size()];
                int j = 0;
                while (j < children.size()) {
                    rval[j] = children.toString(j, "Name");
                    j++;
                }
                return rval;
            } catch (InterruptedException e) {
                t.interrupt();
                throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
            } catch (RuntimeException e) {
                sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                continue;
            }
        }
    }

    protected class GetCategoryAttributesThread extends Thread {
        protected final int catObjectID;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetCategoryAttributesThread(int catObjectID) {
            super();
            setDaemon(true);
            this.catObjectID = catObjectID;
        }

        public void run() {
            try {
                LLValue catID = new LLValue();
                catID.setAssoc();
                catID.add("ID", catObjectID);
                catID.add("Type", LAPI_ATTRIBUTES.CATEGORY_TYPE_LIBRARY);

                LLValue catVersion = new LLValue();
                int status = LLDocs.FetchCategoryVersion(catID, catVersion);
                if (status == 107105 || status == 107106)
                    return;
                if (status != 0) {
                    throw new ManifoldCFException("Error getting category version: " + Integer.toString(status));
                }

                LLValue children = new LLValue();
                status = LLAttributes.AttrListNames(catVersion, null, children);
                if (status != 0) {
                    throw new ManifoldCFException("Error getting attribute names: " + Integer.toString(status));
                }
                rval = children;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }
    }

    /** Given a category path, get a list of legal attribute names.
    *@param catObjectID is the object id of the category.
    *@return a list of attribute names, in sorted order, or null of the path was invalid.
    */
    protected String[] getCategoryAttributes(int catObjectID) throws ManifoldCFException, ServiceInterruption {
        int sanityRetryCount = FAILURE_RETRY_COUNT;
        while (true) {
            GetCategoryAttributesThread t = new GetCategoryAttributesThread(catObjectID);
            try {
                t.start();
                LLValue children;
                try {
                    children = t.finishUp();
                } catch (ManifoldCFException e) {
                    sanityRetryCount = assessRetry(sanityRetryCount, e);
                    continue;
                }

                if (children == null)
                    return null;

                String[] rval = new String[children.size()];
                LLValueEnumeration en = children.enumerateValues();

                int j = 0;
                while (en.hasMoreElements()) {
                    LLValue v = (LLValue) en.nextElement();
                    rval[j] = v.toString();
                    j++;
                }
                return rval;
            } catch (InterruptedException e) {
                t.interrupt();
                throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
            } catch (RuntimeException e) {
                sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                continue;
            }
        }
    }

    protected class GetCategoryVersionThread extends Thread {
        protected final int objID;
        protected final int catID;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetCategoryVersionThread(int objID, int catID) {
            super();
            setDaemon(true);
            this.objID = objID;
            this.catID = catID;
        }

        public void run() {
            try {
                // Set up the right llvalues

                // Object ID
                LLValue objIDValue = new LLValue().setAssoc();
                objIDValue.add("ID", objID);
                // Current version, so don't set the "Version" field

                // CatID
                LLValue catIDValue = new LLValue().setAssoc();
                catIDValue.add("ID", catID);
                catIDValue.add("Type", LAPI_ATTRIBUTES.CATEGORY_TYPE_LIBRARY);

                LLValue rvalue = new LLValue();

                int status = LLDocs.GetObjectAttributesEx(objIDValue, catIDValue, rvalue);
                // If either the object is wrong, or the object does not have the specified category, return null.
                if (status == 103101 || status == 107205)
                    return;

                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving category version: " + Integer.toString(status)
                            + ": " + llServer.getErrors());
                }

                rval = rvalue;

            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }

    }

    /** Get a category version for document.
    */
    protected LLValue getCatVersion(int objID, int catID) throws ManifoldCFException, ServiceInterruption {
        int sanityRetryCount = FAILURE_RETRY_COUNT;
        while (true) {
            GetCategoryVersionThread t = new GetCategoryVersionThread(objID, catID);
            try {
                t.start();
                try {
                    return t.finishUp();
                } catch (ManifoldCFException e) {
                    sanityRetryCount = assessRetry(sanityRetryCount, e);
                    continue;
                }
            } catch (InterruptedException e) {
                t.interrupt();
                throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
            } catch (NullPointerException npe) {
                // LAPI throws a null pointer exception under very rare conditions when the GetObjectAttributesEx is
                // called.  The conditions are not clear at this time - it could even be due to Livelink corruption.
                // However, I'm going to have to treat this as
                // indicating that this category version does not exist for this document.
                Logging.connectors
                        .warn("Livelink: Null pointer exception thrown trying to get cat version for category "
                                + Integer.toString(catID) + " for object " + Integer.toString(objID));
                return null;
            } catch (RuntimeException e) {
                sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                continue;
            }
        }
    }

    protected class GetAttributeValueThread extends Thread {
        protected final LLValue categoryVersion;
        protected final String attributeName;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetAttributeValueThread(LLValue categoryVersion, String attributeName) {
            super();
            setDaemon(true);
            this.categoryVersion = categoryVersion;
            this.attributeName = attributeName;
        }

        public void run() {
            try {
                // Set up the right llvalues
                LLValue children = new LLValue();
                int status = LLAttributes.AttrGetValues(categoryVersion, attributeName, 0, null, children);
                // "Not found" status - I don't know if it possible to get this here, but if so, behave civilly
                if (status == 103101)
                    return;
                // This seems to be the real error LAPI returns if you don't have an attribute of this name
                if (status == 8000604)
                    return;

                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving attribute value: " + Integer.toString(status)
                            + ": " + llServer.getErrors());
                }
                rval = children;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }

    }

    /** Get an attribute value from a category version.
    */
    protected String[] getAttributeValue(LLValue categoryVersion, String attributeName)
            throws ManifoldCFException, ServiceInterruption {
        int sanityRetryCount = FAILURE_RETRY_COUNT;
        while (true) {
            GetAttributeValueThread t = new GetAttributeValueThread(categoryVersion, attributeName);
            try {
                t.start();
                LLValue children;
                try {
                    children = t.finishUp();
                } catch (ManifoldCFException e) {
                    sanityRetryCount = assessRetry(sanityRetryCount, e);
                    continue;
                }

                if (children == null)
                    return null;
                String[] rval = new String[children.size()];
                LLValueEnumeration en = children.enumerateValues();

                int j = 0;
                while (en.hasMoreElements()) {
                    LLValue v = (LLValue) en.nextElement();
                    rval[j] = v.toString();
                    j++;
                }
                return rval;
            } catch (InterruptedException e) {
                t.interrupt();
                throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
            } catch (RuntimeException e) {
                sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                continue;
            }
        }
    }

    protected class GetObjectRightsThread extends Thread {
        protected final int vol;
        protected final int objID;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetObjectRightsThread(int vol, int objID) {
            super();
            setDaemon(true);
            this.vol = vol;
            this.objID = objID;
        }

        public void run() {
            try {
                LLValue childrenObjects = new LLValue();
                int status = LLDocs.GetObjectRights(vol, objID, childrenObjects);
                // If the rights object doesn't exist, behave civilly
                if (status == 103101)
                    return;

                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving document rights: " + Integer.toString(status)
                            + ": " + llServer.getErrors());
                }

                rval = childrenObjects;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }

    }

    /** Get an object's rights.  This will be an array of right id's, including the special
    * ones defined by Livelink, or null will be returned (if the object is not found).
    *@param vol is the volume id
    *@param objID is the object id
    *@return the array.
    */
    protected int[] getObjectRights(int vol, int objID) throws ManifoldCFException, ServiceInterruption {
        int sanityRetryCount = FAILURE_RETRY_COUNT;
        while (true) {
            GetObjectRightsThread t = new GetObjectRightsThread(vol, objID);
            try {
                t.start();
                LLValue childrenObjects;
                try {
                    childrenObjects = t.finishUp();
                } catch (ManifoldCFException e) {
                    sanityRetryCount = assessRetry(sanityRetryCount, e);
                    continue;
                }

                if (childrenObjects == null)
                    return null;

                int size;
                if (childrenObjects.isRecord())
                    size = 1;
                else if (childrenObjects.isTable())
                    size = childrenObjects.size();
                else
                    size = 0;

                int minPermission = LAPI_DOCUMENTS.PERM_SEE + LAPI_DOCUMENTS.PERM_SEECONTENTS;

                int j = 0;
                int count = 0;
                while (j < size) {
                    int permission = childrenObjects.toInteger(j, "Permissions");
                    // Only if the permission is "see contents" can we consider this
                    // access token!
                    if ((permission & minPermission) == minPermission)
                        count++;
                    j++;
                }

                int[] rval = new int[count];
                j = 0;
                count = 0;
                while (j < size) {
                    int token = childrenObjects.toInteger(j, "RightID");
                    int permission = childrenObjects.toInteger(j, "Permissions");
                    // Only if the permission is "see contents" can we consider this
                    // access token!
                    if ((permission & minPermission) == minPermission)
                        rval[count++] = token;
                    j++;
                }
                return rval;
            } catch (InterruptedException e) {
                t.interrupt();
                throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
            } catch (RuntimeException e) {
                sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                continue;
            }
        }
    }

    /** Local cache for various kinds of objects that may be useful more than once.
    */
    protected class LivelinkContext {
        /** Cache of ObjectInformation objects. */
        protected final Map<ObjectInformation, ObjectInformation> objectInfoMap = new HashMap<ObjectInformation, ObjectInformation>();
        /** Cache of VersionInformation objects. */
        protected final Map<VersionInformation, VersionInformation> versionInfoMap = new HashMap<VersionInformation, VersionInformation>();
        /** Cache of UserInformation objects */
        protected final Map<UserInformation, UserInformation> userInfoMap = new HashMap<UserInformation, UserInformation>();

        public LivelinkContext() {
        }

        public ObjectInformation getObjectInformation(int volumeID, int objectID) {
            ObjectInformation oi = new ObjectInformation(volumeID, objectID);
            ObjectInformation lookupValue = objectInfoMap.get(oi);
            if (lookupValue == null) {
                objectInfoMap.put(oi, oi);
                return oi;
            }
            return lookupValue;
        }

        public VersionInformation getVersionInformation(int volumeID, int objectID, int revisionNumber) {
            VersionInformation vi = new VersionInformation(volumeID, objectID, revisionNumber);
            VersionInformation lookupValue = versionInfoMap.get(vi);
            if (lookupValue == null) {
                versionInfoMap.put(vi, vi);
                return vi;
            }
            return lookupValue;
        }

        public UserInformation getUserInformation(int userID) {
            UserInformation ui = new UserInformation(userID);
            UserInformation lookupValue = userInfoMap.get(ui);
            if (lookupValue == null) {
                userInfoMap.put(ui, ui);
                return ui;
            }
            return lookupValue;
        }
    }

    /** This object represents a cache of user information.
    * Initialize it with the user ID.  Then, request desired fields from it.
    */
    protected class UserInformation {
        protected final int userID;

        protected LLValue userValue = null;

        public UserInformation(int userID) {
            this.userID = userID;
        }

        public boolean exists() throws ServiceInterruption, ManifoldCFException {
            return getUserValue() != null;
        }

        public String getName() throws ServiceInterruption, ManifoldCFException {
            LLValue userValue = getUserValue();
            if (userValue == null)
                return null;
            return userValue.toString("NAME");
        }

        protected LLValue getUserValue() throws ServiceInterruption, ManifoldCFException {
            if (userValue == null) {
                int sanityRetryCount = FAILURE_RETRY_COUNT;
                while (true) {
                    GetUserInfoThread t = new GetUserInfoThread(userID);
                    try {
                        t.start();
                        try {
                            userValue = t.finishUp();
                        } catch (ManifoldCFException e) {
                            sanityRetryCount = assessRetry(sanityRetryCount, e);
                            continue;
                        }
                        break;
                    } catch (InterruptedException e) {
                        t.interrupt();
                        throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                ManifoldCFException.INTERRUPTED);
                    } catch (RuntimeException e) {
                        sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                        continue;
                    }
                }
            }
            return userValue;
        }

        @Override
        public String toString() {
            return "(" + userID + ")";
        }

        @Override
        public int hashCode() {
            return (userID << 5) ^ (userID >> 3);
        }

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof UserInformation))
                return false;
            UserInformation other = (UserInformation) o;
            return userID == other.userID;
        }

    }

    /** This object represents a cache of version information.
    * Initialize it with the volume ID and object ID and revision number (usually zero).
    * Then, request the desired fields from it.
    */
    protected class VersionInformation {
        protected final int volumeID;
        protected final int objectID;
        protected final int revisionNumber;

        protected LLValue versionValue = null;

        public VersionInformation(int volumeID, int objectID, int revisionNumber) {
            this.volumeID = volumeID;
            this.objectID = objectID;
            this.revisionNumber = revisionNumber;
        }

        public boolean exists() throws ServiceInterruption, ManifoldCFException {
            return getVersionValue() != null;
        }

        /** Get data size.
        */
        public Long getDataSize() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getVersionValue();
            if (elem == null)
                return null;
            return new Long(elem.toLong("FILEDATASIZE"));
        }

        /** Get file name.
        */
        public String getFileName() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getVersionValue();
            if (elem == null)
                return null;
            return elem.toString("FILENAME");
        }

        /** Get mime type.
        */
        public String getMimeType() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getVersionValue();
            if (elem == null)
                return null;
            return elem.toString("MIMETYPE");
        }

        /** Get modify date.
        */
        public Date getModifyDate() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getVersionValue();
            if (elem == null)
                return null;
            return elem.toDate("MODIFYDATE");
        }

        /** Get modifier.
        */
        public Integer getOwnerId() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getVersionValue();
            if (elem == null)
                return null;
            return new Integer(elem.toInteger("OWNER"));
        }

        /** Get version LLValue */
        protected LLValue getVersionValue() throws ServiceInterruption, ManifoldCFException {
            if (versionValue == null) {
                int sanityRetryCount = FAILURE_RETRY_COUNT;
                while (true) {
                    GetVersionInfoThread t = new GetVersionInfoThread(volumeID, objectID, revisionNumber);
                    try {
                        t.start();
                        try {
                            versionValue = t.finishUp();
                        } catch (ManifoldCFException e) {
                            sanityRetryCount = assessRetry(sanityRetryCount, e);
                            continue;
                        }
                        break;
                    } catch (InterruptedException e) {
                        t.interrupt();
                        throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                ManifoldCFException.INTERRUPTED);
                    } catch (RuntimeException e) {
                        sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                        continue;
                    }
                }
            }
            return versionValue;
        }

        @Override
        public int hashCode() {
            return (volumeID << 5) ^ (volumeID >> 3) ^ (objectID << 5) ^ (objectID >> 3) ^ (revisionNumber << 5)
                    ^ (revisionNumber >> 3);
        }

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof VersionInformation))
                return false;
            VersionInformation other = (VersionInformation) o;
            return volumeID == other.volumeID && objectID == other.objectID
                    && revisionNumber == other.revisionNumber;
        }

    }

    /** This object represents an object information cache.
    * Initialize it with the volume ID and object ID, and then request
    * the appropriate fields from it.  Keep it around as long as needed; it functions as a cache
    * of sorts...
    */
    protected class ObjectInformation {
        protected final int volumeID;
        protected final int objectID;

        protected LLValue objectValue = null;

        public ObjectInformation(int volumeID, int objectID) {
            this.volumeID = volumeID;
            this.objectID = objectID;
        }

        /**
        * Check whether object seems to exist or not.
        */
        public boolean exists() throws ServiceInterruption, ManifoldCFException {
            return getObjectValue() != null;
        }

        /** Check if this object is the category workspace.
        */
        public boolean isCategoryWorkspace() {
            return objectID == LLCATWK_ID;
        }

        /** Check if this object is the entity workspace.
        */
        public boolean isEntityWorkspace() {
            return objectID == LLENTWK_ID;
        }

        /** toString override */
        @Override
        public String toString() {
            return "(Volume: " + volumeID + ", Object: " + objectID + ")";
        }

        /**
        * Returns the object ID specified by the path name.
        * @param startPath is the folder name (a string with dots as separators)
        */
        public VolumeAndId getPathId(String startPath) throws ServiceInterruption, ManifoldCFException {
            LLValue objInfo = getObjectValue();
            if (objInfo == null)
                return null;

            // Grab the volume ID and starting object
            int obj = objInfo.toInteger("ID");
            int vol = objInfo.toInteger("VolumeID");

            // Pick apart the start path.  This is a string separated by slashes.
            int charindex = 0;
            while (charindex < startPath.length()) {
                StringBuilder currentTokenBuffer = new StringBuilder();
                // Find the current token
                while (charindex < startPath.length()) {
                    char x = startPath.charAt(charindex++);
                    if (x == '/')
                        break;
                    if (x == '\\') {
                        // Attempt to escape what follows
                        x = startPath.charAt(charindex);
                        charindex++;
                    }
                    currentTokenBuffer.append(x);
                }

                String subFolder = currentTokenBuffer.toString();
                // We want only folders that are children of the current object and which match the specified subfolder
                String filterString = "(SubType=" + LAPI_DOCUMENTS.FOLDERSUBTYPE + " or SubType="
                        + LAPI_DOCUMENTS.PROJECTSUBTYPE + " or SubType=" + LAPI_DOCUMENTS.COMPOUNDDOCUMENTSUBTYPE
                        + ") and Name='" + subFolder + "'";

                int sanityRetryCount = FAILURE_RETRY_COUNT;
                while (true) {
                    ListObjectsThread t = new ListObjectsThread(vol, obj, filterString);
                    try {
                        t.start();
                        LLValue children;
                        try {
                            children = t.finishUp();
                        } catch (ManifoldCFException e) {
                            sanityRetryCount = assessRetry(sanityRetryCount, e);
                            continue;
                        }

                        if (children == null)
                            return null;

                        // If there is one child, then we are okay.
                        if (children.size() == 1) {
                            // New starting point is the one we found.
                            obj = children.toInteger(0, "ID");
                            int subtype = children.toInteger(0, "SubType");
                            if (subtype == LAPI_DOCUMENTS.PROJECTSUBTYPE) {
                                vol = obj;
                                obj = -obj;
                            }
                        } else {
                            // Couldn't find the path.  Instead of throwing up, return null to indicate
                            // illegal node.
                            return null;
                        }
                        break;
                    } catch (InterruptedException e) {
                        t.interrupt();
                        throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                ManifoldCFException.INTERRUPTED);
                    } catch (RuntimeException e) {
                        sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                        continue;

                    }
                }

            }
            return new VolumeAndId(vol, obj);
        }

        /**
        * Returns the category ID specified by the path name.
        * @param startPath is the folder name, ending in a category name (a string with slashes as separators)
        */
        public int getCategoryId(String startPath) throws ManifoldCFException, ServiceInterruption {
            LLValue objInfo = getObjectValue();
            if (objInfo == null)
                return -1;

            // Grab the volume ID and starting object
            int obj = objInfo.toInteger("ID");
            int vol = objInfo.toInteger("VolumeID");

            // Pick apart the start path.  This is a string separated by slashes.
            if (startPath.length() == 0)
                return -1;

            int charindex = 0;
            while (charindex < startPath.length()) {
                StringBuilder currentTokenBuffer = new StringBuilder();
                // Find the current token
                while (charindex < startPath.length()) {
                    char x = startPath.charAt(charindex++);
                    if (x == '/')
                        break;
                    if (x == '\\') {
                        // Attempt to escape what follows
                        x = startPath.charAt(charindex);
                        charindex++;
                    }
                    currentTokenBuffer.append(x);
                }
                String subFolder = currentTokenBuffer.toString();
                String filterString;

                // We want only folders that are children of the current object and which match the specified subfolder
                if (charindex < startPath.length())
                    filterString = "(SubType=" + LAPI_DOCUMENTS.FOLDERSUBTYPE + " or SubType="
                            + LAPI_DOCUMENTS.PROJECTSUBTYPE + " or SubType="
                            + LAPI_DOCUMENTS.COMPOUNDDOCUMENTSUBTYPE + ")";
                else
                    filterString = "SubType=" + LAPI_DOCUMENTS.CATEGORYSUBTYPE;

                filterString += " and Name='" + subFolder + "'";

                int sanityRetryCount = FAILURE_RETRY_COUNT;
                while (true) {
                    ListObjectsThread t = new ListObjectsThread(vol, obj, filterString);
                    try {
                        t.start();
                        LLValue children;
                        try {
                            children = t.finishUp();
                        } catch (ManifoldCFException e) {
                            sanityRetryCount = assessRetry(sanityRetryCount, e);
                            continue;
                        }

                        if (children == null)
                            return -1;

                        // If there is one child, then we are okay.
                        if (children.size() == 1) {
                            // New starting point is the one we found.
                            obj = children.toInteger(0, "ID");
                            int subtype = children.toInteger(0, "SubType");
                            if (subtype == LAPI_DOCUMENTS.PROJECTSUBTYPE) {
                                vol = obj;
                                obj = -obj;
                            }
                        } else {
                            // Couldn't find the path.  Instead of throwing up, return null to indicate
                            // illegal node.
                            return -1;
                        }
                        break;
                    } catch (InterruptedException e) {
                        t.interrupt();
                        throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                ManifoldCFException.INTERRUPTED);
                    } catch (RuntimeException e) {
                        sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                        continue;
                    }
                }
            }
            return obj;
        }

        /** Get permissions.
        */
        public Integer getPermissions() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return new Integer(objectValue.toInteger("Permissions"));
        }

        /** Get OpenText document name.
        */
        public String getName() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return elem.toString("NAME");
        }

        /** Get OpenText comments/description.
        */
        public String getComments() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return elem.toString("COMMENT");
        }

        /** Get parent ID.
        */
        public Integer getParentId() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return new Integer(elem.toInteger("ParentId"));
        }

        /** Get owner ID.
        */
        public Integer getOwnerId() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return new Integer(elem.toInteger("UserId"));
        }

        /** Get group ID.
        */
        public Integer getGroupId() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return new Integer(elem.toInteger("GroupId"));
        }

        /** Get creation date.
        */
        public Date getCreationDate() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return elem.toDate("CREATEDATE");
        }

        /** Get creator ID.
        */
        public Integer getCreatorId() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return new Integer(elem.toInteger("CREATEDBY"));
        }

        /* Get modify date.
        */
        public Date getModifyDate() throws ServiceInterruption, ManifoldCFException {
            LLValue elem = getObjectValue();
            if (elem == null)
                return null;
            return elem.toDate("ModifyDate");
        }

        /** Get the objInfo object.
        */
        protected LLValue getObjectValue() throws ServiceInterruption, ManifoldCFException {
            if (objectValue == null) {
                int sanityRetryCount = FAILURE_RETRY_COUNT;
                while (true) {
                    GetObjectInfoThread t = new GetObjectInfoThread(volumeID, objectID);
                    try {
                        t.start();
                        try {
                            objectValue = t.finishUp();
                        } catch (ManifoldCFException e) {
                            sanityRetryCount = assessRetry(sanityRetryCount, e);
                            continue;
                        }
                        break;
                    } catch (InterruptedException e) {
                        t.interrupt();
                        throw new ManifoldCFException("Interrupted: " + e.getMessage(), e,
                                ManifoldCFException.INTERRUPTED);
                    } catch (RuntimeException e) {
                        sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                        continue;
                    }
                }
            }
            return objectValue;
        }

        @Override
        public int hashCode() {
            return (volumeID << 5) ^ (volumeID >> 3) ^ (objectID << 5) ^ (objectID >> 3);
        }

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof ObjectInformation))
                return false;
            ObjectInformation other = (ObjectInformation) o;
            return volumeID == other.volumeID && objectID == other.objectID;
        }
    }

    /** Thread we can abandon that lists all users (except admin).
    */
    protected class ListUsersThread extends Thread {
        protected LLValue rval = null;
        protected Throwable exception = null;

        public ListUsersThread() {
            super();
            setDaemon(true);
        }

        public void run() {
            try {
                LLValue userList = new LLValue();
                int status = LLUsers.ListUsers(userList);

                if (Logging.connectors.isDebugEnabled()) {
                    Logging.connectors.debug("Livelink: User list retrieved: status=" + Integer.toString(status));
                }

                if (status < 0) {
                    Logging.connectors.debug("Livelink: User list inaccessable (" + llServer.getErrors() + ")");
                    return;
                }

                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving user list: status=" + Integer.toString(status)
                            + " (" + llServer.getErrors() + ")");
                }

                rval = userList;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }

    }

    /** Thread we can abandon that gets user information for a userID.
    */
    protected class GetUserInfoThread extends Thread {
        protected final int user;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetUserInfoThread(int user) {
            super();
            setDaemon(true);
            this.user = user;
        }

        public void run() {
            try {
                LLValue userinfo = new LLValue().setAssoc();
                int status = LLUsers.GetUserByID(user, userinfo);

                // Need to detect if object was deleted, and return null in this case!!!
                if (Logging.connectors.isDebugEnabled()) {
                    Logging.connectors.debug("Livelink: User status retrieved for " + Integer.toString(user)
                            + ": status=" + Integer.toString(status));
                }

                // Treat both 103101 and 103102 as 'object not found'. 401101 is 'user not found'.
                if (status == 103101 || status == 103102 || status == 401101)
                    return;

                // This error means we don't have permission to get the object's status, apparently
                if (status < 0) {
                    Logging.connectors.debug("Livelink: User info inaccessable for user " + Integer.toString(user)
                            + " (" + llServer.getErrors() + ")");
                    return;
                }

                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving user " + Integer.toString(user) + ": status="
                            + Integer.toString(status) + " (" + llServer.getErrors() + ")");
                }
                rval = userinfo;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }
    }

    /** Thread we can abandon that gets version information for a volume and an id and a revision.
    */
    protected class GetVersionInfoThread extends Thread {
        protected final int vol;
        protected final int id;
        protected final int revNumber;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetVersionInfoThread(int vol, int id, int revNumber) {
            super();
            setDaemon(true);
            this.vol = vol;
            this.id = id;
            this.revNumber = revNumber;
        }

        public void run() {
            try {
                LLValue versioninfo = new LLValue().setAssocNotSet();
                int status = LLDocs.GetVersionInfo(vol, id, revNumber, versioninfo);

                // Need to detect if object was deleted, and return null in this case!!!
                if (Logging.connectors.isDebugEnabled()) {
                    Logging.connectors.debug("Livelink: Version status retrieved for " + Integer.toString(vol) + ":"
                            + Integer.toString(id) + ", rev " + revNumber + ": status=" + Integer.toString(status));
                }

                // Treat both 103101 and 103102 as 'object not found'.
                if (status == 103101 || status == 103102)
                    return;

                // This error means we don't have permission to get the object's status, apparently
                if (status < 0) {
                    Logging.connectors.debug("Livelink: Version info inaccessable for object "
                            + Integer.toString(vol) + ":" + Integer.toString(id) + ", rev " + revNumber + " ("
                            + llServer.getErrors() + ")");
                    return;
                }

                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving document version " + Integer.toString(vol) + ":"
                            + Integer.toString(id) + ", rev " + revNumber + ": status=" + Integer.toString(status)
                            + " (" + llServer.getErrors() + ")");
                }
                rval = versioninfo;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }
    }

    /** Thread we can abandon that gets object information for a volume and an id.
    */
    protected class GetObjectInfoThread extends Thread {
        protected int vol;
        protected int id;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetObjectInfoThread(int vol, int id) {
            super();
            setDaemon(true);
            this.vol = vol;
            this.id = id;
        }

        public void run() {
            try {
                LLValue objinfo = new LLValue().setAssocNotSet();
                int status = LLDocs.GetObjectInfo(vol, id, objinfo);

                // Need to detect if object was deleted, and return null in this case!!!
                if (Logging.connectors.isDebugEnabled()) {
                    Logging.connectors.debug("Livelink: Status retrieved for " + Integer.toString(vol) + ":"
                            + Integer.toString(id) + ": status=" + Integer.toString(status));
                }

                // Treat both 103101 and 103102 as 'object not found'.
                if (status == 103101 || status == 103102)
                    return;

                // This error means we don't have permission to get the object's status, apparently
                if (status < 0) {
                    Logging.connectors
                            .debug("Livelink: Object info inaccessable for object " + Integer.toString(vol) + ":"
                                    + Integer.toString(id) + " (" + llServer.getErrors() + ")");
                    return;
                }

                if (status != 0) {
                    throw new ManifoldCFException(
                            "Error retrieving document object " + Integer.toString(vol) + ":" + Integer.toString(id)
                                    + ": status=" + Integer.toString(status) + " (" + llServer.getErrors() + ")");
                }
                rval = objinfo;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }
    }

    /** Build a set of actual acls given a set of rights */
    protected String[] lookupTokens(int[] rights, ObjectInformation objInfo)
            throws ManifoldCFException, ServiceInterruption {
        if (!objInfo.exists())
            return null;

        String[] convertedAcls = new String[rights.length];

        LLValue infoObject = null;
        int j = 0;
        int k = 0;
        while (j < rights.length) {
            int token = rights[j++];
            String tokenValue;
            // Consider this token
            switch (token) {
            case LAPI_DOCUMENTS.RIGHT_OWNER:
                // Look up user for current document (UserID attribute)
                tokenValue = objInfo.getOwnerId().toString();
                break;
            case LAPI_DOCUMENTS.RIGHT_GROUP:
                tokenValue = objInfo.getGroupId().toString();
                break;
            case LAPI_DOCUMENTS.RIGHT_WORLD:
                // Add "Guest" token
                tokenValue = "GUEST";
                break;
            case LAPI_DOCUMENTS.RIGHT_SYSTEM:
                // Add "System" token
                tokenValue = "SYSTEM";
                break;
            default:
                tokenValue = Integer.toString(token);
                break;
            }

            // This might return a null if we could not look up the object corresponding to the right.  If so, it is safe to skip it because
            // that always RESTRICTS view of the object (maybe erroneously), but does not expand visibility.
            if (tokenValue != null)
                convertedAcls[k++] = tokenValue;
        }
        String[] actualAcls = new String[k];
        j = 0;
        while (j < k) {
            actualAcls[j] = convertedAcls[j];
            j++;
        }
        return actualAcls;
    }

    protected class GetObjectCategoryIDsThread extends Thread {
        protected final int vol;
        protected final int id;
        protected Throwable exception = null;
        protected LLValue rval = null;

        public GetObjectCategoryIDsThread(int vol, int id) {
            super();
            setDaemon(true);
            this.vol = vol;
            this.id = id;
        }

        public void run() {
            try {
                // Object ID
                LLValue objIDValue = new LLValue().setAssocNotSet();
                objIDValue.add("ID", id);

                // Category ID List
                LLValue catIDList = new LLValue().setAssocNotSet();

                int status = LLDocs.ListObjectCategoryIDs(objIDValue, catIDList);

                // Need to detect if object was deleted, and return null in this case!!!
                if (Logging.connectors.isDebugEnabled()) {
                    Logging.connectors.debug(
                            "Livelink: Status value for getting object categories for " + Integer.toString(vol)
                                    + ":" + Integer.toString(id) + " is: " + Integer.toString(status));
                }

                if (status == 103101)
                    return;

                if (status != 0) {
                    throw new ManifoldCFException("Error retrieving document categories for "
                            + Integer.toString(vol) + ":" + Integer.toString(id) + ": status="
                            + Integer.toString(status) + " (" + llServer.getErrors() + ")");
                }
                rval = catIDList;
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public LLValue finishUp() throws ManifoldCFException, InterruptedException {
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException(
                            "Unrecognized exception type: " + thr.getClass().getName() + ": " + thr.getMessage(),
                            thr);
            }
            return rval;
        }
    }

    /** Get category IDs associated with an object.
    * @param vol is the volume ID
    * @param id the object ID
    * @return an array of integers containing category identifiers, or null if the object is not found.
    */
    protected int[] getObjectCategoryIDs(int vol, int id) throws ManifoldCFException, ServiceInterruption {
        int sanityRetryCount = FAILURE_RETRY_COUNT;
        while (true) {
            GetObjectCategoryIDsThread t = new GetObjectCategoryIDsThread(vol, id);
            try {
                t.start();
                LLValue catIDList;
                try {
                    catIDList = t.finishUp();
                } catch (ManifoldCFException e) {
                    sanityRetryCount = assessRetry(sanityRetryCount, e);
                    continue;
                }

                if (catIDList == null)
                    return null;

                int size = catIDList.size();

                if (Logging.connectors.isDebugEnabled()) {
                    Logging.connectors.debug("Livelink: Object " + Integer.toString(vol) + ":"
                            + Integer.toString(id) + " has " + Integer.toString(size) + " attached categories");
                }

                // Count the category ids
                int count = 0;
                int j = 0;
                while (j < size) {
                    int type = catIDList.toValue(j).toInteger("Type");
                    if (type == LAPI_ATTRIBUTES.CATEGORY_TYPE_LIBRARY)
                        count++;
                    j++;
                }

                int[] rval = new int[count];

                // Do the scan
                j = 0;
                count = 0;
                while (j < size) {
                    int type = catIDList.toValue(j).toInteger("Type");
                    if (type == LAPI_ATTRIBUTES.CATEGORY_TYPE_LIBRARY) {
                        int childID = catIDList.toValue(j).toInteger("ID");
                        rval[count++] = childID;
                    }
                    j++;
                }

                if (Logging.connectors.isDebugEnabled()) {
                    Logging.connectors
                            .debug("Livelink: Object " + Integer.toString(vol) + ":" + Integer.toString(id)
                                    + " has " + Integer.toString(rval.length) + " attached library categories");
                }

                return rval;
            } catch (InterruptedException e) {
                t.interrupt();
                throw new ManifoldCFException("Interrupted: " + e.getMessage(), e, ManifoldCFException.INTERRUPTED);
            } catch (RuntimeException e) {
                sanityRetryCount = handleLivelinkRuntimeException(e, sanityRetryCount, true);
                continue;
            }
        }
    }

    /** RootValue version of getPathId.
    */
    protected VolumeAndId getPathId(RootValue rv) throws ManifoldCFException, ServiceInterruption {
        return rv.getRootValue().getPathId(rv.getRemainderPath());
    }

    /** Rootvalue version of getCategoryId.
    */
    protected int getCategoryId(RootValue rv) throws ManifoldCFException, ServiceInterruption {
        return rv.getRootValue().getCategoryId(rv.getRemainderPath());
    }

    // Protected static methods

    /** Check if a file or directory should be included, given a document specification.
    *@param filename is the name of the "file".
    *@param documentSpecification is the specification.
    *@return true if it should be included.
    */
    protected static boolean checkInclude(String filename, Specification documentSpecification)
            throws ManifoldCFException {
        // Scan includes to insure we match
        int i = 0;
        while (i < documentSpecification.getChildCount()) {
            SpecificationNode sn = documentSpecification.getChild(i);
            if (sn.getType().equals("include")) {
                String filespec = sn.getAttributeValue("filespec");
                // If it matches, we can exit this loop.
                if (checkMatch(filename, 0, filespec))
                    break;
            }
            i++;
        }
        if (i == documentSpecification.getChildCount())
            return false;

        // We matched an include.  Now, scan excludes to ditch it if needed.
        i = 0;
        while (i < documentSpecification.getChildCount()) {
            SpecificationNode sn = documentSpecification.getChild(i);
            if (sn.getType().equals("exclude")) {
                String filespec = sn.getAttributeValue("filespec");
                // If it matches, we return false.
                if (checkMatch(filename, 0, filespec))
                    return false;
            }
            i++;
        }

        // System.out.println("Match!");
        return true;
    }

    /** Check if a file should be ingested, given a document specification.  It is presumed that
    * documents that pass checkInclude() will be checked with this method.
    *@param objID is the file ID.
    *@param documentSpecification is the specification.
    */
    protected boolean checkIngest(LivelinkContext llc, int objID, Specification documentSpecification)
            throws ManifoldCFException {
        // Since the only exclusions at this point are not based on file contents, this is a no-op.
        return true;
    }

    /** Check a match between two strings with wildcards.
    *@param sourceMatch is the expanded string (no wildcards)
    *@param sourceIndex is the starting point in the expanded string.
    *@param match is the wildcard-based string.
    *@return true if there is a match.
    */
    protected static boolean checkMatch(String sourceMatch, int sourceIndex, String match) {
        // Note: The java regex stuff looks pretty heavyweight for this purpose.
        // I've opted to try and do a simple recursive version myself, which is not compiled.
        // Basically, the match proceeds by recursive descent through the string, so that all *'s cause
        // recursion.
        boolean caseSensitive = false;

        return processCheck(caseSensitive, sourceMatch, sourceIndex, match, 0);
    }

    /** Recursive worker method for checkMatch.  Returns 'true' if there is a path that consumes both
    * strings in their entirety in a matched way.
    *@param caseSensitive is true if file names are case sensitive.
    *@param sourceMatch is the source string (w/o wildcards)
    *@param sourceIndex is the current point in the source string.
    *@param match is the match string (w/wildcards)
    *@param matchIndex is the current point in the match string.
    *@return true if there is a match.
    */
    protected static boolean processCheck(boolean caseSensitive, String sourceMatch, int sourceIndex, String match,
            int matchIndex) {
        // Logging.connectors.debug("Matching '"+sourceMatch+"' position "+Integer.toString(sourceIndex)+
        //      " against '"+match+"' position "+Integer.toString(matchIndex));

        // Match up through the next * we encounter
        while (true) {
            // If we've reached the end, it's a match.
            if (sourceMatch.length() == sourceIndex && match.length() == matchIndex)
                return true;
            // If one has reached the end but the other hasn't, no match
            if (match.length() == matchIndex)
                return false;
            if (sourceMatch.length() == sourceIndex) {
                if (match.charAt(matchIndex) != '*')
                    return false;
                matchIndex++;
                continue;
            }
            char x = sourceMatch.charAt(sourceIndex);
            char y = match.charAt(matchIndex);
            if (!caseSensitive) {
                if (x >= 'A' && x <= 'Z')
                    x -= 'A' - 'a';
                if (y >= 'A' && y <= 'Z')
                    y -= 'A' - 'a';
            }
            if (y == '*') {
                // Wildcard!
                // We will recurse at this point.
                // Basically, we want to combine the results for leaving the "*" in the match string
                // at this point and advancing the source index, with skipping the "*" and leaving the source
                // string alone.
                return processCheck(caseSensitive, sourceMatch, sourceIndex + 1, match, matchIndex)
                        || processCheck(caseSensitive, sourceMatch, sourceIndex, match, matchIndex + 1);
            }
            if (y == '?' || x == y) {
                sourceIndex++;
                matchIndex++;
            } else
                return false;
        }
    }

    /** Class for returning volume id/folder id combination on path lookup.
    */
    protected static class VolumeAndId {
        protected final int volumeID;
        protected final int folderID;

        public VolumeAndId(int volumeID, int folderID) {
            this.volumeID = volumeID;
            this.folderID = folderID;
        }

        public int getVolumeID() {
            return volumeID;
        }

        public int getPathId() {
            return folderID;
        }
    }

    /** Class that describes a metadata catid and path.
    */
    protected static class MetadataPathItem {
        protected final int catID;
        protected final String catName;

        /** Constructor.
        */
        public MetadataPathItem(int catID, String catName) {
            this.catID = catID;
            this.catName = catName;
        }

        /** Get the cat ID.
        *@return the id.
        */
        public int getCatID() {
            return catID;
        }

        /** Get the cat name.
        *@return the category name path.
        */
        public String getCatName() {
            return catName;
        }

    }

    /** Class that describes a metadata catid and attribute set.
    */
    protected static class MetadataItem {
        protected final MetadataPathItem pathItem;
        protected final Set<String> attributeNames = new HashSet<String>();

        /** Constructor.
        */
        public MetadataItem(MetadataPathItem pathItem) {
            this.pathItem = pathItem;
        }

        /** Add an attribute name.
        */
        public void addAttribute(String attributeName) {
            attributeNames.add(attributeName);
        }

        /** Get the path object.
        *@return the object.
        */
        public MetadataPathItem getPathItem() {
            return pathItem;
        }

        /** Get an iterator over the attribute names.
        *@return the iterator.
        */
        public Iterator<String> getAttributeNames() {
            return attributeNames.iterator();
        }

    }

    /** Class that tracks paths associated with nodes, and also keeps track of the name
    * of the metadata attribute to use for the path.
    */
    protected class SystemMetadataDescription {
        // The livelink context
        protected final LivelinkContext llc;

        // The path attribute name
        protected final String pathAttributeName;

        // The path separator
        protected final String pathSeparator;

        // The node ID to path name mapping (which acts like a cache)
        protected final Map<String, String> pathMap = new HashMap<String, String>();

        // The path name map
        protected final MatchMap matchMap = new MatchMap();

        // Acls
        protected final Set<String> aclMap = new HashSet<String>();
        protected final boolean securityOn;

        // Filter string
        protected final String filterString;

        protected final Set<String> holder = new HashSet<String>();
        protected final boolean includeAllMetadata;

        /** Constructor */
        public SystemMetadataDescription(LivelinkContext llc, Specification spec)
                throws ManifoldCFException, ServiceInterruption {
            this.llc = llc;
            String pathAttributeName = null;
            String pathSeparator = null;
            boolean securityOn = true;
            StringBuilder fsb = new StringBuilder();
            boolean first = true;
            boolean includeAllMetadata = false;

            for (int i = 0; i < spec.getChildCount(); i++) {
                SpecificationNode n = spec.getChild(i);
                if (n.getType().equals("pathnameattribute")) {
                    pathAttributeName = n.getAttributeValue("value");
                    pathSeparator = n.getAttributeValue("separator");
                    if (pathSeparator == null)
                        pathSeparator = "/";
                } else if (n.getType().equals("pathmap")) {
                    String pathMatch = n.getAttributeValue("match");
                    String pathReplace = n.getAttributeValue("replace");
                    matchMap.appendMatchPair(pathMatch, pathReplace);
                } else if (n.getType().equals("access")) {
                    String token = n.getAttributeValue("token");
                    aclMap.add(token);
                } else if (n.getType().equals("security")) {
                    String value = n.getAttributeValue("value");
                    if (value.equals("on"))
                        securityOn = true;
                    else if (value.equals("off"))
                        securityOn = false;
                } else if (n.getType().equals("include")) {
                    String includeMatch = n.getAttributeValue("filespec");
                    if (includeMatch != null) {
                        // Peel off the extension
                        int index = includeMatch.lastIndexOf(".");
                        if (index != -1) {
                            String type = includeMatch.substring(index + 1).toLowerCase().replace('*', '%');
                            if (first)
                                first = false;
                            else
                                fsb.append(" or ");
                            fsb.append("lower(FileType) like '").append(type).append("'");
                        }
                    }
                } else if (n.getType().equals("allmetadata")) {
                    String isAll = n.getAttributeValue("all");
                    if (isAll != null && isAll.equals("true"))
                        includeAllMetadata = true;
                } else if (n.getType().equals("metadata")) {
                    String category = n.getAttributeValue("category");
                    String attributeName = n.getAttributeValue("attribute");
                    String isAll = n.getAttributeValue("all");
                    if (isAll != null && isAll.equals("true")) {
                        // Locate all metadata items for the specified category path,
                        // and enter them into the array
                        getSession();
                        String[] attrs = getCategoryAttributes(llc, category);
                        if (attrs != null) {
                            int j = 0;
                            while (j < attrs.length) {
                                attributeName = attrs[j++];
                                String metadataName = packCategoryAttribute(category, attributeName);
                                holder.add(metadataName);
                            }
                        }
                    } else {
                        String metadataName = packCategoryAttribute(category, attributeName);
                        holder.add(metadataName);
                    }

                }
            }

            this.includeAllMetadata = includeAllMetadata;
            this.pathAttributeName = pathAttributeName;
            this.pathSeparator = pathSeparator;
            this.securityOn = securityOn;
            String filterStringPiece = fsb.toString();
            if (filterStringPiece.length() == 0)
                this.filterString = "0>1";
            else {
                StringBuilder sb = new StringBuilder();
                sb.append("SubType=").append(new Integer(LAPI_DOCUMENTS.FOLDERSUBTYPE).toString());
                sb.append(" or SubType=").append(new Integer(LAPI_DOCUMENTS.COMPOUNDDOCUMENTSUBTYPE).toString());
                sb.append(" or SubType=").append(new Integer(LAPI_DOCUMENTS.PROJECTSUBTYPE).toString());
                sb.append(" or (SubType=").append(new Integer(LAPI_DOCUMENTS.DOCUMENTSUBTYPE).toString());
                sb.append(" and (");
                // Walk through the document spec to find the documents that match under the specified root
                // include lower(column)=spec
                sb.append(filterStringPiece);
                sb.append("))");
                this.filterString = sb.toString();
            }
        }

        public boolean includeAllMetadata() {
            return includeAllMetadata;
        }

        public String[] getMetadataAttributes() {
            // Put into an array
            String[] specifiedMetadataAttributes = new String[holder.size()];
            int i = 0;
            for (String attrName : holder) {
                specifiedMetadataAttributes[i++] = attrName;
            }
            return specifiedMetadataAttributes;
        }

        public String getFilterString() {
            return filterString;
        }

        public String[] getAcls() {
            if (!securityOn)
                return null;

            String[] rval = new String[aclMap.size()];
            int i = 0;
            for (String token : aclMap) {
                rval[i++] = token;
            }
            return rval;
        }

        /** Get the path attribute name.
        *@return the path attribute name, or null if none specified.
        */
        public String getPathAttributeName() {
            return pathAttributeName;
        }

        /** Get the path separator.
        */
        public String getPathSeparator() {
            return pathSeparator;
        }

        /** Given an identifier, get the translated string that goes into the metadata.
        */
        public String getPathAttributeValue(String documentIdentifier)
                throws ManifoldCFException, ServiceInterruption {
            String path = getNodePathString(documentIdentifier);
            if (path == null)
                return null;
            return matchMap.translate(path);
        }

        /** Get the matchmap string.
        */
        public String getMatchMapString() {
            return matchMap.toString();
        }

        /** For a given node, get its path.
        */
        public String getNodePathString(String documentIdentifier) throws ManifoldCFException, ServiceInterruption {
            if (Logging.connectors.isDebugEnabled())
                Logging.connectors.debug("Looking up path for '" + documentIdentifier + "'");
            String path = pathMap.get(documentIdentifier);
            if (path == null) {
                // Not yet present.  Look it up, recursively
                String identifierPart = documentIdentifier;
                // Get the current node's name first
                // D = Document; anything else = Folder
                if (identifierPart.startsWith("D") || identifierPart.startsWith("F")) {
                    // Strip off the letter
                    identifierPart = identifierPart.substring(1);
                }
                // See if there's a volume label; if not, use the default.
                int colonPosition = identifierPart.indexOf(":");
                int volumeID;
                int objectID;
                try {
                    if (colonPosition == -1) {
                        // Default volume ID
                        volumeID = LLENTWK_VOL;
                        objectID = Integer.parseInt(identifierPart);
                    } else {
                        volumeID = Integer.parseInt(identifierPart.substring(0, colonPosition));
                        objectID = Integer.parseInt(identifierPart.substring(colonPosition + 1));
                    }
                } catch (NumberFormatException e) {
                    throw new ManifoldCFException("Bad document identifier: " + e.getMessage(), e);
                }

                ObjectInformation objInfo = llc.getObjectInformation(volumeID, objectID);
                if (!objInfo.exists()) {
                    // The document identifier describes a path that does not exist.
                    // This is unexpected, but don't die: just log a warning and allow the higher level to deal with it.
                    Logging.connectors.warn("Livelink: Bad document identifier: '" + documentIdentifier
                            + "' apparently does not exist, but need to find its path");
                    return null;
                }

                // Get the name attribute
                String name = objInfo.getName();
                // Get the parentID attribute
                int parentID = objInfo.getParentId().intValue();
                if (parentID == -1)
                    path = name;
                else {
                    String parentIdentifier = "F" + Integer.toString(volumeID) + ":" + Integer.toString(parentID);
                    String parentPath = getNodePathString(parentIdentifier);
                    if (parentPath == null)
                        return null;
                    path = parentPath + pathSeparator + name;
                }

                pathMap.put(documentIdentifier, path);
            }

            return path;
        }
    }

    /** Class that manages to find catid's and attribute names that have been specified.
    * This accepts a part of the version string which contains the string-ified metadata
    * spec, rather than pulling it out of the document specification.  That guarantees that
    * the version string actually corresponds to the document that was ingested.
    */
    protected class MetadataDescription {
        protected final LivelinkContext llc;

        // This is a map of category name to category ID and attributes
        protected final Map<String, MetadataPathItem> categoryMap = new HashMap<String, MetadataPathItem>();

        /** Constructor.
        */
        public MetadataDescription(LivelinkContext llc) {
            this.llc = llc;
        }

        /** Iterate over the metadata items represented by the specified chunk of version string.
        *@return an iterator over MetadataItem objects.
        */
        public Iterator<MetadataItem> getItems(String[] metadataItems)
                throws ManifoldCFException, ServiceInterruption {
            // This is the map that will be iterated over for a return value.
            // It gets built out of (hopefully cached) data from categoryMap.
            Map<String, MetadataItem> newMap = new HashMap<String, MetadataItem>();

            // Start at root
            ObjectInformation rootValue = null;

            // Walk through string and process each metadata element in turn.
            for (String metadataSpec : metadataItems) {
                StringBuilder categoryBuffer = new StringBuilder();
                StringBuilder attributeBuffer = new StringBuilder();
                unpackCategoryAttribute(categoryBuffer, attributeBuffer, metadataSpec);
                String category = categoryBuffer.toString();
                String attributeName = attributeBuffer.toString();

                // If there's already an entry for this category in the return map, use it
                MetadataItem mi = newMap.get(category);
                if (mi == null) {
                    // Now, look up the node information
                    // Convert category to cat id.
                    MetadataPathItem item = categoryMap.get(category);
                    if (item == null) {
                        RootValue rv = new RootValue(llc, category);
                        if (rootValue == null) {
                            rootValue = rv.getRootValue();
                        }

                        // Get the object id of the category the path describes.
                        // NOTE: We don't use the RootValue version of getCategoryId because
                        // we want to use the cached value of rootValue, if it was around.
                        int catObjectID = rootValue.getCategoryId(rv.getRemainderPath());
                        if (catObjectID != -1) {
                            item = new MetadataPathItem(catObjectID, rv.getRemainderPath());
                            categoryMap.put(category, item);
                        }
                    }
                    mi = new MetadataItem(item);
                    newMap.put(category, mi);
                }
                // Add attribute name to category
                mi.addAttribute(attributeName);
            }

            return newMap.values().iterator();
        }

    }

    /** This class caches the category path strings associated with a given category object identifier.
    * The goal is to allow reasonably speedy lookup of the path name, so we can put it into the metadata part of the
    * version string.
    */
    protected class CategoryPathAccumulator {
        // Livelink context
        protected final LivelinkContext llc;

        // This is the map from category ID to category path name.
        // It's keyed by an Integer formed from the id, and has String values.
        protected final Map<Integer, String> categoryPathMap = new HashMap<Integer, String>();

        // This is the map from category ID to attribute names.  Keyed
        // by an Integer formed from the id, and has a String[] value.
        protected final Map<Integer, String[]> attributeMap = new HashMap<Integer, String[]>();

        /** Constructor */
        public CategoryPathAccumulator(LivelinkContext llc) {
            this.llc = llc;
        }

        /** Get a specified set of packed category paths with attribute names, given the category identifiers */
        public String[] getCategoryPathsAttributeNames(int[] catIDs)
                throws ManifoldCFException, ServiceInterruption {
            Set<String> set = new HashSet<String>();
            for (int x : catIDs) {
                Integer key = new Integer(x);
                String pathValue = categoryPathMap.get(key);
                if (pathValue == null) {
                    // Chase the path back up the chain
                    pathValue = findPath(key.intValue());
                    if (pathValue == null)
                        continue;
                    categoryPathMap.put(key, pathValue);
                }
                String[] attributeNames = attributeMap.get(key);
                if (attributeNames == null) {
                    // Get the attributes for this category
                    attributeNames = findAttributes(key.intValue());
                    if (attributeNames == null)
                        continue;
                    attributeMap.put(key, attributeNames);
                }
                // Now, put the path and the attributes into the hash.
                for (String attributeName : attributeNames) {
                    String metadataName = packCategoryAttribute(pathValue, attributeName);
                    set.add(metadataName);
                }
            }

            String[] rval = new String[set.size()];
            int i = 0;
            for (String value : set) {
                rval[i++] = value;
            }

            return rval;
        }

        /** Find a category path given a category ID */
        protected String findPath(int catID) throws ManifoldCFException, ServiceInterruption {
            return getObjectPath(llc.getObjectInformation(0, catID));
        }

        /** Get the complete path for an object.
        */
        protected String getObjectPath(ObjectInformation currentObject)
                throws ManifoldCFException, ServiceInterruption {
            String path = null;
            while (true) {
                if (currentObject.isCategoryWorkspace())
                    return CATEGORY_NAME + ((path == null) ? "" : ":" + path);
                else if (currentObject.isEntityWorkspace())
                    return ENTWKSPACE_NAME + ((path == null) ? "" : ":" + path);

                if (!currentObject.exists()) {
                    // The document identifier describes a path that does not exist.
                    // This is unexpected, but an exception would terminate the job, and we don't want that.
                    Logging.connectors.warn("Livelink: Bad identifier found? " + currentObject.toString()
                            + " apparently does not exist, but need to look up its path");
                    return null;
                }

                // Get the name attribute
                String name = currentObject.getName();
                if (path == null)
                    path = name;
                else
                    path = name + "/" + path;

                // Get the parentID attribute
                int parentID = currentObject.getParentId().intValue();
                if (parentID == -1) {
                    // Oops, hit the top of the path without finding the workspace we're in.
                    // No idea where it lives; note this condition and exit.
                    Logging.connectors.warn("Livelink: Object ID " + currentObject.toString()
                            + " doesn't seem to live in enterprise or category workspace!  Path I got was '" + path
                            + "'");
                    return null;
                }
                currentObject = llc.getObjectInformation(0, parentID);
            }
        }

        /** Find a set of attributes given a category ID */
        protected String[] findAttributes(int catID) throws ManifoldCFException, ServiceInterruption {
            return getCategoryAttributes(catID);
        }

    }

    /** Class representing a root value object, plus remainder string.
    * This class peels off the workspace name prefix from a path string or
    * attribute string, and finds the right workspace root node and remainder
    * path.
    */
    protected class RootValue {
        protected final LivelinkContext llc;
        protected final String workspaceName;
        protected ObjectInformation rootValue = null;
        protected final String remainderPath;

        /** Constructor.
        *@param pathString is the path string.
        */
        public RootValue(LivelinkContext llc, String pathString) {
            this.llc = llc;
            int colonPos = pathString.indexOf(":");
            if (colonPos == -1) {
                remainderPath = pathString;
                workspaceName = ENTWKSPACE_NAME;
            } else {
                workspaceName = pathString.substring(0, colonPos);
                remainderPath = pathString.substring(colonPos + 1);
            }
        }

        /** Get the path string.
        *@return the path string (without the workspace name prefix).
        */
        public String getRemainderPath() {
            return remainderPath;
        }

        /** Get the root node.
        *@return the root node.
        */
        public ObjectInformation getRootValue() throws ManifoldCFException, ServiceInterruption {
            if (rootValue == null) {
                if (workspaceName.equals(CATEGORY_NAME))
                    rootValue = llc.getObjectInformation(LLCATWK_VOL, LLCATWK_ID);
                else if (workspaceName.equals(ENTWKSPACE_NAME))
                    rootValue = llc.getObjectInformation(LLENTWK_VOL, LLENTWK_ID);
                else
                    throw new ManifoldCFException("Bad workspace name: " + workspaceName);
            }

            if (!rootValue.exists()) {
                Logging.connectors.warn("Livelink: Could not get workspace/volume ID!  Retrying...");
                // This cannot mean a real failure; it MUST mean that we have had an intermittent communication hiccup.  So, pass it off as a service interruption.
                throw new ServiceInterruption("Service interruption getting root value",
                        new ManifoldCFException("Could not get workspace/volume id"),
                        System.currentTimeMillis() + 60000L, System.currentTimeMillis() + 600000L, -1, true);
            }

            return rootValue;
        }
    }

    // Here's an interesting note.  All of the LAPI exceptions are subclassed off of RuntimeException.  This makes life
    // hell because there is no superclass exception to capture, and even tweaky server communication issues wind up throwing
    // uncaught RuntimeException's up the stack.
    //
    // To fix this rather bad design, all places that invoke LAPI need to catch RuntimeException and run it through the following
    // method for interpretation and logging.
    //

    /** Interpret runtimeexception to search for livelink API errors.  Throws an appropriately reinterpreted exception, or
    * just returns if the exception indicates that a short-cycle retry attempt should be made.  (In that case, the appropriate
    * wait has been already performed).
    *@param e is the RuntimeException caught
    *@param failIfTimeout is true if, for transient conditions, we want to signal failure if the timeout condition is acheived.
    */
    protected int handleLivelinkRuntimeException(RuntimeException e, int sanityRetryCount, boolean failIfTimeout)
            throws ManifoldCFException, ServiceInterruption {
        if (e instanceof com.opentext.api.LLHTTPAccessDeniedException
                || e instanceof com.opentext.api.LLHTTPClientException
                || e instanceof com.opentext.api.LLHTTPServerException
                || e instanceof com.opentext.api.LLIndexOutOfBoundsException
                || e instanceof com.opentext.api.LLNoFieldSpecifiedException
                || e instanceof com.opentext.api.LLNoValueSpecifiedException
                || e instanceof com.opentext.api.LLSecurityProviderException
                || e instanceof com.opentext.api.LLUnknownFieldException || e instanceof NumberFormatException
                || e instanceof ArrayIndexOutOfBoundsException) {
            String details = llServer.getErrors();
            long currentTime = System.currentTimeMillis();
            throw new ServiceInterruption(
                    "Livelink API error: " + e.getMessage() + ((details == null) ? "" : "; " + details), e,
                    currentTime + 5 * 60000L, currentTime + 12 * 60 * 60000L, -1, failIfTimeout);
        } else if (e instanceof com.opentext.api.LLBadServerCertificateException
                || e instanceof com.opentext.api.LLHTTPCGINotFoundException
                || e instanceof com.opentext.api.LLCouldNotConnectHTTPException
                || e instanceof com.opentext.api.LLHTTPForbiddenException
                || e instanceof com.opentext.api.LLHTTPProxyAuthRequiredException
                || e instanceof com.opentext.api.LLHTTPRedirectionException
                || e instanceof com.opentext.api.LLUnsupportedAuthMethodException
                || e instanceof com.opentext.api.LLWebAuthInitException) {
            String details = llServer.getErrors();
            throw new ManifoldCFException(
                    "Livelink API error: " + e.getMessage() + ((details == null) ? "" : "; " + details), e);
        } else if (e instanceof com.opentext.api.LLSSLNotAvailableException) {
            String details = llServer.getErrors();
            throw new ManifoldCFException(
                    "Missing llssl.jar error: " + e.getMessage() + ((details == null) ? "" : "; " + details), e);
        } else if (e instanceof com.opentext.api.LLIllegalOperationException) {
            // This usually means that LAPI has had a minor communication difficulty but hasn't reported it accurately.
            // We *could* throw a ServiceInterruption, but OpenText recommends to just retry almost immediately.
            String details = llServer.getErrors();
            return assessRetry(sanityRetryCount, new ManifoldCFException("Livelink API illegal operation error: "
                    + e.getMessage() + ((details == null) ? "" : "; " + details), e));
        } else if (e instanceof com.opentext.api.LLIOException
                || (e instanceof RuntimeException && e.getClass().getName().startsWith("com.opentext.api."))) {
            // Catching obfuscated and unspecified opentext runtime exceptions now too - these come from llssl.jar.  We
            // have to presume these are SSL connection errors; nothing else to go by unfortunately.  UGH.

            // Treat this as a transient error; try again in 5 minutes, and only fail after 12 hours of trying

            // LAPI is returning errors that are not terribly explicit, and I don't have control over their wording, so check that server can be resolved by DNS,
            // so that a better error message can be returned.
            try {
                InetAddress.getByName(serverName);
            } catch (UnknownHostException e2) {
                throw new ManifoldCFException("Server name '" + serverName + "' cannot be resolved", e2);
            }

            long currentTime = System.currentTimeMillis();
            throw new ServiceInterruption(e.getMessage(), e, currentTime + 5 * 60000L,
                    currentTime + 12 * 60 * 60000L, -1, failIfTimeout);
        } else
            throw e;
    }

    /** Do a retry, or throw an exception if the retry count has been exhausted
    */
    protected static int assessRetry(int sanityRetryCount, ManifoldCFException e) throws ManifoldCFException {
        if (sanityRetryCount == 0) {
            throw e;
        }

        sanityRetryCount--;

        try {
            ManifoldCF.sleep(1000L);
        } catch (InterruptedException e2) {
            throw new ManifoldCFException(e2.getMessage(), e2, ManifoldCFException.INTERRUPTED);
        }
        // Exit the method
        return sanityRetryCount;

    }

    /** This thread performs a LAPI FetchVersion command, streaming the resulting
    * document back through a XThreadInputStream to the invoking thread.
    */
    protected class DocumentReadingThread extends Thread {

        protected Throwable exception = null;
        protected final int volumeID;
        protected final int docID;
        protected final int versionNumber;
        protected final XThreadInputStream stream;

        public DocumentReadingThread(int volumeID, int docID, int versionNumber) {
            super();
            this.volumeID = volumeID;
            this.docID = docID;
            this.versionNumber = versionNumber;
            this.stream = new XThreadInputStream();
            setDaemon(true);
        }

        @Override
        public void run() {
            try {
                XThreadOutputStream outputStream = new XThreadOutputStream(stream);
                try {
                    int status = LLDocs.FetchVersion(volumeID, docID, versionNumber, outputStream);
                    if (status != 0) {
                        throw new ManifoldCFException(
                                "Error retrieving contents of document " + Integer.toString(volumeID) + ":"
                                        + Integer.toString(docID) + " revision " + versionNumber + " : Status="
                                        + Integer.toString(status) + " (" + llServer.getErrors() + ")");
                    }
                } finally {
                    outputStream.close();
                }
            } catch (Throwable e) {
                this.exception = e;
            }
        }

        public InputStream getSafeInputStream() {
            return stream;
        }

        public void finishUp() throws InterruptedException, ManifoldCFException {
            // This will be called during the finally
            // block in the case where all is well (and
            // the stream completed) and in the case where
            // there were exceptions.
            stream.abort();
            join();
            Throwable thr = exception;
            if (thr != null) {
                if (thr instanceof ManifoldCFException)
                    throw (ManifoldCFException) thr;
                else if (thr instanceof RuntimeException)
                    throw (RuntimeException) thr;
                else if (thr instanceof Error)
                    throw (Error) thr;
                else
                    throw new RuntimeException("Unhandled exception of type: " + thr.getClass().getName(), thr);
            }
        }

    }

    /** This thread does the actual socket communication with the server.
    * It's set up so that it can be abandoned at shutdown time.
    *
    * The way it works is as follows:
    * - it starts the transaction
    * - it receives the response, and saves that for the calling class to inspect
    * - it transfers the data part to an input stream provided to the calling class
    * - it shuts the connection down
    *
    * If there is an error, the sequence is aborted, and an exception is recorded
    * for the calling class to examine.
    *
    * The calling class basically accepts the sequence above.  It starts the
    * thread, and tries to get a response code.  If instead an exception is seen,
    * the exception is thrown up the stack.
    */
    protected static class ExecuteMethodThread extends Thread {
        /** Client and method, all preconfigured */
        protected final HttpClient httpClient;
        protected final HttpRequestBase executeMethod;

        protected HttpResponse response = null;
        protected Throwable responseException = null;
        protected XThreadInputStream threadStream = null;
        protected InputStream bodyStream = null;
        protected boolean streamCreated = false;
        protected Throwable streamException = null;
        protected boolean abortThread = false;

        protected Throwable shutdownException = null;

        protected Throwable generalException = null;

        public ExecuteMethodThread(HttpClient httpClient, HttpRequestBase executeMethod) {
            super();
            setDaemon(true);
            this.httpClient = httpClient;
            this.executeMethod = executeMethod;
        }

        public void run() {
            try {
                try {
                    // Call the execute method appropriately
                    synchronized (this) {
                        if (!abortThread) {
                            try {
                                response = httpClient.execute(executeMethod);
                            } catch (java.net.SocketTimeoutException e) {
                                responseException = e;
                            } catch (ConnectTimeoutException e) {
                                responseException = e;
                            } catch (InterruptedIOException e) {
                                throw e;
                            } catch (Throwable e) {
                                responseException = e;
                            }
                            this.notifyAll();
                        }
                    }

                    // Start the transfer of the content
                    if (responseException == null) {
                        synchronized (this) {
                            if (!abortThread) {
                                try {
                                    bodyStream = response.getEntity().getContent();
                                    if (bodyStream != null) {
                                        threadStream = new XThreadInputStream(bodyStream);
                                    }
                                    streamCreated = true;
                                } catch (java.net.SocketTimeoutException e) {
                                    streamException = e;
                                } catch (ConnectTimeoutException e) {
                                    streamException = e;
                                } catch (InterruptedIOException e) {
                                    throw e;
                                } catch (Throwable e) {
                                    streamException = e;
                                }
                                this.notifyAll();
                            }
                        }
                    }

                    if (responseException == null && streamException == null) {
                        if (threadStream != null) {
                            // Stuff the content until we are done
                            threadStream.stuffQueue();
                        }
                    }

                } finally {
                    if (bodyStream != null) {
                        try {
                            bodyStream.close();
                        } catch (IOException e) {
                        }
                        bodyStream = null;
                    }
                    synchronized (this) {
                        try {
                            executeMethod.abort();
                        } catch (Throwable e) {
                            shutdownException = e;
                        }
                        this.notifyAll();
                    }
                }
            } catch (Throwable e) {
                // We catch exceptions here that should ONLY be InterruptedExceptions, as a result of the thread being aborted.
                this.generalException = e;
            }
        }

        public int getResponseCode() throws InterruptedException, IOException, HttpException {
            // Must wait until the response object is there
            while (true) {
                synchronized (this) {
                    checkException(responseException);
                    if (response != null)
                        return response.getStatusLine().getStatusCode();
                    wait();
                }
            }
        }

        public long getResponseContentLength() throws InterruptedException, IOException, HttpException {
            String contentLength = getFirstHeader("Content-Length");
            if (contentLength == null || contentLength.length() == 0)
                return -1L;
            return new Long(contentLength.trim()).longValue();
        }

        public String getFirstHeader(String headerName) throws InterruptedException, IOException, HttpException {
            // Must wait for the response object to appear
            while (true) {
                synchronized (this) {
                    checkException(responseException);
                    if (response != null) {
                        Header h = response.getFirstHeader(headerName);
                        if (h == null)
                            return null;
                        return h.getValue();
                    }
                    wait();
                }
            }
        }

        public InputStream getSafeInputStream() throws InterruptedException, IOException, HttpException {
            // Must wait until stream is created, or until we note an exception was thrown.
            while (true) {
                synchronized (this) {
                    if (responseException != null)
                        throw new IllegalStateException("Check for response before getting stream");
                    checkException(streamException);
                    if (streamCreated)
                        return threadStream;
                    wait();
                }
            }
        }

        public void abort() {
            // This will be called during the finally
            // block in the case where all is well (and
            // the stream completed) and in the case where
            // there were exceptions.
            synchronized (this) {
                if (streamCreated) {
                    if (threadStream != null)
                        threadStream.abort();
                }
                abortThread = true;
            }
        }

        public void finishUp() throws InterruptedException {
            join();
        }

        protected synchronized void checkException(Throwable exception) throws IOException, HttpException {
            if (exception != null) {
                // Throw the current exception, but clear it, so no further throwing is possible on the same problem.
                Throwable e = exception;
                if (e instanceof IOException)
                    throw (IOException) e;
                else if (e instanceof HttpException)
                    throw (HttpException) e;
                else if (e instanceof RuntimeException)
                    throw (RuntimeException) e;
                else if (e instanceof Error)
                    throw (Error) e;
                else
                    throw new RuntimeException("Unhandled exception of type: " + e.getClass().getName(), e);
            }
        }

    }

}