// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.collide.server.documents;
import com.google.collide.dto.ClientToServerDocOp;
import com.google.collide.dto.DocOp;
import com.google.collide.dto.DocumentSelection;
import com.google.collide.dto.server.DtoServerImpls.DocumentSelectionImpl;
import com.google.collide.dto.server.DtoServerImpls.FilePositionImpl;
import com.google.collide.dto.server.ServerDocOpFactory;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.LineInfo;
import com.google.collide.shared.document.anchor.Anchor;
import com.google.collide.shared.document.anchor.AnchorManager;
import com.google.collide.shared.document.anchor.AnchorType;
import com.google.collide.shared.ot.Composer;
import com.google.collide.shared.ot.Composer.ComposeException;
import com.google.collide.shared.ot.DocOpApplier;
import com.google.collide.shared.ot.DocOpUtils;
import com.google.collide.shared.ot.OperationPair;
import com.google.collide.shared.ot.PositionTransformer;
import com.google.collide.shared.ot.Transformer;
import com.google.collide.shared.ot.Transformer.TransformException;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import org.vertx.java.core.logging.Logger;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* A document at a specific revision. It can be converted to and from raw text, and can be mutated
* via the application of document operations passed to
* {@link #consume(List, String, int, DocumentSelection)}.
*
* <p>
* This class is thread-safe.
*
*/
public class VersionedDocument {
/**
* A simple class for the result of the {@link VersionedDocument#consume} method.
*/
public static class ConsumeResult {
/**
* The set of transformed DocOps, where the value is the DocOp and the key is the revision of
* the document resulting from the application of that DocOp. The size of the returned Map will
* equal that of the List of input DocOps.
*/
public final SortedMap<Integer, AppliedDocOp> appliedDocOps;
/**
* The transformed selection of the user, or null if one was not given.
*
* @see ClientToServerDocOp#getSelection()
*/
public final DocumentSelection transformedDocumentSelection;
private ConsumeResult(SortedMap<Integer, AppliedDocOp> appliedDocOps,
DocumentSelection transformedDocumentSelection) {
this.appliedDocOps = appliedDocOps;
this.transformedDocumentSelection = transformedDocumentSelection;
}
}
/**
* Doc op that was applied to the document, tagged with its author.
*/
public static class AppliedDocOp {
public final DocOp docOp;
public final String authorClientId;
private AppliedDocOp(DocOp docOp, String authorClientId) {
this.docOp = docOp;
this.authorClientId = authorClientId;
}
@Override
public String toString() {
return "[" + authorClientId + ", " + DocOpUtils.toString(docOp, true) + "]";
}
}
/**
* Serialized form of the document at a particular revision.
*/
public static class VersionedText {
public final int ccRevision;
public final String text;
private VersionedText(int ccRevision, String text) {
this.ccRevision = ccRevision;
this.text = text;
}
}
/**
* Thrown when there was a problem with document operations transformation or composition.
*/
public static class DocumentOperationException extends Exception {
public DocumentOperationException(String text, Throwable cause) {
super(text, cause);
}
public DocumentOperationException(String text) {
super(text);
}
}
/** Revision number of the document */
private int ccRevision;
/** Backing document */
private final Document contents;
/**
* Stores the doc ops used to build the document, where the doc op at index i was applied to form
* the document at revision i. There is a null value at index 0 since there was no doc op that
* gave birth to the document.
*/
private final List<AppliedDocOp> docOpHistory;
/**
* Intended revision of the last doc op from each client. If we see the same revision twice, we
* consider the second a duplicate and discard it. One such scenario would be when the client
* re-sends an unacked doc op after being momentarily disconnected, but the original doc op
* actually did make it to the server.
*/
private final Map<String, Integer> lastIntendedCcRevisionPerClient = Maps.newHashMap();
private final Logger logger;
/**
* Constructs a new {@link VersionedDocument} with the given contents and revision number
*/
public VersionedDocument(Document contents, int ccRevision, Logger logger) {
this.ccRevision = ccRevision;
this.contents = contents;
this.logger = logger;
// See javadoc for docOpHistory to understand the null element
this.docOpHistory = Lists.newArrayList((AppliedDocOp) null);
}
/**
* Constructs a new {@link VersionedDocument} with the given initial contents. The revision number
* starts at zero.
*/
public VersionedDocument(String initialContents, Logger logger) {
this(Document.createFromString(initialContents), 0, logger);
}
public int getCcRevision() {
return ccRevision;
}
/**
* Applies the given list of {@link DocOp DocOps} to the backing document.
*
* @param docOps the list of {@code DocOp}s being applied
* @param authorClientId clientId who sent the doc ops
* @param intendedCcRevision the revision of the document that the DocOps are intended to be
* applied to
* @param selection see {@link ClientToServerDocOp#getSelection()}
* @return the transformed doc ops, or <code>null</code> if we discarded them as duplicates
*/
public ConsumeResult consume(List<? extends DocOp> docOps, String authorClientId,
int intendedCcRevision, DocumentSelection selection) throws DocumentOperationException {
return consumeWithoutLocking(docOps, authorClientId, intendedCcRevision, selection);
}
/**
* Private helper method that does the actual work of consuming doc ops. The publicly-visible
* {@link #consume(List, String, int, DocumentSelection)} takes care of acquiring/releasing the
* write lock around calls to this method.
*/
private ConsumeResult consumeWithoutLocking(List<? extends DocOp> docOps, String authorClientId,
int intendedCcRevision, DocumentSelection selection) throws DocumentOperationException {
// Check the incoming intended revision against what we last got from this
// client
Integer lastIntendedCcRevision = lastIntendedCcRevisionPerClient.get(authorClientId);
if (lastIntendedCcRevision != null) {
if (intendedCcRevision == lastIntendedCcRevision.intValue()) {
// We've already seen a doc op from this client intended for this
// revision, assume this is a retry and ignore
logger.debug(String.format(
"clientId [%s] already sent a doc op intended for revision [%d]; "
+ "ignoring this one ", authorClientId, intendedCcRevision));
return null;
}
// Sanity check that the client is not sending an obsolete doc op
if (intendedCcRevision < lastIntendedCcRevision.intValue()) {
logger.error(String.format(
"clientId [%s] is sending a doc op intended for revision [%d] older than "
+ "the last one [%d] we saw from that client", authorClientId, intendedCcRevision,
lastIntendedCcRevision.intValue()));
return null;
}
}
/*
* First step, build the bridge from the intended revision to the latest revision by composing
* all of the doc ops between these ranges. This bridge will be used to update a client doc op
* that's intended to be applied to a document in the past.
*/
DocOp bridgeDocOp = null;
int bridgeBeginIndex = intendedCcRevision + 1;
int bridgeEndIndexInclusive = ccRevision;
for (int i = bridgeBeginIndex; i <= bridgeEndIndexInclusive; i++) {
DocOp curDocOp = docOpHistory.get(i).docOp;
try {
bridgeDocOp = bridgeDocOp == null ? curDocOp : Composer.compose(
ServerDocOpFactory.INSTANCE, bridgeDocOp, curDocOp);
} catch (ComposeException e) {
throw newExceptionForConsumeWithoutLocking("Could not build bridge",
e,
intendedCcRevision,
bridgeBeginIndex,
bridgeEndIndexInclusive,
docOps);
}
}
/*
* Second step, iterate through doc ops from the client and transform each against the bridge.
* Take the server op result of the transformation and make that the new bridge. Record each
* into our map that will be returned to the caller of this method.
*/
SortedMap<Integer, AppliedDocOp> appliedDocOps = new TreeMap<Integer, AppliedDocOp>();
for (int i = 0, n = docOps.size(); i < n; i++) {
DocOp clientDocOp = docOps.get(i);
if (bridgeDocOp != null) {
try {
OperationPair transformedPair =
Transformer.transform(ServerDocOpFactory.INSTANCE, clientDocOp, bridgeDocOp);
clientDocOp = transformedPair.clientOp();
bridgeDocOp = transformedPair.serverOp();
} catch (TransformException e) {
throw newExceptionForConsumeWithoutLocking("Could not transform doc op\ni: " + i + "\n",
e,
intendedCcRevision,
bridgeBeginIndex,
bridgeEndIndexInclusive,
docOps);
}
}
try {
DocOpApplier.apply(clientDocOp, contents);
} catch (Throwable t) {
throw newExceptionForConsumeWithoutLocking("Could not apply doc op\nDoc op being applied: "
+ DocOpUtils.toString(clientDocOp, true) + "\n",
t,
intendedCcRevision,
bridgeBeginIndex,
bridgeEndIndexInclusive,
docOps);
}
AppliedDocOp appliedDocOp = new AppliedDocOp(clientDocOp, authorClientId);
docOpHistory.add(appliedDocOp);
ccRevision++;
appliedDocOps.put(ccRevision, appliedDocOp);
lastIntendedCcRevisionPerClient.put(authorClientId, intendedCcRevision);
}
if (bridgeDocOp != null && selection != null) {
PositionTransformer cursorTransformer = new PositionTransformer(
selection.getCursorPosition().getLineNumber(), selection.getCursorPosition().getColumn());
cursorTransformer.transform(bridgeDocOp);
PositionTransformer baseTransformer = new PositionTransformer(
selection.getBasePosition().getLineNumber(), selection.getBasePosition().getColumn());
baseTransformer.transform(bridgeDocOp);
FilePositionImpl basePosition = FilePositionImpl.make().setLineNumber(
baseTransformer.getLineNumber()).setColumn(baseTransformer.getColumn());
FilePositionImpl cursorPosition = FilePositionImpl.make().setLineNumber(
cursorTransformer.getLineNumber()).setColumn(cursorTransformer.getColumn());
DocumentSelectionImpl transformedSelection = DocumentSelectionImpl.make()
.setBasePosition(basePosition).setCursorPosition(cursorPosition)
.setUserId(selection.getUserId());
selection = transformedSelection;
}
return new ConsumeResult(appliedDocOps, selection);
}
private DocumentOperationException newExceptionForConsumeWithoutLocking(String customMessage,
Throwable e,
int intendedCcRevision,
int bridgeBeginIndex,
int bridgeEndIndexInclusive,
List<? extends DocOp> clientDocOps) {
StringBuilder msg = new StringBuilder(customMessage).append('\n');
msg.append("ccRevision: ").append(ccRevision).append('\n');
msg.append("intendedCcRevision: ").append(intendedCcRevision).append('\n');
msg.append("Bridge from ")
.append(bridgeBeginIndex)
.append(" to ")
.append(bridgeEndIndexInclusive)
.append(" doc ops:\n")
.append(docOpHistory.subList(bridgeBeginIndex, bridgeEndIndexInclusive + 1))
.append("\n");
msg.append("Document (hyphens are line separators):\n").append(contents.asDebugString());
msg.append("Client doc ops:\n")
.append(DocOpUtils.toString(clientDocOps, 0, clientDocOps.size() - 1, true)).append("\n");
msg.append("Recent doc ops from server history:\n").append(docOpHistoryToString());
return new DocumentOperationException(msg.toString(), e);
}
public SortedMap<Integer, AppliedDocOp> getAppliedDocOps(int startingCcRevision) {
SortedMap<Integer, AppliedDocOp> appliedDocOps = new TreeMap<Integer, AppliedDocOp>();
if (startingCcRevision > (docOpHistory.size() - 1)) {
logger.error(String.format(
"startingCcRevision [%d] is larger than last revision in docOpHistory [%d]",
startingCcRevision, docOpHistory.size()));
return appliedDocOps;
}
for (int i = startingCcRevision; i < docOpHistory.size(); i++) {
appliedDocOps.put(i, docOpHistory.get(i));
}
return appliedDocOps;
}
public VersionedText asText() {
return new VersionedText(ccRevision, contents.asText());
}
/**
* @param column the column of the anchor, or {@link AnchorManager#IGNORE_COLUMN} for a line
* anchor
*/
public Anchor addAnchor(AnchorType type, int lineNumber, int column) {
LineInfo lineInfo = contents.getLineFinder().findLine(lineNumber);
return contents.getAnchorManager()
.createAnchor(type, lineInfo.line(), lineInfo.number(), column);
}
/**
* @param column the column of the anchor, or {@link AnchorManager#IGNORE_COLUMN} for a line
* anchor
*/
public void moveAnchor(Anchor anchor, int lineNumber, int column) {
LineInfo lineInfo = contents.getLineFinder().findLine(lineNumber);
contents.getAnchorManager().moveAnchor(anchor, lineInfo.line(), lineInfo.number(), column);
}
public void removeAnchor(Anchor anchor) {
contents.getAnchorManager().removeAnchor(anchor);
}
private String docOpHistoryToString() {
List<DocOp> docOps = Lists.newArrayListWithExpectedSize(docOpHistory.size());
for (AppliedDocOp appliedDocOp : docOpHistory) {
docOps.add(appliedDocOp == null ? null : appliedDocOp.docOp);
}
return DocOpUtils.toString(
docOps, Math.max(0, docOps.size() - 10), Math.max(0, docOps.size() - 1), false);
}
}