/** * Copyright 2010 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ package org.waveprotocol.box.server.robots.operations; import com.google.common.base.Preconditions; import com.google.wave.api.Element; import com.google.wave.api.Gadget; import com.google.wave.api.InvalidRequestException; import com.google.wave.api.JsonRpcConstant.ParamsProperty; import com.google.wave.api.OperationRequest; import com.google.wave.api.Range; import com.google.wave.api.data.ApiView; import com.google.wave.api.data.DocumentHitIterator; import com.google.wave.api.impl.DocumentModifyAction; import com.google.wave.api.impl.DocumentModifyAction.BundledAnnotation; import com.google.wave.api.impl.DocumentModifyAction.ModifyHow; import com.google.wave.api.impl.DocumentModifyQuery; import org.waveprotocol.box.server.robots.OperationContext; import org.waveprotocol.box.server.robots.util.OperationUtil; import org.waveprotocol.wave.model.conversation.ObservableConversation; import org.waveprotocol.wave.model.document.Document; import org.waveprotocol.wave.model.document.RangedAnnotation; import org.waveprotocol.wave.model.document.util.LineContainers; import org.waveprotocol.wave.model.document.util.XmlStringBuilder; import org.waveprotocol.wave.model.gadget.GadgetXmlUtil; import org.waveprotocol.wave.model.wave.ParticipantId; import org.waveprotocol.wave.model.wave.opbased.OpBasedWavelet; /** * Implements the "document.modify" operations. * * <p> * The documentModify operation has three bits: the where: where is the * modification applied. a range, index, element or annotation can be specified. * Any of these is the converted to a range. If nothing appropriate is * specified, the entire document is taken. the how: do we insert (before), * append or replace? the what: what is inserted/replaced. * * @author ljvderijk@google.com (Lennard de Rijk) */ public class DocumentModifyService implements OperationService { @Override public void execute( OperationRequest operation, OperationContext context, ParticipantId participant) throws InvalidRequestException { String blipId = OperationUtil.getRequiredParameter(operation, ParamsProperty.BLIP_ID); DocumentModifyAction modifyAction = OperationUtil.getRequiredParameter(operation, ParamsProperty.MODIFY_ACTION); OpBasedWavelet wavelet = context.openWavelet(operation, participant); ObservableConversation conversation = context.openConversation(operation, participant).getRoot(); Document doc = context.getBlip(conversation, blipId).getContent(); ApiView view = new ApiView(doc, wavelet); DocumentHitIterator hitIterator = getDocumentHitIterator(operation, view); switch (modifyAction.getModifyHow()) { case ANNOTATE: annotate(operation, doc, view, hitIterator, modifyAction); break; case CLEAR_ANNOTATION: clearAnnotation(operation, doc, view, hitIterator, modifyAction); break; case DELETE: delete(operation, view, hitIterator); break; case INSERT: insert(operation, doc, view, hitIterator, modifyAction); break; case INSERT_AFTER: insertAfter(operation, doc, view, hitIterator, modifyAction); break; case REPLACE: replace(operation, doc, view, hitIterator, modifyAction); break; case UPDATE_ELEMENT: // TODO(ljvderijk): Implement update of gadgets and other elements default: throw new UnsupportedOperationException( "Unsupported ModifyHow " + modifyAction.getModifyHow()); } } /** * Returns the {@link DocumentHitIterator} for the area that needs to be * modified as specified in the robot operation. * * @param operation the operation that specifies where the modifications need * to be applied. * @param view the {@link ApiView} of the document that needs to be modified. * @throws InvalidRequestException if more than one "where" parameter is * specified. */ private DocumentHitIterator getDocumentHitIterator(OperationRequest operation, ApiView view) throws InvalidRequestException { Range range = OperationUtil.getOptionalParameter(operation, ParamsProperty.RANGE); Integer index = OperationUtil.getOptionalParameter(operation, ParamsProperty.INDEX); DocumentModifyQuery query = OperationUtil.getOptionalParameter(operation, ParamsProperty.MODIFY_QUERY); DocumentHitIterator hitIterator; if (range != null) { if (index != null || query != null) { throw new InvalidRequestException( "At most one parameter out of RANGE, INDEX and MODIFY_QUERY must be specified", operation); } // Use the specified range hitIterator = new DocumentHitIterator.Singleshot(range); } else if (index != null) { if (query != null) { // range is null. throw new InvalidRequestException( "At most one parameter out of RANGE, INDEX and MODIFY_QUERY must be specified", operation); } // Use exactly the location of the index hitIterator = new DocumentHitIterator.Singleshot(new Range(index, index + 1)); } else if (query != null) { // range and index are both null. // Use the query hitIterator = new DocumentHitIterator.ElementMatcher( view, query.getElementMatch(), query.getRestrictions(), query.getMaxRes()); } else { // Take entire document since nothing appropriate was specified hitIterator = new DocumentHitIterator.Singleshot(new Range(0, view.apiContents().length())); } return hitIterator; } /** * Annotates the given ranges of the document as indicated by the * {@link DocumentModifyAction}. * * @param operation the operation to execute. * @param doc the document to annotate. * @param view the view of the document. * @param hitIterator iterates over the ranges to annotate, specified in * {@link ApiView} offset. * @param modifyAction the {@link DocumentModifyAction} specifying what the * annotation is. * @throws InvalidRequestException if the annotation could not be set. */ private void annotate(OperationRequest operation, Document doc, ApiView view, DocumentHitIterator hitIterator, DocumentModifyAction modifyAction) throws InvalidRequestException { Preconditions.checkArgument( modifyAction.getModifyHow() == ModifyHow.ANNOTATE, "This method only supports ANNOTATE"); String annotationKey = modifyAction.getAnnotationKey(); int valueIndex = 0; Range range = hitIterator.next(); while (range != null) { int start = view.transformToXmlOffset(range.getStart()); int end = view.transformToXmlOffset(range.getEnd()); setDocumentAnnotation( operation, doc, start, end, annotationKey, modifyAction.getValue(valueIndex)); valueIndex++; range = hitIterator.next(); } } /** * Clears the annotation for the given ranges of the document as indicated by * the {@link DocumentModifyAction}. * * @param operation the operation to execute. * @param doc the document to annotate. * @param view the view of the document. * @param hitIterator iterates over the ranges to remove the annotation from, * specified in {@link ApiView} offset. * @param modifyAction the {@link DocumentModifyAction} specifying what the * key of the annotation is annotation is. * @throws InvalidRequestException if the annotation could not be set. */ private void clearAnnotation(OperationRequest operation, Document doc, ApiView view, DocumentHitIterator hitIterator, DocumentModifyAction modifyAction) throws InvalidRequestException { Preconditions.checkArgument(modifyAction.getModifyHow() == ModifyHow.CLEAR_ANNOTATION, "This method only supports CLEAR_ANNOTATION"); String annotationKey = modifyAction.getAnnotationKey(); Range range = hitIterator.next(); while (range != null) { int start = view.transformToXmlOffset(range.getStart()); int end = view.transformToXmlOffset(range.getEnd()); setDocumentAnnotation(operation, doc, start, end, annotationKey, null); range = hitIterator.next(); } } /** * Sets the annotation for a document. * * @param operation the operation requesting the annotation to be set. * @param doc the document to change the annotation in. * @param start where the annotation should start. * @param end where the annotation should end. * @param key the key of the annotation. * @param value the value of the annotation. * @throws InvalidRequestException if the annotation could not be set. */ private void setDocumentAnnotation( OperationRequest operation, Document doc, int start, int end, String key, String value) throws InvalidRequestException { try { doc.setAnnotation(start, end, key, value); } catch (IndexOutOfBoundsException e) { throw new InvalidRequestException( "Can't set annotation for out of bounds indices " + e.getMessage(), operation, e); } } /** * Deletes ranges of elements from a document as specified by the iterator. * * @param operation the operation to execute. * @param view the view of the document. * @param hitIterator iterates over the ranges of elements to delete. * @throws InvalidRequestException if the specified range was invalid. */ private void delete(OperationRequest operation, ApiView view, DocumentHitIterator hitIterator) throws InvalidRequestException { Range range = hitIterator.next(); while (range != null) { int start = range.getStart(); int end = range.getEnd(); if (start == 0) { // Can't delete the first new line. start = 1; } if (start >= end) { throw new InvalidRequestException( "Invalid range specified, " + start + ">=" + end, operation); } // Delete using the view. view.delete(start, end); // Shift the iterator to match the updated document. hitIterator.shift(start, end - start); range = hitIterator.next(); } } /** * Inserts elements at the position specified by the hitIterator. * * @param operation the operation that wants to insert elements. * @param doc the document to insert elements in. * @param view the {@link ApiView} of that document. * @param hitIterator the iterator over the places where to insert. * @param modifyAction the action that specifies what to insert. * @throws InvalidRequestException if something goes wrong. */ private void insert(OperationRequest operation, Document doc, ApiView view, DocumentHitIterator hitIterator, DocumentModifyAction modifyAction) throws InvalidRequestException { int valueIndex = 0; Range range = hitIterator.next(); while (range != null) { int insertAt = range.getStart(); int inserted = insertInto(operation, doc, view, insertAt, modifyAction, valueIndex); hitIterator.shift(insertAt, inserted); valueIndex++; range = hitIterator.next(); } } /** * Inserts elements after the position specified by the hitIterator. * * @param operation the operation that wants to insert elements. * @param doc the document to insert elements in. * @param view the {@link ApiView} of that document. * @param hitIterator the iterator over the places where to insert. * @param modifyAction the action that specifies what to insert. * @throws InvalidRequestException if something goes wrong. */ private void insertAfter(OperationRequest operation, Document doc, ApiView view, DocumentHitIterator hitIterator, DocumentModifyAction modifyAction) throws InvalidRequestException { int valueIndex = 0; Range range = hitIterator.next(); while (range != null) { int insertAt = range.getEnd(); int inserted = insertInto(operation, doc, view, insertAt, modifyAction, valueIndex); hitIterator.shift(insertAt, inserted); valueIndex++; range = hitIterator.next(); } } /** * Replaces elements at the position specified by the hitIterator with the * elements specified in the {@link DocumentModifyAction}. * * @param operation the operation that wants to replace elements. * @param doc the document to replace elements in. * @param view the {@link ApiView} of that document. * @param hitIterator the iterator over the places where to replace elements. * @param modifyAction the action that specifies what to replace the elements * with. * @throws InvalidRequestException if something goes wrong. */ private void replace(OperationRequest operation, Document doc, ApiView view, DocumentHitIterator hitIterator, DocumentModifyAction modifyAction) throws InvalidRequestException { int valueIndex = 0; Range range = hitIterator.next(); while (range != null) { int replaceAt = range.getStart(); int numInserted = insertInto(operation, doc, view, replaceAt, modifyAction, valueIndex); // Remove the text after what was inserted (so it looks like it has been // replaced). view.delete(replaceAt + numInserted, range.getEnd() + numInserted); // Shift the iterator from the start of the replacement with the amount of // characters that have been added. int numRemoved = Math.min(0, range.getStart() - range.getEnd()); hitIterator.shift(replaceAt, numInserted + numRemoved); valueIndex++; range = hitIterator.next(); } } /** * Inserts elements into the document at a specified location. * * @param operation the operation that wants to insert elements. * @param doc the document to insert elements in. * @param view the {@link ApiView} of that document. * @param insertAt the {@link ApiView} value of where to insert elements. * @param modifyAction the action that specifies what to insert. * @param valueIndex the index to use for * {@link DocumentModifyAction#getValue(int)}, to find out what to * insert. * @throws InvalidRequestException if something goes wrong. */ private int insertInto(OperationRequest operation, Document doc, ApiView view, int insertAt, DocumentModifyAction modifyAction, int valueIndex) throws InvalidRequestException { if (modifyAction.hasTextAt(valueIndex)) { String toInsert = modifyAction.getValue(valueIndex); if (insertAt == 0) { // Make sure that we have a newline as first character. if (!toInsert.isEmpty() && toInsert.charAt(0) != '\n') { toInsert = '\n' + toInsert; } } // Insert text. view.insert(insertAt, toInsert); // Do something with annotations? if (modifyAction.getBundledAnnotations() != null) { int annotationStart = view.transformToXmlOffset(insertAt); int annotationEnd = view.transformToXmlOffset(insertAt + toInsert.length()); for (RangedAnnotation<String> annotation : doc.rangedAnnotations(annotationStart, annotationEnd, null)) { setDocumentAnnotation( operation, doc, annotationStart, annotationEnd, annotation.key(), null); } for (BundledAnnotation ia : modifyAction.getBundledAnnotations()) { setDocumentAnnotation(operation, doc, annotationStart, annotationEnd, ia.key, ia.value); } } return toInsert.length(); } else { Element element = modifyAction.getElement(valueIndex); if (element != null) { if (element.isGadget()) { Gadget gadget = (Gadget) element; XmlStringBuilder xml = GadgetXmlUtil.constructXml(gadget.getUrl(), "", gadget.getAuthor(), null, gadget.getProperties()); // TODO (Yuri Z.) Make it possible to specify a location to insert the // gadget and implement insertion at the specified location. LineContainers.appendLine(doc, xml); } else { // TODO(ljvderijk): Inserting other elements. throw new UnsupportedOperationException( "Can't insert other elements than text and gadgets at the moment"); } } // should return 1 since elements have a length of 1 in the ApiView; return 1; } } public static DocumentModifyService create() { return new DocumentModifyService(); } }