CollabTextDocument.java

/*
 * Copyright 2012 The Apache Software Foundation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.odftoolkit.odfdom.changes;

import static org.odftoolkit.odfdom.changes.OperationConstants.CONFIG_DEBUG_OPERATIONS;
import static org.odftoolkit.odfdom.changes.OperationConstants.CONFIG_MAX_SHEETS;
import static org.odftoolkit.odfdom.changes.OperationConstants.CONFIG_MAX_TABLE_CELLS;
import static org.odftoolkit.odfdom.changes.OperationConstants.CONFIG_MAX_TABLE_COLUMNS;
import static org.odftoolkit.odfdom.changes.OperationConstants.CONFIG_MAX_TABLE_ROWS;
import static org.odftoolkit.odfdom.changes.OperationConstants.OPK_OPERATIONS;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.odftoolkit.odfdom.doc.OdfTextDocument;
import org.odftoolkit.odfdom.dom.OdfSchemaDocument.OdfXMLFile;
import org.odftoolkit.odfdom.pkg.OdfFileDom;
import org.odftoolkit.odfdom.pkg.OdfPackage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.SAXException;

/** This collaboration document embraces an ODF document ad */
public class CollabTextDocument implements Closeable {

  private static final Logger LOG = LoggerFactory.getLogger(CollabTextDocument.class);
  //	private static final Logger LOG = Logger.getLogger(CollabTextDocument.class.getName());
  // not private as used as well by tests
  static final String OPERATION_REVISON_FILE = "debug/revision.txt";
  static final String OPERATION_TEXT_FILE_PREFIX = "debug/operationUpdates_";
  static final String ORIGNAL_ODT_FILE = "debug/original.odt";

  /**
   * Only being used when a certain test (my workbench test, which is not part of the regression
   * testing) is being used
   */
  private static final String OPERATION_DEBUG_OUTPUT_FILE =
      System.getProperty("java.io.tmpdir") + File.separatorChar + "odf-operations.txt";

  private OdfTextDocument mTextDocument;
  private OdfPackage mPackage;
  private Map<Long, byte[]> mResourceMap;
  private boolean mSaveDebugOperations;
  private int mMaxTableColumnCount;
  private int mMaxTableRowCount;
  private int mMaxTableCellCount;
  private int mMaxSheetCount;
  private boolean isMetadataUpdated = false;
  private int appliedChangesCount = 0;

  /** Creates an empty ODF document. */
  private CollabTextDocument() {}

  // to be ... depending to the target the owner document might be also the StylesDom
  public OdfFileDom getOwnerDocument() throws SAXException, IOException {

    return getDocument().getContentDom();
  }

  /**
   * Creates a new ODT document from the default template
   *
   * @return new CollabTextDocument
   */
  public static CollabTextDocument newTextCollabDocument() throws Exception {
    CollabTextDocument odt = new CollabTextDocument();
    odt.mTextDocument = OdfTextDocument.newTextDocument(Boolean.TRUE);
    return odt;
  }

  /**
   * Creates an CollabTextDocument from the OpenDocument provided by a resource Stream.
   *
   * <p>Since an InputStream does not provide the arbitrary (non sequential) read access needed by
   * CollabTextDocument, the InputStream is cached. This usually takes more time compared to the
   * other createInternalDocument methods. An advantage of caching is that there are no problems
   * overwriting an input file.
   *
   * @param inputStream - the InputStream of the ODF text document.
   */
  public CollabTextDocument(InputStream inputStream) throws Exception {
    mTextDocument = OdfTextDocument.loadDocument(inputStream, Boolean.TRUE);
  }

  /**
   * Creates an CollabTextDocument from the OpenDocument provided by a resource Stream.
   *
   * <p>Since an InputStream does not provide the arbitrary (non sequential) read access needed by
   * CollabTextDocument, the InputStream is cached. This usually takes more time compared to the
   * other createInternalDocument methods. An advantage of caching is that there are no problems
   * overwriting an input file.
   *
   * @param configuration - key/value pairs of user given run-time settings (configuration)
   * @param documentStream - the InputStream of the ODF text document.
   */
  public CollabTextDocument(InputStream documentStream, Map<String, Object> configuration)
      throws Exception {
    this(documentStream, null, configuration);
  }

  /**
   * Creates an CollabTextDocument from the OpenDocument provided by a resource Stream.
   *
   * <p>Since an InputStream does not provide the arbitrary (non sequential) read access needed by
   * CollabTextDocument, the InputStream is cached. This usually takes more time compared to the
   * other createInternalDocument methods. An advantage of caching is that there are no problems
   * overwriting an input file.
   *
   * @param inputStream - the InputStream of the ODF text document.
   * @param resourceManager - the bytes of new resources can be accessed by an ID.
   * @param configuration - key/value pairs of user given run-time settings (configuration)
   * @throws java.lang.Exception document could not be opened
   */
  public CollabTextDocument(
      InputStream inputStream, Map<Long, byte[]> resourceManager, Map<String, Object> configuration)
      throws Exception {
    mTextDocument = OdfTextDocument.loadDocument(inputStream, configuration, Boolean.TRUE);
    mPackage = getDocument().getPackage();
    mResourceMap = resourceManager;
    if (configuration != null) {
      if (configuration.containsKey(CONFIG_DEBUG_OPERATIONS)) {
        mSaveDebugOperations = (Boolean) configuration.get(CONFIG_DEBUG_OPERATIONS);
      }
      if (configuration.containsKey(CONFIG_MAX_TABLE_COLUMNS)) {
        mMaxTableColumnCount = (Integer) configuration.get(CONFIG_MAX_TABLE_COLUMNS);
      }
      if (configuration.containsKey(CONFIG_MAX_TABLE_ROWS)) {
        mMaxTableRowCount = (Integer) configuration.get(CONFIG_MAX_TABLE_ROWS);
      }
      if (configuration.containsKey(CONFIG_MAX_TABLE_CELLS)) {
        mMaxTableCellCount = (Integer) configuration.get(CONFIG_MAX_TABLE_CELLS);
      }
      if (configuration.containsKey(CONFIG_MAX_SHEETS)) {
        mMaxSheetCount = (Integer) configuration.get(CONFIG_MAX_SHEETS);
      }
    }
  }

  /**
   * Receives the (known) operations of the ODF text document
   *
   * @return the operations as JSON
   */
  public JSONObject getDocumentAsChanges() throws SAXException, JSONException, IOException {

    JSONObject ops = mTextDocument.getOperations(this);
    if (ops != null && ops.length() > 0) {
      LOG.debug("\n\n*** ALL OPERATIONS:\n{0}", ops.toString());
    } else {
      LOG.debug("\n\n*** ALL OPERATIONS:\nNo Operation have been extracted!");
    }
    return ops;
  }

  /**
   * Applies the (known) operations to upon the latest state of the ODF text document
   *
   * @param operationString ODF operations as String JSONObject with "changes" as key for operations
   * @return the number of operations being accepted
   */
  public int applyChanges(String operationString) throws Exception {
    JSONObject operations = new JSONObject(operationString);
    int operationsCount = this.applyChanges(operations);
    // if the document was altered
    if (operationsCount > 0) {
      // remove the cached view
      removeCachedView();
    }
    return operationsCount;
  }

  /**
   * Applies the (known) operations to upon the latest state of the ODF text document
   *
   * @param operations ODF operations as JSONArray within an JSONObject with OPK_OPERATIONS key from
   *     <code>OperationConstants</code>.
   * @return the number of operations being accepted
   */
  public int applyChanges(JSONObject operations) throws Exception {
    LOG.debug("\n*** EDIT OPERATIONS:\n{0}", operations.toString());
    //      System.err.println("\n*** EDIT OPERATIONS:\n" + operations.toString());
    final JSONArray ops = operations.getJSONArray(OPK_OPERATIONS);
    if (mSaveDebugOperations) {
      addOriginalOdfAsDebug();
      addOperationFileAsDebug(ops);
    }
    saveLocalDebug(ops);
    final int operationCount = JsonOperationConsumer.applyOperations(this, ops);
    if (operationCount > 0) {
      // remove the cached view
      removeCachedView();
      if (!isMetadataUpdated) {
        mTextDocument.updateMetaData();
        isMetadataUpdated = true;
      }
    }
    return operationCount;
  }

  private static void saveLocalDebug(JSONArray ops) {
    // only meant for local testing
    String unitTest = System.getProperty("test");
    if (unitTest != null && unitTest.equals("org.odftoolkit.odfdom.component.MyLatestTest")) {
      saveOperationAsDebugFile(ops, OPERATION_DEBUG_OUTPUT_FILE);
    }
  }

  private void removeCachedView() {
    if (mPackage == null) {
      mPackage = getDocument().getPackage();
    }
    // removes the LO/AO view caching
    mPackage.remove("Thumbnails/thumbnail.png");
  }

  private void addOriginalOdfAsDebug() throws SAXException {
    OdfPackage pkg = mTextDocument.getPackage();
    // if there is not already an orignal file being stored
    if (!pkg.contains(ORIGNAL_ODT_FILE)) {
      LOG.debug("Adding original ODT document as debug within the zip at " + ORIGNAL_ODT_FILE);
      try {
        // ..from the ODF ZIP
        pkg.insert(
            pkg.getInputStream(), ORIGNAL_ODT_FILE, "application/vnd.oasis.opendocument.text");
      } catch (IOException ex) {
        LOG.error(null, ex);
      }
    }
  }

  /*
   * @param file the file to be saved, when creating a test file, you might use <code>newTestOutputFile(String relativeFilePath)</code>.
   * @param inputData the data to be written into the file
   */
  private static void saveStringToFile(File file, String data) {
    saveStringToFile(file, Charset.forName("UTF-8"), data);
  }

  /**
   * @param file the file to be saved, when creating a test file, you might use <code>
   *     newTestOutputFile(String relativeFilePath)</code>.
   * @param charset the character encoding
   * @param inputData the data to be written into the file
   */
  private static void saveStringToFile(File file, Charset charset, String inputData) {
    BufferedWriter out = null;
    try {
      out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), charset));
      // out = new BufferedWriter(new FileWriter(file));
      out.write(inputData);
      out.close();
    } catch (IOException ex) {
      LOG.error(null, ex);
    } finally {
      try {
        if (out != null) {
          out.close();
        }
      } catch (IOException ex) {
        LOG.error(null, ex);
      }
    }
  }

  private static void saveOperationAsDebugFile(JSONArray operations, String debugFilePath) {
    // serialize the operations as String (using ascii characters only) and indent a line for every
    // new operations (heuristic: every array item will be split into new line)
    if (debugFilePath != null && !debugFilePath.isEmpty()) {
      saveStringToFile(new File(debugFilePath), operations.toString());
    }
  }

  private void addOperationFileAsDebug(JSONArray operations) {
    // serialize the operations as String (using ascii characters only) and indent a line for every
    // new operations (heuristic: every array item will be split into new line)
    try {
      OdfPackage pkg = mTextDocument.getPackage();
      // start with zero to always increment (either read a default by file or new)
      int revisionNo = 0;
      // if there was already a revision, get it..
      if (pkg.contains(OPERATION_REVISON_FILE)) {
        // ..from the ODF ZIP
        byte[] revisionByteArray = pkg.getBytes(OPERATION_REVISON_FILE);
        if (revisionByteArray != null && revisionByteArray.length != 0) {
          BufferedReader reader =
              new BufferedReader(
                  new InputStreamReader(new ByteArrayInputStream(revisionByteArray)));
          // read the first line of the file, only containing one number
          String firstLine = reader.readLine();
          // map it to a number
          revisionNo = Integer.parseInt(firstLine);
          LOG.debug("Found an existing revision number:{0}", revisionNo);
        }
      } else {
        LOG.debug("Created a new revision number: 1");
      }
      // always increment, so even a new file starts with the revision number "1"
      revisionNo++;
      pkg.insert(
          operations.toString().getBytes(),
          OPERATION_TEXT_FILE_PREFIX + revisionNo + ".txt",
          "text/plain");
      pkg.insert(Integer.toString(revisionNo).getBytes(), OPERATION_REVISON_FILE, "text/plain");
    } catch (Exception ex) {
      LOG.error(null, ex);
    }
  }

  public long getContentSize() {
    if (mPackage == null) {
      if (mTextDocument != null) {
        mPackage = mTextDocument.getPackage();
      }
    }
    if (mPackage != null) {
      return mPackage.getSize(OdfXMLFile.CONTENT.getFileName());
    } else {
      return 0;
    }
  }

  /**
   * Returns the OdfTextDocument encapsulating the DOM view
   *
   * @return ODF document - currently only Te
   */
  public OdfTextDocument getDocument() {
    return mTextDocument;
  }

  /**
   * Returns the OdfPackage
   *
   * @return ODF Package
   */
  public OdfPackage getPackage() {
    if (mPackage == null && mTextDocument != null) {
      mPackage = mTextDocument.getPackage();
    }
    return mPackage;
  }

  /**
   * Close the OdfPackage and release all temporary created data. After execution of this method,
   * this class is no longer usable. Do this as the last action to free resources. Closing an
   * already closed document has no effect.
   */
  @Override
  public void close() {
    mTextDocument.close();
  }

  void setAppliedChangesCount(int opCount) {
    appliedChangesCount = opCount;
  }

  /** @return number of correct applied operations */
  public int countAppliedChanges() {
    return appliedChangesCount;
  }

  public int getMaxTableColumnsCount() {
    return mMaxTableColumnCount;
  }

  public int getMaxTableRowsCount() {
    return mMaxTableRowCount;
  }

  public int getMaxTableCellCount() {
    return mMaxTableCellCount;
  }

  public int getMaxSheetCount() {
    return mMaxSheetCount;
  }

  /**
   * Receives the a map with new resources for the Document
   *
   * @return the operations as JSON
   */
  public Map<Long, byte[]> getResourceMap() {
    if (mResourceMap != null) {
      return mResourceMap;
    }
    return null;
  }
}